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.
Files changed (34) hide show
  1. rayforge_burgerguy-0.3.0/PKG-INFO +11 -0
  2. rayforge_burgerguy-0.3.0/README.md +63 -0
  3. rayforge_burgerguy-0.3.0/pyproject.toml +17 -0
  4. rayforge_burgerguy-0.3.0/setup.cfg +4 -0
  5. rayforge_burgerguy-0.3.0/src/rayforge/__init__.py +43 -0
  6. rayforge_burgerguy-0.3.0/src/rayforge/ai.py +213 -0
  7. rayforge_burgerguy-0.3.0/src/rayforge/app.py +144 -0
  8. rayforge_burgerguy-0.3.0/src/rayforge/camera.py +35 -0
  9. rayforge_burgerguy-0.3.0/src/rayforge/caster.py +80 -0
  10. rayforge_burgerguy-0.3.0/src/rayforge/collision.py +24 -0
  11. rayforge_burgerguy-0.3.0/src/rayforge/combat.py +33 -0
  12. rayforge_burgerguy-0.3.0/src/rayforge/effects.py +37 -0
  13. rayforge_burgerguy-0.3.0/src/rayforge/entities.py +245 -0
  14. rayforge_burgerguy-0.3.0/src/rayforge/factory.py +76 -0
  15. rayforge_burgerguy-0.3.0/src/rayforge/grid.py +129 -0
  16. rayforge_burgerguy-0.3.0/src/rayforge/hud.py +121 -0
  17. rayforge_burgerguy-0.3.0/src/rayforge/level.py +77 -0
  18. rayforge_burgerguy-0.3.0/src/rayforge/mode7.py +174 -0
  19. rayforge_burgerguy-0.3.0/src/rayforge/particles.py +69 -0
  20. rayforge_burgerguy-0.3.0/src/rayforge/png.py +18 -0
  21. rayforge_burgerguy-0.3.0/src/rayforge/render.py +214 -0
  22. rayforge_burgerguy-0.3.0/src/rayforge/sound.py +161 -0
  23. rayforge_burgerguy-0.3.0/src/rayforge/textures.py +346 -0
  24. rayforge_burgerguy-0.3.0/src/rayforge/track.py +149 -0
  25. rayforge_burgerguy-0.3.0/src/rayforge/util.py +86 -0
  26. rayforge_burgerguy-0.3.0/src/rayforge/vehicle.py +234 -0
  27. rayforge_burgerguy-0.3.0/src/rayforge/weapons.py +122 -0
  28. rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/PKG-INFO +11 -0
  29. rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/SOURCES.txt +32 -0
  30. rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/dependency_links.txt +1 -0
  31. rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/requires.txt +8 -0
  32. rayforge_burgerguy-0.3.0/src/rayforge_burgerguy.egg-info/top_level.txt +1 -0
  33. rayforge_burgerguy-0.3.0/tests/test_rayforge.py +54 -0
  34. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)