crimsonland 0.1.0.dev1__py3-none-any.whl

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 (138) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +153 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +377 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +663 -0
  34. crimson/gameplay.py +2450 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1039 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1338 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +56 -0
  67. crimson/sim/world_state.py +421 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +414 -0
  86. crimson/views/bonuses.py +201 -0
  87. crimson/views/camera_debug.py +359 -0
  88. crimson/views/camera_shake.py +229 -0
  89. crimson/views/corpse_stamp_debug.py +324 -0
  90. crimson/views/decals_debug.py +739 -0
  91. crimson/views/empty.py +19 -0
  92. crimson/views/fonts.py +114 -0
  93. crimson/views/game_over.py +117 -0
  94. crimson/views/ground.py +259 -0
  95. crimson/views/lighting_debug.py +1166 -0
  96. crimson/views/particles.py +293 -0
  97. crimson/views/perk_menu_debug.py +430 -0
  98. crimson/views/perks.py +398 -0
  99. crimson/views/player.py +433 -0
  100. crimson/views/player_sprite_debug.py +314 -0
  101. crimson/views/projectile_fx.py +608 -0
  102. crimson/views/projectile_render_debug.py +407 -0
  103. crimson/views/projectiles.py +221 -0
  104. crimson/views/quest_title_overlay.py +108 -0
  105. crimson/views/registry.py +34 -0
  106. crimson/views/rush.py +16 -0
  107. crimson/views/small_font_debug.py +204 -0
  108. crimson/views/spawn_plan.py +363 -0
  109. crimson/views/sprites.py +214 -0
  110. crimson/views/survival.py +15 -0
  111. crimson/views/terrain.py +132 -0
  112. crimson/views/ui.py +123 -0
  113. crimson/views/wicons.py +166 -0
  114. crimson/weapon_sfx.py +63 -0
  115. crimson/weapons.py +860 -0
  116. crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
  117. crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
  118. crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
  119. crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
  120. grim/__init__.py +20 -0
  121. grim/app.py +92 -0
  122. grim/assets.py +231 -0
  123. grim/audio.py +106 -0
  124. grim/config.py +294 -0
  125. grim/console.py +737 -0
  126. grim/fonts/__init__.py +7 -0
  127. grim/fonts/grim_mono.py +111 -0
  128. grim/fonts/small.py +120 -0
  129. grim/input.py +44 -0
  130. grim/jaz.py +103 -0
  131. grim/math.py +17 -0
  132. grim/music.py +403 -0
  133. grim/paq.py +76 -0
  134. grim/rand.py +37 -0
  135. grim/sfx.py +276 -0
  136. grim/sfx_map.py +103 -0
  137. grim/terrain_render.py +840 -0
  138. grim/view.py +16 -0
