rayforge-burgerguy 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rayforge_burgerguy-0.3.0/PKG-INFO +11 -0
- rayforge_burgerguy-0.3.0/README.md +63 -0
- rayforge_burgerguy-0.3.0/pyproject.toml +17 -0
- rayforge_burgerguy-0.3.0/setup.cfg +4 -0
- rayforge_burgerguy-0.3.0/src/rayforge/__init__.py +43 -0
- rayforge_burgerguy-0.3.0/src/rayforge/ai.py +213 -0
- rayforge_burgerguy-0.3.0/src/rayforge/app.py +144 -0
- rayforge_burgerguy-0.3.0/src/rayforge/camera.py +35 -0
- rayforge_burgerguy-0.3.0/src/rayforge/caster.py +80 -0
- rayforge_burgerguy-0.3.0/src/rayforge/collision.py +24 -0
- rayforge_burgerguy-0.3.0/src/rayforge/combat.py +33 -0
- rayforge_burgerguy-0.3.0/src/rayforge/effects.py +37 -0
- rayforge_burgerguy-0.3.0/src/rayforge/entities.py +245 -0
- rayforge_burgerguy-0.3.0/src/rayforge/factory.py +76 -0
- rayforge_burgerguy-0.3.0/src/rayforge/grid.py +129 -0
- rayforge_burgerguy-0.3.0/src/rayforge/hud.py +121 -0
- rayforge_burgerguy-0.3.0/src/rayforge/level.py +77 -0
- rayforge_burgerguy-0.3.0/src/rayforge/mode7.py +174 -0
- rayforge_burgerguy-0.3.0/src/rayforge/particles.py +69 -0
- rayforge_burgerguy-0.3.0/src/rayforge/png.py +18 -0
- rayforge_burgerguy-0.3.0/src/rayforge/render.py +214 -0
- rayforge_burgerguy-0.3.0/src/rayforge/sound.py +161 -0
- rayforge_burgerguy-0.3.0/src/rayforge/textures.py +346 -0
- rayforge_burgerguy-0.3.0/src/rayforge/track.py +149 -0
- rayforge_burgerguy-0.3.0/src/rayforge/util.py +86 -0
- rayforge_burgerguy-0.3.0/src/rayforge/vehicle.py +234 -0
- rayforge_burgerguy-0.3.0/src/rayforge/weapons.py +122 -0
- rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/PKG-INFO +11 -0
- rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/SOURCES.txt +32 -0
- rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/dependency_links.txt +1 -0
- rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/requires.txt +8 -0
- rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/top_level.txt +1 -0
- rayforge_burgerguy-0.3.0/tests/test_rayforge.py +54 -0
- rayforge_burgerguy-0.3.0/tests/test_toolkit.py +288 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rayforge-burgerguy
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A small, renderer-agnostic raycasting engine library
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: numpy
|
|
7
|
+
Provides-Extra: pygame
|
|
8
|
+
Requires-Dist: pygame; extra == "pygame"
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == "dev"
|
|
11
|
+
Requires-Dist: pillow; extra == "dev"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# rayforge 0.3
|
|
2
|
+
|
|
3
|
+
A toolkit for raycaster (Wolf3D/Doom-style) and Mode 7 (SNES kart-style) games in Python.
|
|
4
|
+
Core needs only NumPy. pygame is optional (windowing, HUD, sound playback). No art or audio files required.
|
|
5
|
+
|
|
6
|
+
pip install -e . # inside this folder
|
|
7
|
+
pip install pygame # for App / hud / sound playback
|
|
8
|
+
python examples/doom_full.py python examples/kart_race.py (add --demo to watch a bot play)
|
|
9
|
+
python -m pytest # 35 tests
|
|
10
|
+
|
|
11
|
+
Every pygame app also runs headless: `--frames 120 --screenshot out.png`.
|
|
12
|
+
|
|
13
|
+
## What's in the box
|
|
14
|
+
|
|
15
|
+
| Area | Pieces |
|
|
16
|
+
|---|---|
|
|
17
|
+
| **Raycasting** | `GridMap`, `Door`, `Camera`, `cast_ray`, `line_of_sight`, `hitscan`, `Renderer` (textured walls/floors/ceilings, per-cell floors via `floor_map`, sky panorama, coloured fog, doors, billboard `Sprite`s with `z`/`flip`/`fullbright`) |
|
|
18
|
+
| **Mode 7 racing** | `Mode7Renderer`, `M7Camera`, `ChaseCam`, `M7Sprite` (ground shadows, side-on squash), `Track` (spline -> image + surface map + waypoints + grid + minimap, `Track.preset('twisty'/'oval'/'kidney')`), `Vehicle` (drift, mini-turbo, surfaces, collisions, spin-out), `AIDriver`, `Race` (countdown, laps, positions, rubber banding) |
|
|
19
|
+
| **Shooter kit** | `World`, `Entity`, `Actor`, `Player`, `Projectile`, `Pickup`, `Enemy` (idle/chase/attack/pain/dead, A* paths, opens doors, melee or ranged), `astar`, `Inventory` + `PISTOL/SHOTGUN/CHAINGUN/ROCKET`, `Level.from_text`, `create('imp'/'health'/...)`, `register(...)` for your own kinds |
|
|
20
|
+
| **Art** | `textures.*` procedural walls, floors, skies, sprites (imp, kart, tree, cone, barrel, crate, medkit...), `Canvas` to draw your own, `load_image`, `spritesheet`, `Animation` |
|
|
21
|
+
| **Polish** | `ParticleSystem` (both renderers), `effects` (tint, fade, vignette, scanlines, shake, pixelate...), `Shake`, `Timer`, `Cooldown`, easing/math in `util` |
|
|
22
|
+
| **Audio** | `SoundBank` plays ~20 synthesized sounds by name; `.play_events(world.pop_events())`, `.engine(speed)`; `tone`/`noise`/`mix` to make more. Silent no-op without an audio device |
|
|
23
|
+
| **App kit** | `App` (window, loop, fixed-step headless mode), `Input.fps()/racing()` named actions, `hud` (text, bars, crosshair, minimap, `track_minimap`, weapons, flash, `blit_frame`) |
|
|
24
|
+
| **Misc** | `save_png` (no Pillow needed), `move_circle` collision |
|
|
25
|
+
|
|
26
|
+
## Recipes
|
|
27
|
+
|
|
28
|
+
**Minimal raycaster**
|
|
29
|
+
```python
|
|
30
|
+
import rayforge as rf
|
|
31
|
+
world = rf.GridMap.from_strings(["#####", "#...#", "#####"])
|
|
32
|
+
frame = rf.Renderer(320, 200, textures={1: rf.textures.brick()}).render(world, rf.Camera(2.5, 1.5))
|
|
33
|
+
rf.save_png(frame, "out.png")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Shooter from a text map** (see examples/doom_full.py)
|
|
37
|
+
```python
|
|
38
|
+
level = rf.Level.from_text(open("e1m1.txt").read()) # P start, # walls, D doors, R/B locked, i/z/b/g enemies, h/a pickups, E exit
|
|
39
|
+
world = level.build_world(seed=1); player = world.player
|
|
40
|
+
# each frame: world.update(dt); player.inventory.fire(world, player); world.use()
|
|
41
|
+
# draw: renderer.render(world.grid, player.cam, world.sprites())
|
|
42
|
+
# react: for name, data in world.pop_events(): ... # shot, enemy_death, pickup, door, player_hurt, exit...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Racer** (see examples/kart_race.py)
|
|
46
|
+
```python
|
|
47
|
+
track = rf.Track.from_points([(20,40),(60,20),(100,50),(60,80)], width=7) # your own circuit
|
|
48
|
+
race = rf.Race(track, laps=3); kart = rf.Vehicle(*track.start)
|
|
49
|
+
race.add(rf.Racer(kart, "me", is_player=True))
|
|
50
|
+
kart.update(dt, throttle, steer, brake, drift, surface=track.surface_at(kart.x, kart.y))
|
|
51
|
+
race.update(dt) # race.place(racer), race.countdown_text(), race.standings(), race.pop_events()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**A new kind of enemy / pickup**
|
|
55
|
+
```python
|
|
56
|
+
rf.ENEMY_TYPES["cyber"] = rf.EnemyType("cyber", hp=40, speed=2.2, ranged=True, damage=15, color=(60,200,220), scale=1.1)
|
|
57
|
+
rf.register("cyber", lambda x, y: rf.Enemy(x, y, "cyber")) # now 'c'-style spawns / create('cyber', x, y) work
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Conventions
|
|
61
|
+
- Angles are radians; 0 = +x, +pi/2 = +y (down the map). `right = (-sin a, cos a)`.
|
|
62
|
+
- Raycaster sizes are in map cells / wall heights; Mode 7 sizes are in world units (`scale` px per unit on the track image).
|
|
63
|
+
- Textures are `uint8` arrays: `HxWx3` for walls/floors, `HxWx4` (alpha) for sprites.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rayforge-burgerguy"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "A small, renderer-agnostic raycasting engine library"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = ["numpy"]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
pygame = ["pygame"]
|
|
14
|
+
dev = ["pytest", "pillow"]
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""rayforge: a toolkit for raycasting and Mode-7 games.
|
|
2
|
+
|
|
3
|
+
Core (pure Python + NumPy): GridMap, Camera, cast_ray, Renderer, Sprite, hitscan, textures
|
|
4
|
+
Mode 7 racing: Mode7Renderer, M7Camera, M7Sprite, ChaseCam, Track, Vehicle, AIDriver, Race
|
|
5
|
+
Game kit: World, Entity, Actor, Player, Enemy, Projectile, Pickup, Inventory, Level, create()
|
|
6
|
+
Extras: ParticleSystem, effects, SoundBank (procedural audio), save_png, util helpers
|
|
7
|
+
pygame helpers (loaded on demand): App, Input, hud
|
|
8
|
+
"""
|
|
9
|
+
import importlib
|
|
10
|
+
|
|
11
|
+
from .util import (clamp, lerp, inv_lerp, remap, wrap_angle, angle_diff, approach, smoothstep,
|
|
12
|
+
dist, normalize, Timer, Cooldown, Shake, ease_in, ease_out, ease_in_out)
|
|
13
|
+
from .grid import GridMap, Door
|
|
14
|
+
from .collision import move_circle, circle_blocked
|
|
15
|
+
from .camera import Camera
|
|
16
|
+
from .caster import RayHit, cast_ray, cast_rays, line_of_sight
|
|
17
|
+
from .combat import ShotResult, hitscan
|
|
18
|
+
from .render import Renderer, Sprite, sprite_view_index
|
|
19
|
+
from . import textures, effects
|
|
20
|
+
from .textures import Animation, load_image, spritesheet
|
|
21
|
+
from .particles import ParticleSystem
|
|
22
|
+
from .mode7 import Mode7Renderer, M7Camera, M7Sprite, ChaseCam
|
|
23
|
+
from .track import Track, GRASS, ROAD, CURB, BOOST, OFFROAD
|
|
24
|
+
from .vehicle import Vehicle, AIDriver, Race, Racer, SURFACE_PROPS
|
|
25
|
+
from .entities import Entity, Actor, Projectile, Pickup, Player, World
|
|
26
|
+
from .weapons import WeaponDef, Inventory, PISTOL, SHOTGUN, CHAINGUN, ROCKET, WEAPONS
|
|
27
|
+
from .ai import Enemy, EnemyType, ENEMY_TYPES, astar
|
|
28
|
+
from .factory import create, register, kinds
|
|
29
|
+
from .level import Level, Spawn
|
|
30
|
+
from .sound import SoundBank
|
|
31
|
+
from .png import save_png
|
|
32
|
+
|
|
33
|
+
__version__ = "0.3.0"
|
|
34
|
+
|
|
35
|
+
_LAZY = {"App": "app", "Input": "app", "parse_args": "app"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __getattr__(name): # pygame-dependent bits load only when used
|
|
39
|
+
if name in ("hud", "app"):
|
|
40
|
+
return importlib.import_module(f".{name}", __name__)
|
|
41
|
+
if name in _LAZY:
|
|
42
|
+
return getattr(importlib.import_module(f".{_LAZY[name]}", __name__), name)
|
|
43
|
+
raise AttributeError(f"module 'rayforge' has no attribute {name!r}")
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Enemy AI: A* pathfinding, line-of-sight, and a ready-made Enemy actor
|
|
2
|
+
(idle -> chase -> attack / pain -> dead) with melee and ranged types."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import heapq, math
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, List, Optional, Tuple
|
|
7
|
+
from .caster import line_of_sight
|
|
8
|
+
from .entities import Actor, Projectile
|
|
9
|
+
from .render import Sprite
|
|
10
|
+
from .util import wrap_angle
|
|
11
|
+
from . import textures
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def astar(grid, start: Tuple[int, int], goal: Tuple[int, int], through_doors=True,
|
|
15
|
+
max_nodes=3000, through_locked=False) -> List[Tuple[float, float]]:
|
|
16
|
+
"""Shortest path over grid cells (8-way, no corner cutting). Returns cell
|
|
17
|
+
centres from the step after `start` to `goal`; [] if unreachable."""
|
|
18
|
+
if start == goal:
|
|
19
|
+
return []
|
|
20
|
+
ok = lambda x, y: grid.passable(x, y, through_doors, through_locked)
|
|
21
|
+
if not ok(*goal):
|
|
22
|
+
return []
|
|
23
|
+
def h(a, b):
|
|
24
|
+
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
|
|
25
|
+
return (dx + dy) + (1.4142 - 2) * min(dx, dy)
|
|
26
|
+
openq = [(h(start, goal), 0.0, start)]
|
|
27
|
+
came, g = {}, {start: 0.0}
|
|
28
|
+
n = 0
|
|
29
|
+
while openq and n < max_nodes:
|
|
30
|
+
_, cost, cur = heapq.heappop(openq)
|
|
31
|
+
if cur == goal:
|
|
32
|
+
path = [cur]
|
|
33
|
+
while cur in came:
|
|
34
|
+
cur = came[cur]; path.append(cur)
|
|
35
|
+
return [(x + .5, y + .5) for x, y in reversed(path[:-1])]
|
|
36
|
+
if cost > g.get(cur, 1e9):
|
|
37
|
+
continue
|
|
38
|
+
n += 1
|
|
39
|
+
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)):
|
|
40
|
+
nx, ny = cur[0] + dx, cur[1] + dy
|
|
41
|
+
if not ok(nx, ny):
|
|
42
|
+
continue
|
|
43
|
+
if dx and dy and not (ok(cur[0] + dx, cur[1]) and ok(cur[0], cur[1] + dy)):
|
|
44
|
+
continue
|
|
45
|
+
nc = cost + (1.4142 if dx and dy else 1.0)
|
|
46
|
+
if nc < g.get((nx, ny), 1e9):
|
|
47
|
+
g[(nx, ny)] = nc; came[(nx, ny)] = cur
|
|
48
|
+
heapq.heappush(openq, (nc + h((nx, ny), goal), nc, (nx, ny)))
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class EnemyType:
|
|
54
|
+
name: str
|
|
55
|
+
hp: float = 6
|
|
56
|
+
speed: float = 1.9
|
|
57
|
+
sight: float = 14.0
|
|
58
|
+
attack_range: float = 1.1
|
|
59
|
+
attack_cooldown: float = 1.2
|
|
60
|
+
windup: float = 0.3
|
|
61
|
+
damage: float = 8
|
|
62
|
+
ranged: bool = False
|
|
63
|
+
projectile_speed: float = 5.5
|
|
64
|
+
pain_chance: float = 0.6
|
|
65
|
+
pain_time: float = 0.25
|
|
66
|
+
scale: float = 0.85
|
|
67
|
+
radius: float = 0.3
|
|
68
|
+
color: Tuple[int, int, int] = (170, 55, 40)
|
|
69
|
+
score: int = 100
|
|
70
|
+
persistence: float = 30.0 # seconds an enemy keeps hunting after losing sight of you
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
ENEMY_TYPES: Dict[str, EnemyType] = {
|
|
74
|
+
"imp": EnemyType("imp", hp=6, speed=2.0, ranged=True, attack_range=9.0, attack_cooldown=1.7, damage=7),
|
|
75
|
+
"zombie": EnemyType("zombie", hp=4, speed=1.2, attack_range=1.1, damage=6, pain_chance=0.8,
|
|
76
|
+
color=(80, 140, 70), score=60),
|
|
77
|
+
"brute": EnemyType("brute", hp=20, speed=1.5, attack_range=1.3, damage=18, pain_chance=0.15, scale=1.05,
|
|
78
|
+
radius=0.4, color=(125, 60, 155), score=300),
|
|
79
|
+
"ghost": EnemyType("ghost", hp=5, speed=3.0, attack_range=1.0, damage=5, pain_chance=0.4,
|
|
80
|
+
color=(150, 185, 235), score=120),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_cache: Dict[Tuple, tuple] = {}
|
|
84
|
+
def _enemy_textures(color):
|
|
85
|
+
if color not in _cache:
|
|
86
|
+
base = textures.imp(64, color)
|
|
87
|
+
_cache[color] = (base, textures.tint(base, (255, 255, 255), 0.8), textures.corpse(base))
|
|
88
|
+
return _cache[color]
|
|
89
|
+
|
|
90
|
+
_fire = None
|
|
91
|
+
def _fireball():
|
|
92
|
+
global _fire
|
|
93
|
+
if _fire is None: _fire = textures.fireball(24)
|
|
94
|
+
return _fire
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Enemy(Actor):
|
|
98
|
+
def __init__(self, x, y, kind="imp"):
|
|
99
|
+
t = ENEMY_TYPES[kind] if isinstance(kind, str) else kind
|
|
100
|
+
self.type = t
|
|
101
|
+
self.tex, self.flash_tex, self.corpse_tex = _enemy_textures(t.color)
|
|
102
|
+
super().__init__(x, y, hp=t.hp, team="enemy", radius=t.radius, name=t.name,
|
|
103
|
+
sprite=Sprite(x, y, texture=self.tex, scale=t.scale))
|
|
104
|
+
self.state, self.state_t = "idle", 0.0
|
|
105
|
+
self.cool, self.flash, self.memory = 0.0, 0.0, 0.0
|
|
106
|
+
self.path: List[Tuple[float, float]] = []
|
|
107
|
+
self.path_t = 0.0
|
|
108
|
+
self.did_attack = False
|
|
109
|
+
|
|
110
|
+
# --- hooks ---
|
|
111
|
+
def hear(self):
|
|
112
|
+
if self.state == "idle" and not self.dead:
|
|
113
|
+
self.state, self.memory = "chase", self.type.persistence
|
|
114
|
+
|
|
115
|
+
def on_hurt(self, dmg, source, world):
|
|
116
|
+
self.flash = 0.12
|
|
117
|
+
if self.state == "idle":
|
|
118
|
+
self.state, self.memory = "chase", self.type.persistence
|
|
119
|
+
if self.state in ("chase", "idle") and world and world.rng.random() < self.type.pain_chance:
|
|
120
|
+
self.state, self.state_t = "pain", 0.0
|
|
121
|
+
if world: world.emit("enemy_hurt", enemy=self)
|
|
122
|
+
|
|
123
|
+
def on_death(self, source, world):
|
|
124
|
+
self.state, self.solid = "dead", False
|
|
125
|
+
self.sprite.texture, self.sprite.scale, self.sprite.z = self.corpse_tex, self.type.scale, 0.0
|
|
126
|
+
if source is not None and hasattr(source, "score"):
|
|
127
|
+
source.score += self.type.score
|
|
128
|
+
if world: world.emit("enemy_death", enemy=self)
|
|
129
|
+
|
|
130
|
+
# --- helpers ---
|
|
131
|
+
def _step(self, world, tx, ty, dt, speed=None):
|
|
132
|
+
d = math.hypot(tx - self.x, ty - self.y)
|
|
133
|
+
if d < 1e-6:
|
|
134
|
+
return
|
|
135
|
+
sp = (speed or self.type.speed) * dt
|
|
136
|
+
self.angle = math.atan2(ty - self.y, tx - self.x)
|
|
137
|
+
dx, dy = (tx - self.x) / d * sp, (ty - self.y) / d * sp
|
|
138
|
+
for o in world.query(self.x, self.y, self.radius * 2.2, lambda e: isinstance(e, Enemy) and e is not self and not e.dead):
|
|
139
|
+
ox, oy = self.x - o.x, self.y - o.y
|
|
140
|
+
od = math.hypot(ox, oy) or 1.0
|
|
141
|
+
dx += ox / od * sp * 0.5; dy += oy / od * sp * 0.5
|
|
142
|
+
if self.move(world.grid, dx, dy):
|
|
143
|
+
ax, ay = self.x + math.cos(self.angle) * 0.7, self.y + math.sin(self.angle) * 0.7
|
|
144
|
+
door = world.grid.get_door(int(ax // 1), int(ay // 1))
|
|
145
|
+
if door is not None and not door.key: # monsters can't pick locks
|
|
146
|
+
door.activate()
|
|
147
|
+
|
|
148
|
+
def _approach(self, world, p, sees, dt):
|
|
149
|
+
if sees:
|
|
150
|
+
self.path.clear()
|
|
151
|
+
self._step(world, p.x, p.y, dt)
|
|
152
|
+
return
|
|
153
|
+
self.path_t -= dt
|
|
154
|
+
if self.path_t <= 0 or not self.path:
|
|
155
|
+
self.path = astar(world.grid, (int(self.x // 1), int(self.y // 1)), (int(p.x // 1), int(p.y // 1)))
|
|
156
|
+
self.path_t = 0.6
|
|
157
|
+
while self.path and math.hypot(self.path[0][0] - self.x, self.path[0][1] - self.y) < 0.3:
|
|
158
|
+
self.path.pop(0)
|
|
159
|
+
if self.path:
|
|
160
|
+
self._step(world, *self.path[0], dt)
|
|
161
|
+
|
|
162
|
+
def _attack(self, world, p):
|
|
163
|
+
t = self.type
|
|
164
|
+
if t.ranged:
|
|
165
|
+
ang = math.atan2(p.y - self.y, p.x - self.x)
|
|
166
|
+
spr = Sprite(self.x, self.y, texture=_fireball(), scale=0.22, z=0.4, fullbright=True)
|
|
167
|
+
world.add(Projectile(self.x + math.cos(ang) * 0.4, self.y + math.sin(ang) * 0.4, ang,
|
|
168
|
+
t.projectile_speed, t.damage, self, sprite=spr))
|
|
169
|
+
world.emit("enemy_shoot", enemy=self)
|
|
170
|
+
elif math.hypot(p.x - self.x, p.y - self.y) <= t.attack_range + p.radius + 0.3:
|
|
171
|
+
p.hurt(t.damage, self, world)
|
|
172
|
+
world.emit("enemy_melee", enemy=self)
|
|
173
|
+
|
|
174
|
+
# --- frame ---
|
|
175
|
+
def update(self, dt, world):
|
|
176
|
+
self.age += dt
|
|
177
|
+
self.state_t += dt
|
|
178
|
+
self.flash = max(0.0, self.flash - dt)
|
|
179
|
+
self.cool = max(0.0, self.cool - dt)
|
|
180
|
+
self.sprite.texture = self.corpse_tex if self.dead else (self.flash_tex if self.flash > 0 else self.tex)
|
|
181
|
+
self.sync_sprite()
|
|
182
|
+
p = world.player
|
|
183
|
+
if self.dead or p is None or p.dead:
|
|
184
|
+
return
|
|
185
|
+
t = self.type
|
|
186
|
+
d = math.hypot(p.x - self.x, p.y - self.y)
|
|
187
|
+
sees = d < t.sight and line_of_sight(world.grid, self.x, self.y, p.x, p.y)
|
|
188
|
+
|
|
189
|
+
if self.state == "idle":
|
|
190
|
+
if sees:
|
|
191
|
+
self.state, self.memory = "chase", t.persistence
|
|
192
|
+
world.emit("enemy_alert", enemy=self)
|
|
193
|
+
elif self.state == "pain":
|
|
194
|
+
if self.state_t >= t.pain_time:
|
|
195
|
+
self.state, self.state_t = "chase", 0.0
|
|
196
|
+
elif self.state == "chase":
|
|
197
|
+
self.memory = t.persistence if sees else self.memory - dt
|
|
198
|
+
if self.memory <= 0:
|
|
199
|
+
self.state = "idle"
|
|
200
|
+
elif sees and d <= t.attack_range and self.cool <= 0:
|
|
201
|
+
self.state, self.state_t, self.did_attack = "attack", 0.0, False
|
|
202
|
+
self.angle = math.atan2(p.y - self.y, p.x - self.x)
|
|
203
|
+
elif not (sees and d <= (t.attack_range * 0.8 if not t.ranged else t.attack_range * 0.5)):
|
|
204
|
+
self._approach(world, p, sees, dt)
|
|
205
|
+
self.sprite.z = abs(math.sin(self.age * 9)) * 0.035
|
|
206
|
+
elif self.state == "attack":
|
|
207
|
+
self.sprite.z = 0.0
|
|
208
|
+
if not self.did_attack and self.state_t >= t.windup:
|
|
209
|
+
self.did_attack = True
|
|
210
|
+
self._attack(world, p)
|
|
211
|
+
self.cool = t.attack_cooldown
|
|
212
|
+
if self.state_t >= t.windup + 0.25:
|
|
213
|
+
self.state, self.state_t = "chase", 0.0
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Game framework: window, loop, input actions, mouse look, headless test mode.
|
|
2
|
+
|
|
3
|
+
class MyGame(App):
|
|
4
|
+
title, width, height, scale = "My game", 400, 250, 3
|
|
5
|
+
def setup(self): ... # build your world
|
|
6
|
+
def update(self, dt): ... # self.input.down("forward") etc.
|
|
7
|
+
def draw(self, screen): ... # draw into the window surface
|
|
8
|
+
MyGame().run()
|
|
9
|
+
|
|
10
|
+
Run any app headless with: python game.py --frames 120 --screenshot out.png
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import argparse, os
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_args(extra=None):
|
|
18
|
+
ap = argparse.ArgumentParser()
|
|
19
|
+
ap.add_argument("--frames", type=int, default=0, help="run N frames then quit (headless, fixed timestep)")
|
|
20
|
+
ap.add_argument("--screenshot", help="save the final frame to this PNG")
|
|
21
|
+
ap.add_argument("--seed", type=int)
|
|
22
|
+
ap.add_argument("--scale", type=int)
|
|
23
|
+
ap.add_argument("--demo", action="store_true", help="let your game run itself (if it supports it)")
|
|
24
|
+
ap.add_argument("--mute", action="store_true")
|
|
25
|
+
if extra: extra(ap)
|
|
26
|
+
return ap.parse_args()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Input:
|
|
30
|
+
"""Named actions bound to keys. input.down('fire'), input.pressed('use'), input.mouse_dx."""
|
|
31
|
+
def __init__(self):
|
|
32
|
+
import pygame
|
|
33
|
+
self.pg = pygame
|
|
34
|
+
self.bindings, self.mouse_dx, self.mouse_dy = {}, 0, 0
|
|
35
|
+
self._prev, self._cur, self._mouse_prev, self._mouse = {}, {}, (0, 0, 0), (0, 0, 0)
|
|
36
|
+
self._wheel = 0
|
|
37
|
+
|
|
38
|
+
def bind(self, action, *keys):
|
|
39
|
+
"""keys are pygame key names: 'w', 'space', 'left', 'lshift', 'return', 'mouse1'/'mouse2'/'mouse3'."""
|
|
40
|
+
self.bindings[action] = keys
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def fps(cls):
|
|
45
|
+
i = cls()
|
|
46
|
+
return (i.bind("forward", "w", "up").bind("back", "s", "down").bind("left", "a").bind("right", "d")
|
|
47
|
+
.bind("turn_left", "q", "left").bind("turn_right", "e", "right")
|
|
48
|
+
.bind("fire", "space", "mouse1").bind("sprint", "left shift").bind("use", "f", "return")
|
|
49
|
+
.bind("map", "m").bind("next_weapon", "x").bind("prev_weapon", "z")
|
|
50
|
+
.bind("weapon1", "1").bind("weapon2", "2").bind("weapon3", "3").bind("weapon4", "4")
|
|
51
|
+
.bind("restart", "r").bind("quit", "escape"))
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def racing(cls):
|
|
55
|
+
i = cls()
|
|
56
|
+
return (i.bind("accelerate", "w", "up").bind("brake", "s", "down").bind("left", "a", "left")
|
|
57
|
+
.bind("right", "d", "right").bind("drift", "left shift", "space").bind("item", "e", "left ctrl")
|
|
58
|
+
.bind("restart", "r").bind("quit", "escape"))
|
|
59
|
+
|
|
60
|
+
def _held(self, name, keys, mouse):
|
|
61
|
+
pg = self.pg
|
|
62
|
+
if name.startswith("mouse"):
|
|
63
|
+
return bool(mouse[int(name[5:]) - 1])
|
|
64
|
+
return bool(keys[pg.key.key_code(name)])
|
|
65
|
+
|
|
66
|
+
def poll(self, events=()):
|
|
67
|
+
pg = self.pg
|
|
68
|
+
keys, mouse = pg.key.get_pressed(), pg.mouse.get_pressed()
|
|
69
|
+
self._prev = self._cur
|
|
70
|
+
self._cur = {a: any(self._held(k, keys, mouse) for k in ks) for a, ks in self.bindings.items()}
|
|
71
|
+
self.mouse_dx, self.mouse_dy = pg.mouse.get_rel()
|
|
72
|
+
self._wheel = sum(e.y for e in events if e.type == pg.MOUSEWHEEL)
|
|
73
|
+
|
|
74
|
+
def down(self, a): return self._cur.get(a, False)
|
|
75
|
+
def pressed(self, a): return self._cur.get(a, False) and not self._prev.get(a, False)
|
|
76
|
+
def released(self, a): return self._prev.get(a, False) and not self._cur.get(a, False)
|
|
77
|
+
def axis(self, neg, pos): return float(self.down(pos)) - float(self.down(neg))
|
|
78
|
+
@property
|
|
79
|
+
def wheel(self): return self._wheel
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class App:
|
|
83
|
+
title = "rayforge"
|
|
84
|
+
width, height, scale, fps = 400, 250, 3, 60
|
|
85
|
+
mouse_look = False
|
|
86
|
+
input_factory = staticmethod(Input.fps)
|
|
87
|
+
|
|
88
|
+
def __init__(self, args=None):
|
|
89
|
+
self.args = args or parse_args()
|
|
90
|
+
self.frame_no, self.time, self.running = 0, 0.0, True
|
|
91
|
+
self.screen = self.clock = self.input = None
|
|
92
|
+
|
|
93
|
+
# ---- override these ----
|
|
94
|
+
def setup(self): pass
|
|
95
|
+
def update(self, dt): pass
|
|
96
|
+
def draw(self, screen): pass
|
|
97
|
+
def on_event(self, e): pass
|
|
98
|
+
|
|
99
|
+
# ---- helpers ----
|
|
100
|
+
@property
|
|
101
|
+
def window_size(self):
|
|
102
|
+
s = self.args.scale or self.scale
|
|
103
|
+
return self.width * s, self.height * s
|
|
104
|
+
|
|
105
|
+
def quit(self): self.running = False
|
|
106
|
+
|
|
107
|
+
def screenshot(self, path):
|
|
108
|
+
import pygame
|
|
109
|
+
pygame.image.save(self.screen, path)
|
|
110
|
+
|
|
111
|
+
def run(self):
|
|
112
|
+
a = self.args
|
|
113
|
+
if a.frames:
|
|
114
|
+
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
|
115
|
+
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
|
116
|
+
import pygame
|
|
117
|
+
pygame.init()
|
|
118
|
+
self.screen = pygame.display.set_mode(self.window_size)
|
|
119
|
+
pygame.display.set_caption(self.title)
|
|
120
|
+
self.clock = pygame.time.Clock()
|
|
121
|
+
self.input = self.input_factory()
|
|
122
|
+
if self.mouse_look and not a.frames:
|
|
123
|
+
pygame.mouse.set_visible(False); pygame.event.set_grab(True)
|
|
124
|
+
pygame.mouse.get_rel()
|
|
125
|
+
self.setup()
|
|
126
|
+
while self.running:
|
|
127
|
+
dt = (1.0 / self.fps) if a.frames else min(self.clock.tick(self.fps) / 1000.0, 0.05)
|
|
128
|
+
events = pygame.event.get()
|
|
129
|
+
for e in events:
|
|
130
|
+
if e.type == pygame.QUIT:
|
|
131
|
+
self.running = False
|
|
132
|
+
self.on_event(e)
|
|
133
|
+
self.input.poll(events)
|
|
134
|
+
if self.input.pressed("quit"):
|
|
135
|
+
self.running = False
|
|
136
|
+
self.update(dt)
|
|
137
|
+
self.draw(self.screen)
|
|
138
|
+
pygame.display.flip()
|
|
139
|
+
self.time += dt; self.frame_no += 1
|
|
140
|
+
if a.frames and self.frame_no >= a.frames:
|
|
141
|
+
self.running = False
|
|
142
|
+
if a.screenshot:
|
|
143
|
+
self.screenshot(a.screenshot)
|
|
144
|
+
pygame.quit()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""First-person camera with circle collision."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from .grid import GridMap
|
|
6
|
+
from .collision import move_circle
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Camera:
|
|
11
|
+
x: float = 1.5
|
|
12
|
+
y: float = 1.5
|
|
13
|
+
angle: float = 0.0 # radians; 0 = +x, pi/2 = +y (down on the map)
|
|
14
|
+
fov: float = math.radians(66)
|
|
15
|
+
radius: float = 0.2
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def dir(self): return math.cos(self.angle), math.sin(self.angle)
|
|
19
|
+
@property
|
|
20
|
+
def right(self): return -math.sin(self.angle), math.cos(self.angle)
|
|
21
|
+
|
|
22
|
+
def rotate(self, delta: float) -> None:
|
|
23
|
+
self.angle = (self.angle + delta) % math.tau
|
|
24
|
+
|
|
25
|
+
def look_at(self, x: float, y: float) -> None:
|
|
26
|
+
self.angle = math.atan2(y - self.y, x - self.x) % math.tau
|
|
27
|
+
|
|
28
|
+
def move(self, grid: GridMap, dx: float, dy: float) -> None:
|
|
29
|
+
self.x, self.y = move_circle(grid, self.x, self.y, dx, dy, self.radius)
|
|
30
|
+
|
|
31
|
+
def move_forward(self, grid: GridMap, dist: float) -> None:
|
|
32
|
+
self.move(grid, math.cos(self.angle) * dist, math.sin(self.angle) * dist)
|
|
33
|
+
|
|
34
|
+
def strafe(self, grid: GridMap, dist: float) -> None:
|
|
35
|
+
self.move(grid, -math.sin(self.angle) * dist, math.cos(self.angle) * dist)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""DDA grid ray casting (Amanatides & Woo), door-aware."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from .grid import GridMap
|
|
7
|
+
|
|
8
|
+
_INF = float("inf")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class RayHit:
|
|
13
|
+
tile: int
|
|
14
|
+
map_x: int
|
|
15
|
+
map_y: int
|
|
16
|
+
side: int # 0 = hit a vertical (x-facing) face, 1 = horizontal
|
|
17
|
+
distance: float # euclidean distance from ray origin
|
|
18
|
+
wall_x: float # 0..1 position along the face (texture u)
|
|
19
|
+
x: float
|
|
20
|
+
y: float
|
|
21
|
+
angle: float = 0.0
|
|
22
|
+
perp_distance: float = 0.0
|
|
23
|
+
is_door: bool = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cast_ray(grid: GridMap, ox: float, oy: float, angle: float,
|
|
27
|
+
max_dist: float = 64.0) -> Optional[RayHit]:
|
|
28
|
+
dx, dy = math.cos(angle), math.sin(angle)
|
|
29
|
+
mx, my = int(math.floor(ox)), int(math.floor(oy))
|
|
30
|
+
ddx = abs(1.0 / dx) if dx else _INF
|
|
31
|
+
ddy = abs(1.0 / dy) if dy else _INF
|
|
32
|
+
if dx < 0: step_x, sx = -1, (ox - mx) * ddx
|
|
33
|
+
else: step_x, sx = 1, (mx + 1.0 - ox) * ddx
|
|
34
|
+
if dy < 0: step_y, sy = -1, (oy - my) * ddy
|
|
35
|
+
else: step_y, sy = 1, (my + 1.0 - oy) * ddy
|
|
36
|
+
|
|
37
|
+
side, dist = 0, 0.0
|
|
38
|
+
doors = grid.doors
|
|
39
|
+
while dist <= max_dist:
|
|
40
|
+
if sx < sy:
|
|
41
|
+
dist, sx, mx, side = sx, sx + ddx, mx + step_x, 0
|
|
42
|
+
else:
|
|
43
|
+
dist, sy, my, side = sy, sy + ddy, my + step_y, 1
|
|
44
|
+
tile = grid.get(mx, my)
|
|
45
|
+
if tile:
|
|
46
|
+
hx, hy = ox + dx * dist, oy + dy * dist
|
|
47
|
+
u = (hy if side == 0 else hx) % 1.0
|
|
48
|
+
if (side == 0 and dx > 0) or (side == 1 and dy < 0):
|
|
49
|
+
u = 1.0 - u
|
|
50
|
+
door = doors.get((mx, my)) if doors else None
|
|
51
|
+
if door is not None and door.open > 0:
|
|
52
|
+
if u >= 1.0 - door.open: # the gap the door has slid open
|
|
53
|
+
continue
|
|
54
|
+
u += door.open # texture slides with the door
|
|
55
|
+
return RayHit(tile, mx, my, side, dist, u, hx, hy, angle, is_door=door is not None)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cast_rays(grid: GridMap, ox: float, oy: float, direction: float,
|
|
60
|
+
fov: float, columns: int, max_dist: float = 64.0) -> List[Optional[RayHit]]:
|
|
61
|
+
"""One ray per screen column (flat projection plane, no fisheye)."""
|
|
62
|
+
half = math.tan(fov / 2.0)
|
|
63
|
+
out: List[Optional[RayHit]] = []
|
|
64
|
+
for i in range(columns):
|
|
65
|
+
offset = (2.0 * (i + 0.5) / columns - 1.0) * half
|
|
66
|
+
a = direction + math.atan(offset)
|
|
67
|
+
hit = cast_ray(grid, ox, oy, a, max_dist)
|
|
68
|
+
if hit is not None:
|
|
69
|
+
hit.perp_distance = hit.distance * math.cos(a - direction)
|
|
70
|
+
out.append(hit)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def line_of_sight(grid: GridMap, x0: float, y0: float, x1: float, y1: float) -> bool:
|
|
75
|
+
"""True if no wall (or closed door) lies between the two points."""
|
|
76
|
+
d = math.hypot(x1 - x0, y1 - y0)
|
|
77
|
+
if d < 1e-6:
|
|
78
|
+
return True
|
|
79
|
+
hit = cast_ray(grid, x0, y0, math.atan2(y1 - y0, x1 - x0), d + 0.01)
|
|
80
|
+
return hit is None or hit.distance >= d
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Circle-vs-grid collision with wall sliding."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def circle_blocked(grid, x: float, y: float, r: float) -> bool:
|
|
7
|
+
for cy in range(int(math.floor(y - r)), int(math.floor(y + r)) + 1):
|
|
8
|
+
for cx in range(int(math.floor(x - r)), int(math.floor(x + r)) + 1):
|
|
9
|
+
if grid.is_solid(cx + .5, cy + .5):
|
|
10
|
+
nx = min(max(x, cx), cx + 1)
|
|
11
|
+
ny = min(max(y, cy), cy + 1)
|
|
12
|
+
if (x - nx) ** 2 + (y - ny) ** 2 < r * r:
|
|
13
|
+
return True
|
|
14
|
+
return False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def move_circle(grid, x: float, y: float, dx: float, dy: float, r: float):
|
|
18
|
+
"""Move a circle by (dx, dy), sliding along walls. Returns (new_x, new_y)."""
|
|
19
|
+
steps = max(1, int(max(abs(dx), abs(dy)) / max(r, 0.05) + 0.999)) # no tunnelling
|
|
20
|
+
sx, sy = dx / steps, dy / steps
|
|
21
|
+
for _ in range(steps):
|
|
22
|
+
if not circle_blocked(grid, x + sx, y, r): x += sx
|
|
23
|
+
if not circle_blocked(grid, x, y + sy, r): y += sy
|
|
24
|
+
return x, y
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Hitscan shooting: a ray that stops at walls and hits the nearest target.
|
|
2
|
+
|
|
3
|
+
A target is any object with `.x` and `.y` (and optionally `.radius`)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Iterable, Optional
|
|
8
|
+
from .grid import GridMap
|
|
9
|
+
from .caster import RayHit, cast_ray
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ShotResult:
|
|
14
|
+
target: Optional[Any] # nearest target hit, or None
|
|
15
|
+
distance: float # distance to whatever was hit
|
|
16
|
+
wall: Optional[RayHit] # the wall behind it (None if out of range)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hitscan(grid: GridMap, x: float, y: float, angle: float,
|
|
20
|
+
targets: Iterable[Any], max_dist: float = 30.0,
|
|
21
|
+
radius: float = 0.3) -> ShotResult:
|
|
22
|
+
wall = cast_ray(grid, x, y, angle, max_dist)
|
|
23
|
+
limit = wall.distance if wall else max_dist
|
|
24
|
+
dx, dy = math.cos(angle), math.sin(angle)
|
|
25
|
+
best, best_d = None, limit
|
|
26
|
+
for t in targets:
|
|
27
|
+
rx, ry = t.x - x, t.y - y
|
|
28
|
+
along = rx * dx + ry * dy # distance along the ray
|
|
29
|
+
if along <= 0 or along >= best_d:
|
|
30
|
+
continue
|
|
31
|
+
if abs(-rx * dy + ry * dx) <= getattr(t, "radius", radius): # sideways miss
|
|
32
|
+
best, best_d = t, along
|
|
33
|
+
return ShotResult(best, best_d, wall)
|