crimsonland 0.1.0.dev5__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 (139) 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 +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -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 +652 -0
  34. crimson/gameplay.py +2467 -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 +1133 -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 +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -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 +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pyray as rl
6
+
7
+ from .registry import register_view
8
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
9
+ from grim.view import View, ViewContext
10
+
11
+ from ..bonuses import BonusId
12
+ from ..gameplay import (
13
+ GameplayState,
14
+ PlayerInput,
15
+ PlayerState,
16
+ bonus_apply,
17
+ bonus_hud_update,
18
+ player_update,
19
+ weapon_assign_player,
20
+ )
21
+ from ..perks import PerkId
22
+ from ..ui.hud import HudAssets, draw_hud_overlay, hud_ui_scale, load_hud_assets
23
+ from ..weapons import WEAPON_TABLE
24
+
25
+ WORLD_SIZE = 1024.0
26
+
27
+ UI_TEXT_SCALE = 1.0
28
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
29
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
30
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class DummyCreature:
35
+ x: float
36
+ y: float
37
+ hp: float
38
+ size: float = 32.0
39
+
40
+
41
+ def _clamp(value: float, lo: float, hi: float) -> float:
42
+ if value < lo:
43
+ return lo
44
+ if value > hi:
45
+ return hi
46
+ return value
47
+
48
+
49
+ def _lerp(a: float, b: float, t: float) -> float:
50
+ return a + (b - a) * t
51
+
52
+
53
+ def _rand_float01(state: GameplayState) -> float:
54
+ return float(state.rng.rand()) / 32767.0
55
+
56
+
57
+ class PlayerSandboxView:
58
+ def __init__(self, ctx: ViewContext) -> None:
59
+ self._assets_root = ctx.assets_dir
60
+ self._missing_assets: list[str] = []
61
+ self._small: SmallFontData | None = None
62
+
63
+ self._state = GameplayState()
64
+ self._player = PlayerState(index=0, pos_x=WORLD_SIZE * 0.5, pos_y=WORLD_SIZE * 0.5)
65
+ self._creatures: list[DummyCreature] = []
66
+
67
+ self._hud_assets: HudAssets | None = None
68
+ self._hud_missing: list[str] = []
69
+ self._elapsed_ms = 0.0
70
+ self._last_dt_ms = 0.0
71
+
72
+ self._camera_x = -1.0
73
+ self._camera_y = -1.0
74
+ self._paused = False
75
+
76
+ self._weapon_ids = [entry.weapon_id for entry in WEAPON_TABLE if entry.name is not None]
77
+ self._weapon_index = 0
78
+ self._damage_scale_by_type = {}
79
+ for entry in WEAPON_TABLE:
80
+ if entry.weapon_id <= 0:
81
+ continue
82
+ self._damage_scale_by_type[int(entry.weapon_id)] = float(entry.damage_scale or 1.0)
83
+
84
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
85
+ if self._small is not None:
86
+ return int(self._small.cell_size * scale)
87
+ return int(20 * scale)
88
+
89
+ def _draw_ui_text(
90
+ self,
91
+ text: str,
92
+ x: float,
93
+ y: float,
94
+ color: rl.Color,
95
+ scale: float = UI_TEXT_SCALE,
96
+ ) -> None:
97
+ if self._small is not None:
98
+ draw_small_text(self._small, text, x, y, scale, color)
99
+ else:
100
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
101
+
102
+ def _ensure_creatures(self, target_count: int) -> None:
103
+ while len(self._creatures) < target_count:
104
+ margin = 40.0
105
+ x = margin + _rand_float01(self._state) * (WORLD_SIZE - margin * 2)
106
+ y = margin + _rand_float01(self._state) * (WORLD_SIZE - margin * 2)
107
+ self._creatures.append(DummyCreature(x=x, y=y, hp=80.0, size=28.0))
108
+
109
+ def _weapon_id(self) -> int:
110
+ if not self._weapon_ids:
111
+ return 0
112
+ return int(self._weapon_ids[self._weapon_index % len(self._weapon_ids)])
113
+
114
+ def _set_weapon(self, weapon_id: int) -> None:
115
+ weapon_assign_player(self._player, weapon_id)
116
+
117
+ def _toggle_perk(self, perk_id: PerkId, *, count: int = 1) -> None:
118
+ idx = int(perk_id)
119
+ current = self._player.perk_counts[idx] if 0 <= idx < len(self._player.perk_counts) else 0
120
+ next_value = 0 if current else int(count)
121
+ if 0 <= idx < len(self._player.perk_counts):
122
+ self._player.perk_counts[idx] = next_value
123
+ if perk_id == PerkId.ALTERNATE_WEAPON and next_value:
124
+ if self._player.alt_weapon_id is None:
125
+ alt_idx = (self._weapon_index + 1) % max(1, len(self._weapon_ids))
126
+ alt_id = int(self._weapon_ids[alt_idx])
127
+ weapon = next((w for w in WEAPON_TABLE if w.weapon_id == alt_id), None)
128
+ clip = int(weapon.clip_size) if weapon is not None and weapon.clip_size is not None else 0
129
+ self._player.alt_weapon_id = alt_id
130
+ self._player.alt_clip_size = max(0, clip)
131
+ self._player.alt_ammo = self._player.alt_clip_size
132
+ if perk_id == PerkId.ALTERNATE_WEAPON and not next_value:
133
+ self._player.alt_weapon_id = None
134
+
135
+ def open(self) -> None:
136
+ self._missing_assets.clear()
137
+ self._hud_missing.clear()
138
+ try:
139
+ self._small = load_small_font(self._assets_root, self._missing_assets)
140
+ except Exception:
141
+ self._small = None
142
+ self._hud_assets = load_hud_assets(self._assets_root)
143
+ if self._hud_assets.missing:
144
+ self._hud_missing = list(self._hud_assets.missing)
145
+
146
+ self._state.rng.srand(0xBEEF)
147
+ self._creatures.clear()
148
+ self._ensure_creatures(14)
149
+
150
+ self._weapon_index = 0
151
+ self._set_weapon(self._weapon_id())
152
+
153
+ self._player.pos_x = WORLD_SIZE * 0.5
154
+ self._player.pos_y = WORLD_SIZE * 0.5
155
+ self._player.health = 100.0
156
+ self._elapsed_ms = 0.0
157
+
158
+ def close(self) -> None:
159
+ if self._small is not None:
160
+ rl.unload_texture(self._small.texture)
161
+ self._small = None
162
+ if self._hud_assets is not None:
163
+ self._hud_assets = None
164
+
165
+ def _handle_input(self) -> None:
166
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
167
+ self._paused = not self._paused
168
+
169
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_Q):
170
+ self._weapon_index = (self._weapon_index - 1) % max(1, len(self._weapon_ids))
171
+ self._set_weapon(self._weapon_id())
172
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_E):
173
+ self._weapon_index = (self._weapon_index + 1) % max(1, len(self._weapon_ids))
174
+ self._set_weapon(self._weapon_id())
175
+
176
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ONE):
177
+ self._toggle_perk(PerkId.SHARPSHOOTER)
178
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TWO):
179
+ self._toggle_perk(PerkId.ANXIOUS_LOADER)
180
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_THREE):
181
+ self._toggle_perk(PerkId.STATIONARY_RELOADER)
182
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_FOUR):
183
+ self._toggle_perk(PerkId.ANGRY_RELOADER)
184
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_FIVE):
185
+ self._toggle_perk(PerkId.MAN_BOMB)
186
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SIX):
187
+ self._toggle_perk(PerkId.HOT_TEMPERED)
188
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SEVEN):
189
+ self._toggle_perk(PerkId.FIRE_CAUGH)
190
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_T):
191
+ self._toggle_perk(PerkId.ALTERNATE_WEAPON)
192
+
193
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_Z):
194
+ bonus_apply(self._state, self._player, BonusId.WEAPON_POWER_UP)
195
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_X):
196
+ bonus_apply(self._state, self._player, BonusId.SHIELD)
197
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_C):
198
+ bonus_apply(self._state, self._player, BonusId.SPEED)
199
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_V):
200
+ bonus_apply(self._state, self._player, BonusId.FIRE_BULLETS)
201
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_B):
202
+ bonus_apply(self._state, self._player, BonusId.FIREBLAST, origin=self._player)
203
+
204
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
205
+ self._state.bonuses.weapon_power_up = 0.0
206
+ self._player.shield_timer = 0.0
207
+ self._player.speed_bonus_timer = 0.0
208
+ self._player.fire_bullets_timer = 0.0
209
+ bonus_hud_update(self._state, [self._player])
210
+
211
+ def _camera_world_to_screen(self, x: float, y: float) -> tuple[float, float]:
212
+ return self._camera_x + x, self._camera_y + y
213
+
214
+ def _camera_screen_to_world(self, x: float, y: float) -> tuple[float, float]:
215
+ return x - self._camera_x, y - self._camera_y
216
+
217
+ def _update_camera(self, dt: float) -> None:
218
+ screen_w = float(rl.get_screen_width())
219
+ screen_h = float(rl.get_screen_height())
220
+ if screen_w > WORLD_SIZE:
221
+ screen_w = WORLD_SIZE
222
+ if screen_h > WORLD_SIZE:
223
+ screen_h = WORLD_SIZE
224
+
225
+ focus_x = self._player.pos_x
226
+ focus_y = self._player.pos_y
227
+
228
+ desired_x = (screen_w * 0.5) - focus_x
229
+ desired_y = (screen_h * 0.5) - focus_y
230
+
231
+ min_x = screen_w - WORLD_SIZE
232
+ min_y = screen_h - WORLD_SIZE
233
+ if desired_x > -1.0:
234
+ desired_x = -1.0
235
+ if desired_x < min_x:
236
+ desired_x = min_x
237
+ if desired_y > -1.0:
238
+ desired_y = -1.0
239
+ if desired_y < min_y:
240
+ desired_y = min_y
241
+
242
+ t = _clamp(dt * 6.0, 0.0, 1.0)
243
+ self._camera_x = _lerp(self._camera_x, desired_x, t)
244
+ self._camera_y = _lerp(self._camera_y, desired_y, t)
245
+
246
+ def _build_input(self) -> PlayerInput:
247
+ move_x = 0.0
248
+ move_y = 0.0
249
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
250
+ move_x -= 1.0
251
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
252
+ move_x += 1.0
253
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
254
+ move_y -= 1.0
255
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
256
+ move_y += 1.0
257
+
258
+ mouse = rl.get_mouse_position()
259
+ aim_x, aim_y = self._camera_screen_to_world(float(mouse.x), float(mouse.y))
260
+
261
+ fire_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
262
+ fire_pressed = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
263
+ reload_pressed = rl.is_key_pressed(rl.KeyboardKey.KEY_R)
264
+
265
+ return PlayerInput(
266
+ move_x=move_x,
267
+ move_y=move_y,
268
+ aim_x=aim_x,
269
+ aim_y=aim_y,
270
+ fire_down=fire_down,
271
+ fire_pressed=fire_pressed,
272
+ reload_pressed=reload_pressed,
273
+ )
274
+
275
+ def _decay_global_timers(self, dt: float) -> None:
276
+ self._state.bonuses.weapon_power_up = max(0.0, self._state.bonuses.weapon_power_up - dt)
277
+ self._state.bonuses.reflex_boost = max(0.0, self._state.bonuses.reflex_boost - dt)
278
+ self._state.bonuses.energizer = max(0.0, self._state.bonuses.energizer - dt)
279
+ self._state.bonuses.double_experience = max(0.0, self._state.bonuses.double_experience - dt)
280
+ self._state.bonuses.freeze = max(0.0, self._state.bonuses.freeze - dt)
281
+
282
+ def update(self, dt: float) -> None:
283
+ self._handle_input()
284
+
285
+ if self._paused:
286
+ dt = 0.0
287
+
288
+ self._last_dt_ms = float(min(dt, 0.1) * 1000.0)
289
+
290
+ self._elapsed_ms += dt * 1000.0
291
+
292
+ # Frame loop: projectiles update first; player spawns are visible next tick.
293
+ self._state.projectiles.update(
294
+ dt,
295
+ self._creatures,
296
+ world_size=WORLD_SIZE,
297
+ damage_scale_by_type=self._damage_scale_by_type,
298
+ detail_preset=5,
299
+ rng=self._state.rng.rand,
300
+ runtime_state=self._state,
301
+ )
302
+ self._state.secondary_projectiles.update_pulse_gun(dt, self._creatures)
303
+ self._creatures = [c for c in self._creatures if c.hp > 0.0]
304
+ self._ensure_creatures(10)
305
+
306
+ self._decay_global_timers(dt)
307
+
308
+ input_state = self._build_input()
309
+ player_update(self._player, input_state, dt, self._state, world_size=WORLD_SIZE)
310
+
311
+ bonus_hud_update(self._state, [self._player])
312
+ self._update_camera(dt)
313
+
314
+ def draw(self) -> None:
315
+ rl.clear_background(rl.Color(10, 10, 12, 255))
316
+ if self._missing_assets:
317
+ message = "Missing assets: " + ", ".join(self._missing_assets)
318
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
319
+ return
320
+
321
+ # World bounds.
322
+ x0, y0 = self._camera_world_to_screen(0.0, 0.0)
323
+ x1, y1 = self._camera_world_to_screen(WORLD_SIZE, WORLD_SIZE)
324
+ rl.draw_rectangle_lines(int(x0), int(y0), int(x1 - x0), int(y1 - y0), rl.Color(40, 40, 55, 255))
325
+
326
+ # Creatures.
327
+ for creature in self._creatures:
328
+ sx, sy = self._camera_world_to_screen(creature.x, creature.y)
329
+ color = rl.Color(220, 90, 90, 255)
330
+ rl.draw_circle(int(sx), int(sy), float(creature.size * 0.5), color)
331
+
332
+ # Projectiles.
333
+ for proj in self._state.projectiles.iter_active():
334
+ sx, sy = self._camera_world_to_screen(proj.pos_x, proj.pos_y)
335
+ rl.draw_circle(int(sx), int(sy), 2.0, rl.Color(240, 220, 160, 255))
336
+
337
+ for proj in self._state.secondary_projectiles.iter_active():
338
+ sx, sy = self._camera_world_to_screen(proj.pos_x, proj.pos_y)
339
+ color = rl.Color(120, 200, 240, 255) if proj.type_id != 3 else rl.Color(200, 240, 160, 255)
340
+ rl.draw_circle(int(sx), int(sy), 3.0, color)
341
+
342
+ # Player.
343
+ px, py = self._camera_world_to_screen(self._player.pos_x, self._player.pos_y)
344
+ rl.draw_circle(int(px), int(py), 14.0, rl.Color(90, 190, 120, 255))
345
+ rl.draw_circle_lines(int(px), int(py), 14.0, rl.Color(40, 80, 50, 255))
346
+
347
+ aim_len = 42.0
348
+ ax = px + self._player.aim_dir_x * aim_len
349
+ ay = py + self._player.aim_dir_y * aim_len
350
+ rl.draw_line(int(px), int(py), int(ax), int(ay), rl.Color(240, 240, 240, 255))
351
+
352
+ hud_bottom = 0.0
353
+ if self._hud_assets is not None:
354
+ hud_bottom = draw_hud_overlay(
355
+ self._hud_assets,
356
+ player=self._player,
357
+ bonus_hud=self._state.bonus_hud,
358
+ elapsed_ms=self._elapsed_ms,
359
+ score=self._player.experience,
360
+ font=self._small,
361
+ frame_dt_ms=self._last_dt_ms,
362
+ )
363
+
364
+ if self._hud_missing:
365
+ warn = "Missing HUD assets: " + ", ".join(self._hud_missing)
366
+ self._draw_ui_text(warn, 24, rl.get_screen_height() - 28, UI_ERROR_COLOR, scale=0.8)
367
+
368
+ # UI.
369
+ scale = hud_ui_scale(float(rl.get_screen_width()), float(rl.get_screen_height()))
370
+ margin = 18
371
+ x = float(margin)
372
+ y = max(float(margin) + 110.0 * scale, hud_bottom + 12.0 * scale)
373
+ line = self._ui_line_height()
374
+
375
+ weapon_id = self._player.weapon_id
376
+ weapon_name = next((w.name for w in WEAPON_TABLE if w.weapon_id == weapon_id), None) or f"weapon_{weapon_id}"
377
+ self._draw_ui_text(f"{weapon_name} (id {weapon_id})", x, y, UI_TEXT_COLOR)
378
+ y += line + 4
379
+ self._draw_ui_text(
380
+ f"ammo {self._player.ammo}/{self._player.clip_size} reload {self._player.reload_timer:.2f}/{self._player.reload_timer_max:.2f}",
381
+ x,
382
+ y,
383
+ UI_TEXT_COLOR,
384
+ )
385
+ y += line + 4
386
+ self._draw_ui_text(
387
+ f"cooldown {self._player.shot_cooldown:.3f} spread {self._player.spread_heat:.3f}",
388
+ x,
389
+ y,
390
+ UI_TEXT_COLOR,
391
+ )
392
+ y += line + 8
393
+
394
+ self._draw_ui_text("WASD move Mouse aim LMB fire R reload/swap Q/E weapon Tab pause", x, y, UI_HINT_COLOR)
395
+ y += line + 4
396
+ self._draw_ui_text(
397
+ "1 Sharpshooter 2 Anxious 3 Stationary 4 Angry 5 Man Bomb 6 Hot Tempered 7 Fire Cough T Alt Weapon",
398
+ x,
399
+ y,
400
+ UI_HINT_COLOR,
401
+ )
402
+ y += line + 4
403
+ self._draw_ui_text("Z PowerUp X Shield C Speed V FireBullets B Fireblast Backspace clear bonuses", x, y, UI_HINT_COLOR)
404
+ y += line + 10
405
+
406
+ active_perks = []
407
+ for perk in (
408
+ PerkId.SHARPSHOOTER,
409
+ PerkId.ANXIOUS_LOADER,
410
+ PerkId.STATIONARY_RELOADER,
411
+ PerkId.ANGRY_RELOADER,
412
+ PerkId.MAN_BOMB,
413
+ PerkId.HOT_TEMPERED,
414
+ PerkId.FIRE_CAUGH,
415
+ PerkId.ALTERNATE_WEAPON,
416
+ ):
417
+ if self._player.perk_counts[int(perk)]:
418
+ active_perks.append(perk.name.lower())
419
+ self._draw_ui_text("perks: " + (", ".join(active_perks) if active_perks else "none"), x, y, UI_TEXT_COLOR)
420
+ y += line + 8
421
+
422
+ # Bonus HUD slots (text-only).
423
+ slots = [slot for slot in self._state.bonus_hud.slots if slot.active]
424
+ if slots:
425
+ self._draw_ui_text("bonuses:", x, y, UI_TEXT_COLOR)
426
+ y += line + 4
427
+ for slot in slots:
428
+ self._draw_ui_text(f"- {slot.label}", x, y, UI_HINT_COLOR)
429
+ y += line + 2
430
+
431
+
432
+ @register_view("player", "Player sandbox")
433
+ def build_player_view(ctx: ViewContext) -> View:
434
+ return PlayerSandboxView(ctx)