@@ -0,0 +1,739 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ import time
8
+
9
+ import pyray as rl
10
+
11
+ from crimson.creatures.anim import creature_anim_advance_phase, creature_anim_select_frame, creature_corpse_frame_for_type
12
+ from crimson.creatures.runtime import CreaturePool
13
+ from crimson.creatures.spawn import CreatureFlags, CreatureInit, CreatureTypeId, SpawnEnv
14
+ from crimson.effects import FxQueue, FxQueueRotated
15
+ from crimson.gameplay import GameplayState, PlayerState
16
+ from crimson.render.terrain_fx import FxQueueTextures, bake_fx_queues
17
+ from grim.assets import resolve_asset_path
18
+ from grim.config import ensure_crimson_cfg
19
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
20
+ from grim.terrain_render import GroundRenderer
21
+ from grim.view import View, ViewContext
22
+
23
+ from ..paths import default_runtime_dir
24
+ from .registry import register_view
25
+
26
+
27
+ UI_TEXT_SCALE = 1.0
28
+ UI_TEXT_DARK = rl.Color(30, 30, 30, 255)
29
+ UI_HINT_DARK = rl.Color(70, 70, 70, 255)
30
+ UI_TEXT_LIGHT = rl.Color(220, 220, 220, 255)
31
+ UI_HINT_LIGHT = rl.Color(140, 140, 140, 255)
32
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
33
+
34
+ BG_DARK = rl.Color(12, 12, 14, 255)
35
+ BG_LIGHT = rl.Color(235, 235, 235, 255)
36
+ GRID_COLOR = rl.Color(0, 0, 0, 20)
37
+
38
+ WORLD_SIZE = 1024.0
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class _CreatureAnimInfo:
43
+ base: int
44
+ anim_rate: float
45
+ mirror: bool
46
+
47
+
48
+ _CREATURE_ANIM: dict[CreatureTypeId, _CreatureAnimInfo] = {
49
+ CreatureTypeId.ZOMBIE: _CreatureAnimInfo(base=0x20, anim_rate=1.2, mirror=False),
50
+ CreatureTypeId.LIZARD: _CreatureAnimInfo(base=0x10, anim_rate=1.6, mirror=True),
51
+ CreatureTypeId.ALIEN: _CreatureAnimInfo(base=0x20, anim_rate=1.35, mirror=False),
52
+ CreatureTypeId.SPIDER_SP1: _CreatureAnimInfo(base=0x10, anim_rate=1.5, mirror=True),
53
+ CreatureTypeId.SPIDER_SP2: _CreatureAnimInfo(base=0x10, anim_rate=1.5, mirror=True),
54
+ CreatureTypeId.TROOPER: _CreatureAnimInfo(base=0x00, anim_rate=1.0, mirror=False),
55
+ }
56
+
57
+ _CREATURE_ASSET: dict[CreatureTypeId, str] = {
58
+ CreatureTypeId.ZOMBIE: "zombie",
59
+ CreatureTypeId.LIZARD: "lizard",
60
+ CreatureTypeId.ALIEN: "alien",
61
+ CreatureTypeId.SPIDER_SP1: "spider_sp1",
62
+ CreatureTypeId.SPIDER_SP2: "spider_sp2",
63
+ CreatureTypeId.TROOPER: "trooper",
64
+ }
65
+
66
+
67
+ TERRAIN_TEXTURES: list[tuple[int, str]] = [
68
+ (0, "ter/ter_q1_base.png"),
69
+ (1, "ter/ter_q1_tex1.png"),
70
+ (2, "ter/ter_q2_base.png"),
71
+ (3, "ter/ter_q2_tex1.png"),
72
+ (4, "ter/ter_q3_base.png"),
73
+ (5, "ter/ter_q3_tex1.png"),
74
+ (6, "ter/ter_q4_base.png"),
75
+ (7, "ter/ter_q4_tex1.png"),
76
+ ]
77
+
78
+
79
+ class DecalsDebugView:
80
+ def __init__(self, ctx: ViewContext) -> None:
81
+ self._assets_root = ctx.assets_dir
82
+ self._missing_assets: list[str] = []
83
+ self._small: SmallFontData | None = None
84
+
85
+ self._terrain_textures: dict[int, rl.Texture] = {}
86
+ self._creature_textures: dict[str, rl.Texture] = {}
87
+ self._owned_textures: list[rl.Texture] = []
88
+
89
+ self._fx_textures: FxQueueTextures | None = None
90
+ self._ground: GroundRenderer | None = None
91
+ self._camera_x = 0.0
92
+ self._camera_y = 0.0
93
+ self._light_mode = False
94
+
95
+ self._terrain_seed = 0xBEEF
96
+ self._terrain_pair = 0 # 0..3, maps to (0,1),(2,3),(4,5),(6,7)
97
+ self._show_stamp_log = True
98
+ self._frame = 0
99
+ self._stamp_log_path: Path | None = None
100
+ self._stamp_log_file = None
101
+
102
+ self._state = GameplayState()
103
+ self._player = PlayerState(index=0, pos_x=WORLD_SIZE * 0.5, pos_y=WORLD_SIZE * 0.5)
104
+ self._creatures = CreaturePool()
105
+ self._env = SpawnEnv(
106
+ terrain_width=WORLD_SIZE,
107
+ terrain_height=WORLD_SIZE,
108
+ demo_mode_active=True,
109
+ hardcore=False,
110
+ difficulty_level=0,
111
+ )
112
+
113
+ self._fx_queue = FxQueue()
114
+ self._fx_queue_rotated = FxQueueRotated()
115
+
116
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
117
+ if self._small is not None:
118
+ return int(self._small.cell_size * scale)
119
+ return int(20 * scale)
120
+
121
+ def _draw_ui_text(self, text: str, x: float, y: float, color: rl.Color, scale: float = UI_TEXT_SCALE) -> None:
122
+ if self._small is not None:
123
+ draw_small_text(self._small, text, x, y, scale, color)
124
+ else:
125
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
126
+
127
+ def _write_stamp_log(self, payload: dict) -> None:
128
+ if self._stamp_log_file is None:
129
+ return
130
+ try:
131
+ self._stamp_log_file.write(json.dumps(payload, sort_keys=True) + "\n")
132
+ self._stamp_log_file.flush()
133
+ except Exception:
134
+ self._stamp_log_file = None
135
+
136
+ def _load_runtime_config(self) -> tuple[float, float | None, float | None]:
137
+ runtime_dir = default_runtime_dir()
138
+ if runtime_dir.is_dir():
139
+ try:
140
+ cfg = ensure_crimson_cfg(runtime_dir)
141
+ return (
142
+ float(cfg.texture_scale),
143
+ float(cfg.screen_width),
144
+ float(cfg.screen_height),
145
+ )
146
+ except Exception:
147
+ return 1.0, None, None
148
+ return 1.0, None, None
149
+
150
+ def _apply_terrain_pair(self) -> None:
151
+ if self._ground is None:
152
+ return
153
+ base_id = int(self._terrain_pair) * 2
154
+ overlay_id = base_id + 1
155
+ base = self._terrain_textures.get(base_id)
156
+ overlay = self._terrain_textures.get(overlay_id)
157
+ if base is None:
158
+ return
159
+ self._ground.texture = base
160
+ self._ground.overlay = overlay
161
+ self._ground.overlay_detail = overlay or base
162
+ self._ground.schedule_generate(seed=int(self._terrain_seed), layers=3)
163
+
164
+ def _clear_ground_light(self) -> None:
165
+ ground = self._ground
166
+ if ground is None:
167
+ return
168
+ ground.create_render_target()
169
+ if ground.render_target is None:
170
+ return
171
+ rl.begin_texture_mode(ground.render_target)
172
+ rl.clear_background(BG_LIGHT)
173
+ rl.end_texture_mode()
174
+ # GroundRenderer treats this as an internal invariant; set it for debug fills.
175
+ ground._render_target_ready = True # type: ignore[attr-defined]
176
+
177
+ def _reset_ground(self) -> None:
178
+ if self._ground is None:
179
+ return
180
+ if self._light_mode:
181
+ self._clear_ground_light()
182
+ else:
183
+ self._apply_terrain_pair()
184
+
185
+ def _world_to_screen(self, x: float, y: float) -> tuple[float, float]:
186
+ ground = self._ground
187
+ screen_w = float(rl.get_screen_width())
188
+ screen_h = float(rl.get_screen_height())
189
+ if ground is None:
190
+ return x, y
191
+
192
+ # Mirror GameWorld camera behavior (ground.draw uses the same clamp rules).
193
+ cfg_w = float(ground.screen_width or screen_w)
194
+ cfg_h = float(ground.screen_height or screen_h)
195
+ if cfg_w > WORLD_SIZE:
196
+ cfg_w = WORLD_SIZE
197
+ if cfg_h > WORLD_SIZE:
198
+ cfg_h = WORLD_SIZE
199
+
200
+ min_x = cfg_w - WORLD_SIZE
201
+ min_y = cfg_h - WORLD_SIZE
202
+ cam_x = self._camera_x
203
+ cam_y = self._camera_y
204
+ if cam_x > -1.0:
205
+ cam_x = -1.0
206
+ if cam_x < min_x:
207
+ cam_x = min_x
208
+ if cam_y > -1.0:
209
+ cam_y = -1.0
210
+ if cam_y < min_y:
211
+ cam_y = min_y
212
+
213
+ scale_x = screen_w / cfg_w if cfg_w > 0 else 1.0
214
+ scale_y = screen_h / cfg_h if cfg_h > 0 else 1.0
215
+ return (x + cam_x) * scale_x, (y + cam_y) * scale_y
216
+
217
+ def _screen_to_world(self, x: float, y: float) -> tuple[float, float]:
218
+ ground = self._ground
219
+ screen_w = float(rl.get_screen_width())
220
+ screen_h = float(rl.get_screen_height())
221
+ if ground is None:
222
+ return x, y
223
+
224
+ cfg_w = float(ground.screen_width or screen_w)
225
+ cfg_h = float(ground.screen_height or screen_h)
226
+ if cfg_w > WORLD_SIZE:
227
+ cfg_w = WORLD_SIZE
228
+ if cfg_h > WORLD_SIZE:
229
+ cfg_h = WORLD_SIZE
230
+
231
+ min_x = cfg_w - WORLD_SIZE
232
+ min_y = cfg_h - WORLD_SIZE
233
+ cam_x = self._camera_x
234
+ cam_y = self._camera_y
235
+ if cam_x > -1.0:
236
+ cam_x = -1.0
237
+ if cam_x < min_x:
238
+ cam_x = min_x
239
+ if cam_y > -1.0:
240
+ cam_y = -1.0
241
+ if cam_y < min_y:
242
+ cam_y = min_y
243
+
244
+ scale_x = screen_w / cfg_w if cfg_w > 0 else 1.0
245
+ scale_y = screen_h / cfg_h if cfg_h > 0 else 1.0
246
+ world_x = (x / scale_x) - cam_x
247
+ world_y = (y / scale_y) - cam_y
248
+ return world_x, world_y
249
+
250
+ def _world_scale(self) -> float:
251
+ ground = self._ground
252
+ if ground is None:
253
+ return 1.0
254
+ out_w = float(rl.get_screen_width())
255
+ out_h = float(rl.get_screen_height())
256
+ cfg_w = float(ground.screen_width or out_w)
257
+ cfg_h = float(ground.screen_height or out_h)
258
+ if cfg_w > WORLD_SIZE:
259
+ cfg_w = WORLD_SIZE
260
+ if cfg_h > WORLD_SIZE:
261
+ cfg_h = WORLD_SIZE
262
+ if cfg_w <= 0.0 or cfg_h <= 0.0:
263
+ return 1.0
264
+ scale_x = out_w / cfg_w
265
+ scale_y = out_h / cfg_h
266
+ return (scale_x + scale_y) * 0.5
267
+
268
+ def _draw_grid(self) -> None:
269
+ ground = self._ground
270
+ if ground is None:
271
+ return
272
+ step = 64.0
273
+ screen_w = float(rl.get_screen_width())
274
+ screen_h = float(rl.get_screen_height())
275
+ cfg_w = float(ground.screen_width or screen_w)
276
+ cfg_h = float(ground.screen_height or screen_h)
277
+ if cfg_w > WORLD_SIZE:
278
+ cfg_w = WORLD_SIZE
279
+ if cfg_h > WORLD_SIZE:
280
+ cfg_h = WORLD_SIZE
281
+
282
+ min_x = cfg_w - WORLD_SIZE
283
+ min_y = cfg_h - WORLD_SIZE
284
+ cam_x = self._camera_x
285
+ cam_y = self._camera_y
286
+ if cam_x > -1.0:
287
+ cam_x = -1.0
288
+ if cam_x < min_x:
289
+ cam_x = min_x
290
+ if cam_y > -1.0:
291
+ cam_y = -1.0
292
+ if cam_y < min_y:
293
+ cam_y = min_y
294
+
295
+ scale_x = screen_w / cfg_w if cfg_w > 0 else 1.0
296
+ scale_y = screen_h / cfg_h if cfg_h > 0 else 1.0
297
+
298
+ start_x = math.floor((-cam_x) / step) * step
299
+ end_x = (-cam_x) + cfg_w
300
+ x = start_x
301
+ while x <= end_x:
302
+ sx = int((x + cam_x) * scale_x)
303
+ rl.draw_line(sx, 0, sx, int(screen_h), GRID_COLOR)
304
+ x += step
305
+
306
+ start_y = math.floor((-cam_y) / step) * step
307
+ end_y = (-cam_y) + cfg_h
308
+ y = start_y
309
+ while y <= end_y:
310
+ sy = int((y + cam_y) * scale_y)
311
+ rl.draw_line(0, sy, int(screen_w), sy, GRID_COLOR)
312
+ y += step
313
+
314
+ def _draw_creature_sprite(
315
+ self,
316
+ texture: rl.Texture,
317
+ *,
318
+ info: _CreatureAnimInfo,
319
+ flags: CreatureFlags,
320
+ phase: float,
321
+ mirror_long: bool,
322
+ world_x: float,
323
+ world_y: float,
324
+ rotation_rad: float,
325
+ scale: float,
326
+ size_scale: float,
327
+ tint: rl.Color,
328
+ ) -> None:
329
+ frame, _, _ = creature_anim_select_frame(
330
+ phase,
331
+ base_frame=info.base,
332
+ mirror_long=mirror_long,
333
+ flags=flags,
334
+ )
335
+ grid = 8
336
+ cell = float(texture.width) / grid if grid > 0 else float(texture.width)
337
+ row = frame // grid
338
+ col = frame % grid
339
+ src = rl.Rectangle(float(col * cell), float(row * cell), float(cell), float(cell))
340
+ sx, sy = self._world_to_screen(world_x, world_y)
341
+ width = cell * float(scale) * float(size_scale)
342
+ height = cell * float(scale) * float(size_scale)
343
+ dst = rl.Rectangle(float(sx), float(sy), float(width), float(height))
344
+ origin = rl.Vector2(float(width) * 0.5, float(height) * 0.5)
345
+ rl.draw_texture_pro(texture, src, dst, origin, math.degrees(float(rotation_rad)), tint)
346
+
347
+ def _spawn_enemy(self, x: float, y: float) -> None:
348
+ type_id = CreatureTypeId(int(self._state.rng.rand()) % 5)
349
+ size = float(int(self._state.rng.rand()) % 30 + 40)
350
+ move_speed = float(int(self._state.rng.rand()) % 30) * 0.05 + 1.0
351
+ hp = float(int(self._state.rng.rand()) % 4 + 2)
352
+ heading = float(int(self._state.rng.rand()) % 628) * 0.01
353
+ init = CreatureInit(
354
+ origin_template_id=-1,
355
+ pos_x=float(x),
356
+ pos_y=float(y),
357
+ heading=heading,
358
+ phase_seed=float(int(self._state.rng.rand()) & 0xFF),
359
+ type_id=type_id,
360
+ flags=CreatureFlags(0),
361
+ ai_mode=0,
362
+ health=hp,
363
+ max_health=hp,
364
+ move_speed=move_speed,
365
+ reward_value=0.0,
366
+ size=size,
367
+ contact_damage=0.0,
368
+ tint=(1.0, 1.0, 1.0, 1.0),
369
+ )
370
+ self._creatures.spawn_init(init, rand=self._state.rng.rand)
371
+
372
+ def open(self) -> None:
373
+ self._missing_assets.clear()
374
+ self._owned_textures.clear()
375
+ self._terrain_textures.clear()
376
+ self._creature_textures.clear()
377
+ self._fx_textures = None
378
+ self._fx_queue.clear()
379
+ self._fx_queue_rotated.clear()
380
+ self._creatures.reset()
381
+
382
+ self._small = load_small_font(self._assets_root, self._missing_assets)
383
+
384
+ for terrain_id, rel_path in TERRAIN_TEXTURES:
385
+ path = resolve_asset_path(self._assets_root, rel_path)
386
+ if path is None:
387
+ self._missing_assets.append(rel_path)
388
+ continue
389
+ texture = rl.load_texture(str(path))
390
+ self._owned_textures.append(texture)
391
+ self._terrain_textures[int(terrain_id)] = texture
392
+
393
+ for asset in sorted(set(_CREATURE_ASSET.values())):
394
+ rel_path = f"game/{asset}.png"
395
+ path = resolve_asset_path(self._assets_root, rel_path)
396
+ if path is None:
397
+ self._missing_assets.append(rel_path)
398
+ continue
399
+ texture = rl.load_texture(str(path))
400
+ self._owned_textures.append(texture)
401
+ self._creature_textures[asset] = texture
402
+
403
+ particles_path = resolve_asset_path(self._assets_root, "game/particles.png")
404
+ if particles_path is None:
405
+ self._missing_assets.append("game/particles.png")
406
+ bodyset_path = resolve_asset_path(self._assets_root, "game/bodyset.png")
407
+ if bodyset_path is None:
408
+ self._missing_assets.append("game/bodyset.png")
409
+
410
+ if self._missing_assets:
411
+ raise FileNotFoundError(f"Missing assets: {', '.join(self._missing_assets)}")
412
+
413
+ particles_tex = rl.load_texture(str(particles_path))
414
+ bodyset_tex = rl.load_texture(str(bodyset_path))
415
+ self._owned_textures.append(particles_tex)
416
+ self._owned_textures.append(bodyset_tex)
417
+ self._fx_textures = FxQueueTextures(particles=particles_tex, bodyset=bodyset_tex)
418
+
419
+ texture_scale, screen_w, screen_h = self._load_runtime_config()
420
+ base_id = self._terrain_pair * 2
421
+ base = self._terrain_textures.get(base_id)
422
+ overlay = self._terrain_textures.get(base_id + 1)
423
+ if base is None:
424
+ raise FileNotFoundError("Missing base terrain texture")
425
+
426
+ self._ground = GroundRenderer(
427
+ texture=base,
428
+ overlay=overlay,
429
+ overlay_detail=overlay or base,
430
+ width=int(WORLD_SIZE),
431
+ height=int(WORLD_SIZE),
432
+ texture_scale=texture_scale,
433
+ screen_width=screen_w,
434
+ screen_height=screen_h,
435
+ )
436
+ self._reset_ground()
437
+ self._camera_x = 0.0
438
+ self._camera_y = 0.0
439
+ self._frame = 0
440
+
441
+ log_dir = Path("artifacts") / "debug"
442
+ try:
443
+ log_dir.mkdir(parents=True, exist_ok=True)
444
+ except Exception:
445
+ log_dir = Path("artifacts")
446
+ self._stamp_log_path = log_dir / "decals_stamp_trace.jsonl"
447
+ try:
448
+ self._stamp_log_file = self._stamp_log_path.open("w", encoding="utf-8")
449
+ except Exception:
450
+ self._stamp_log_file = None
451
+
452
+ # Spawn a few enemies near the center for immediate testing.
453
+ for _ in range(6):
454
+ ox = float(int(self._state.rng.rand()) % 200 - 100)
455
+ oy = float(int(self._state.rng.rand()) % 200 - 100)
456
+ self._spawn_enemy(WORLD_SIZE * 0.5 + ox, WORLD_SIZE * 0.5 + oy)
457
+
458
+ def close(self) -> None:
459
+ if self._ground is not None and self._ground.render_target is not None:
460
+ rl.unload_render_texture(self._ground.render_target)
461
+ self._ground.render_target = None
462
+ self._ground = None
463
+
464
+ for texture in self._owned_textures:
465
+ rl.unload_texture(texture)
466
+ self._owned_textures.clear()
467
+ self._terrain_textures.clear()
468
+ self._creature_textures.clear()
469
+ self._fx_textures = None
470
+
471
+ if self._small is not None:
472
+ rl.unload_texture(self._small.texture)
473
+ self._small = None
474
+
475
+ self._fx_queue.clear()
476
+ self._fx_queue_rotated.clear()
477
+ self._creatures.reset()
478
+ if self._stamp_log_file is not None:
479
+ try:
480
+ self._stamp_log_file.close()
481
+ except Exception:
482
+ pass
483
+ self._stamp_log_file = None
484
+
485
+ def update(self, dt: float) -> None:
486
+ self._frame += 1
487
+
488
+ speed = 240.0
489
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
490
+ self._camera_x += speed * dt
491
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
492
+ self._camera_x -= speed * dt
493
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
494
+ self._camera_y += speed * dt
495
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
496
+ self._camera_y -= speed * dt
497
+
498
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_G):
499
+ self._light_mode = not self._light_mode
500
+ self._reset_ground()
501
+
502
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_C):
503
+ self._reset_ground()
504
+
505
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
506
+ self._terrain_seed = int(rl.get_random_value(0, 0x7FFFFFFF))
507
+ if not self._light_mode:
508
+ self._apply_terrain_pair()
509
+ else:
510
+ self._clear_ground_light()
511
+
512
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_T):
513
+ self._terrain_pair = int(rl.get_random_value(0, 3))
514
+ if not self._light_mode:
515
+ self._apply_terrain_pair()
516
+
517
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_L):
518
+ self._show_stamp_log = not self._show_stamp_log
519
+
520
+ if self._ground is not None:
521
+ texture_scale, screen_w, screen_h = self._load_runtime_config()
522
+ self._ground.texture_scale = texture_scale
523
+ self._ground.screen_width = screen_w
524
+ self._ground.screen_height = screen_h
525
+ self._ground.process_pending()
526
+ self._ground.debug_log_stamps = self._show_stamp_log
527
+ if self._show_stamp_log:
528
+ self._ground.debug_clear_stamp_log()
529
+
530
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_RIGHT):
531
+ mouse = rl.get_mouse_position()
532
+ x, y = self._screen_to_world(float(mouse.x), float(mouse.y))
533
+ self._spawn_enemy(x, y)
534
+
535
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
536
+ mouse = rl.get_mouse_position()
537
+ x, y = self._screen_to_world(float(mouse.x), float(mouse.y))
538
+ hit = None
539
+ for creature in self._creatures.entries:
540
+ if not (creature.active and creature.hp > 0.0):
541
+ continue
542
+ dx = float(creature.x) - float(x)
543
+ dy = float(creature.y) - float(y)
544
+ r = float(creature.size) * 0.35 + 12.0
545
+ if dx * dx + dy * dy <= r * r:
546
+ hit = creature
547
+ break
548
+ if hit is not None:
549
+ hit.hp -= 1.0
550
+ self._fx_queue.add_random(pos_x=float(hit.x), pos_y=float(hit.y), rand=self._state.rng.rand)
551
+ else:
552
+ # Paint blood directly for ground decal checks.
553
+ self._fx_queue.add(
554
+ effect_id=0x07,
555
+ pos_x=float(x),
556
+ pos_y=float(y),
557
+ width=30.0,
558
+ height=30.0,
559
+ rotation=0.0,
560
+ rgba=(1.0, 1.0, 1.0, 1.0),
561
+ )
562
+
563
+ # Keep the player fixed; creatures use it as a target for heading/movement.
564
+ self._player.pos_x = WORLD_SIZE * 0.5
565
+ self._player.pos_y = WORLD_SIZE * 0.5
566
+ self._player.health = 1e9
567
+
568
+ creature_result = self._creatures.update(
569
+ dt,
570
+ state=self._state,
571
+ players=[self._player],
572
+ env=self._env,
573
+ world_width=WORLD_SIZE,
574
+ world_height=WORLD_SIZE,
575
+ fx_queue=self._fx_queue,
576
+ fx_queue_rotated=self._fx_queue_rotated,
577
+ )
578
+ del creature_result
579
+
580
+ # Advance alive animation phase (CreaturePool intentionally does not).
581
+ for creature in self._creatures.entries:
582
+ if not (creature.active and creature.hp > 0.0):
583
+ continue
584
+ try:
585
+ type_id = CreatureTypeId(int(creature.type_id))
586
+ except ValueError:
587
+ continue
588
+ info = _CREATURE_ANIM.get(type_id)
589
+ if info is None:
590
+ continue
591
+ creature.anim_phase, _ = creature_anim_advance_phase(
592
+ float(creature.anim_phase),
593
+ anim_rate=info.anim_rate,
594
+ move_speed=float(creature.move_speed),
595
+ dt=dt,
596
+ size=float(creature.size),
597
+ local_scale=float(getattr(creature, "move_scale", 1.0)),
598
+ flags=creature.flags,
599
+ ai_mode=int(creature.ai_mode),
600
+ )
601
+
602
+ if self._ground is not None and self._fx_textures is not None:
603
+ if self._fx_queue.count or self._fx_queue_rotated.count:
604
+ fx_count = int(self._fx_queue.count)
605
+ corpse_count = int(self._fx_queue_rotated.count)
606
+ bake_fx_queues(
607
+ self._ground,
608
+ fx_queue=self._fx_queue,
609
+ fx_queue_rotated=self._fx_queue_rotated,
610
+ textures=self._fx_textures,
611
+ corpse_frame_for_type=creature_corpse_frame_for_type,
612
+ corpse_shadow=not self._light_mode,
613
+ )
614
+ if self._show_stamp_log:
615
+ stamp_log = self._ground.debug_stamp_log()
616
+ if stamp_log:
617
+ ts = time.time()
618
+ for idx, event in enumerate(stamp_log):
619
+ self._write_stamp_log(
620
+ {
621
+ "ts": ts,
622
+ "dt": dt,
623
+ "frame": self._frame,
624
+ "event_idx": idx,
625
+ "queue": {"fx": fx_count, "corpse": corpse_count},
626
+ **event,
627
+ }
628
+ )
629
+
630
+ def draw(self) -> None:
631
+ rl.clear_background(BG_LIGHT if self._light_mode else BG_DARK)
632
+
633
+ if self._missing_assets:
634
+ self._draw_ui_text("Missing assets: " + ", ".join(self._missing_assets), 24, 24, UI_ERROR_COLOR)
635
+ return
636
+
637
+ if self._ground is None:
638
+ self._draw_ui_text("Ground renderer not initialized.", 24, 24, UI_ERROR_COLOR)
639
+ return
640
+
641
+ self._ground.draw(self._camera_x, self._camera_y)
642
+ if self._light_mode:
643
+ self._draw_grid()
644
+
645
+ # Creatures (including death slide/fade stage).
646
+ scale = self._world_scale()
647
+ for creature in self._creatures.entries:
648
+ if not creature.active:
649
+ continue
650
+ try:
651
+ type_id = CreatureTypeId(int(creature.type_id))
652
+ except ValueError:
653
+ continue
654
+ asset = _CREATURE_ASSET.get(type_id)
655
+ texture = self._creature_textures.get(asset) if asset is not None else None
656
+ info = _CREATURE_ANIM.get(type_id)
657
+ if texture is None or info is None:
658
+ continue
659
+
660
+ alpha = float(creature.tint_a)
661
+ if float(creature.hitbox_size) < 0.0:
662
+ alpha = max(0.0, alpha + float(creature.hitbox_size) * 0.1)
663
+ r = int(max(0.0, min(float(creature.tint_r), 1.0)) * 255.0 + 0.5)
664
+ g = int(max(0.0, min(float(creature.tint_g), 1.0)) * 255.0 + 0.5)
665
+ b = int(max(0.0, min(float(creature.tint_b), 1.0)) * 255.0 + 0.5)
666
+ a = int(max(0.0, min(alpha, 1.0)) * 255.0 + 0.5)
667
+ tint = rl.Color(r, g, b, a)
668
+
669
+ flags = creature.flags
670
+ long_strip = (flags & CreatureFlags.ANIM_PING_PONG) == 0 or (flags & CreatureFlags.ANIM_LONG_STRIP) != 0
671
+ phase = float(creature.anim_phase)
672
+ hitbox_size = float(creature.hitbox_size)
673
+ if long_strip:
674
+ if hitbox_size < 0.0:
675
+ phase = -1.0
676
+ elif hitbox_size < 16.0:
677
+ phase = float(info.base + 0x0F) - hitbox_size - 0.5
678
+ mirror_long = bool(info.mirror) and hitbox_size >= 16.0
679
+
680
+ size_scale = max(0.25, min(float(creature.size) / 64.0, 2.0))
681
+ self._draw_creature_sprite(
682
+ texture,
683
+ info=info,
684
+ flags=flags,
685
+ phase=phase,
686
+ mirror_long=mirror_long,
687
+ world_x=float(creature.x),
688
+ world_y=float(creature.y),
689
+ rotation_rad=float(creature.heading) - math.pi / 2.0,
690
+ scale=scale,
691
+ size_scale=size_scale,
692
+ tint=tint,
693
+ )
694
+
695
+ # UI overlay.
696
+ text_color = UI_TEXT_DARK if self._light_mode else UI_TEXT_LIGHT
697
+ hint_color = UI_HINT_DARK if self._light_mode else UI_HINT_LIGHT
698
+ x = 16
699
+ y = 12
700
+ line = self._ui_line_height()
701
+ self._draw_ui_text("Decals debug", x, y, text_color)
702
+ y += line
703
+ self._draw_ui_text("LMB: blood / damage enemy RMB: spawn enemy", x, y, hint_color)
704
+ y += line
705
+ self._draw_ui_text(
706
+ "WASD: pan R: random seed T: random terrain G: toggle light grid C: clear L: stamp log",
707
+ x,
708
+ y,
709
+ hint_color,
710
+ )
711
+ y += line
712
+ self._draw_ui_text(f"enemies={len([c for c in self._creatures.entries if c.active])}", x, y, hint_color)
713
+ y += line
714
+ if self._stamp_log_path is not None:
715
+ status = "on" if self._show_stamp_log else "off"
716
+ self._draw_ui_text(f"stamp log ({status}): {self._stamp_log_path}", x, y, hint_color)
717
+ y += line
718
+ if self._ground is not None and self._show_stamp_log:
719
+ stamp_log = self._ground.debug_stamp_log()
720
+ if stamp_log:
721
+ self._draw_ui_text("stamp order:", x, y, hint_color)
722
+ y += line
723
+ for event in stamp_log[-6:]:
724
+ kind = str(event.get("kind", "?"))
725
+ if kind == "bake_corpse_decals":
726
+ msg = f"{kind} shadow={event.get('shadow')} count={event.get('count')}"
727
+ elif kind == "bake_decals":
728
+ msg = f"{kind} count={event.get('count')}"
729
+ elif kind.endswith("_pass"):
730
+ msg = f"{kind} draws={event.get('draws')}"
731
+ else:
732
+ msg = kind
733
+ self._draw_ui_text(msg, x, y, hint_color)
734
+ y += line
735
+
736
+
737
+ @register_view("decals", "Decals debug")
738
+ def build_decals_debug_view(ctx: ViewContext) -> View:
739
+ return DecalsDebugView(ctx)