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,274 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pyray as rl
6
+
7
+ from ..creatures.anim import creature_anim_advance_phase, creature_anim_select_frame
8
+ from ..creatures.spawn import CreatureFlags, CreatureTypeId, SPAWN_TEMPLATES, SpawnTemplate, resolve_tint
9
+ from .registry import register_view
10
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
11
+ from grim.view import View, ViewContext
12
+
13
+ UI_TEXT_SCALE = 1.0
14
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
15
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
16
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class TypeAnimInfo:
21
+ base: int
22
+ anim_rate: float
23
+ mirror: bool
24
+
25
+
26
+ TYPE_ANIM: dict[CreatureTypeId, TypeAnimInfo] = {
27
+ CreatureTypeId.ZOMBIE: TypeAnimInfo(base=0x20, anim_rate=1.2, mirror=False),
28
+ CreatureTypeId.LIZARD: TypeAnimInfo(base=0x10, anim_rate=1.6, mirror=True),
29
+ CreatureTypeId.ALIEN: TypeAnimInfo(base=0x20, anim_rate=1.35, mirror=False),
30
+ CreatureTypeId.SPIDER_SP1: TypeAnimInfo(base=0x10, anim_rate=1.5, mirror=True),
31
+ CreatureTypeId.SPIDER_SP2: TypeAnimInfo(base=0x10, anim_rate=1.5, mirror=True),
32
+ CreatureTypeId.TROOPER: TypeAnimInfo(base=0x00, anim_rate=1.0, mirror=False),
33
+ }
34
+
35
+
36
+ class CreatureAnimationView:
37
+ def __init__(self, ctx: ViewContext) -> None:
38
+ self._assets_root = ctx.assets_dir
39
+ self._missing_assets: list[str] = []
40
+ self._textures: dict[str, rl.Texture] = {}
41
+ self._small: SmallFontData | None = None
42
+ self._templates: list[SpawnTemplate] = [
43
+ entry for entry in SPAWN_TEMPLATES if entry.type_id is not None and entry.creature is not None
44
+ ]
45
+ self._index = 0
46
+ self._phase = 0.0
47
+ self._move_speed = 2.0
48
+ self._size = 50.0
49
+ self._local_scale = 1.0
50
+ self._paused = False
51
+ self._last_dt = 0.0
52
+ self._last_step = 0.0
53
+ self._apply_template_defaults()
54
+
55
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
56
+ if self._small is not None:
57
+ return int(self._small.cell_size * scale)
58
+ return int(20 * scale)
59
+
60
+ def _draw_ui_text(
61
+ self,
62
+ text: str,
63
+ x: float,
64
+ y: float,
65
+ color: rl.Color,
66
+ scale: float = UI_TEXT_SCALE,
67
+ ) -> None:
68
+ if self._small is not None:
69
+ draw_small_text(self._small, text, x, y, scale, color)
70
+ else:
71
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
72
+
73
+ def open(self) -> None:
74
+ self._missing_assets.clear()
75
+ self._textures.clear()
76
+ self._small = load_small_font(self._assets_root, self._missing_assets)
77
+ for entry in self._templates:
78
+ if entry.creature is None:
79
+ continue
80
+ if entry.creature in self._textures:
81
+ continue
82
+ path = self._assets_root / "crimson" / "game" / f"{entry.creature}.png"
83
+ if not path.is_file():
84
+ self._missing_assets.append(str(path))
85
+ continue
86
+ self._textures[entry.creature] = rl.load_texture(str(path))
87
+ if self._missing_assets:
88
+ raise FileNotFoundError(f"Missing creature textures: {', '.join(self._missing_assets)}")
89
+
90
+ def close(self) -> None:
91
+ for texture in self._textures.values():
92
+ rl.unload_texture(texture)
93
+ self._textures.clear()
94
+ if self._small is not None:
95
+ rl.unload_texture(self._small.texture)
96
+ self._small = None
97
+
98
+ def update(self, dt: float) -> None:
99
+ self._last_dt = dt
100
+ if self._paused:
101
+ self._last_step = 0.0
102
+ return
103
+ template = self._current_template()
104
+ if template is None or template.type_id is None:
105
+ return
106
+ info = TYPE_ANIM.get(template.type_id)
107
+ if info is None:
108
+ return
109
+ flags = template.flags or CreatureFlags(0)
110
+ self._phase, self._last_step = creature_anim_advance_phase(
111
+ self._phase,
112
+ anim_rate=info.anim_rate,
113
+ move_speed=self._move_speed,
114
+ dt=dt,
115
+ size=self._size,
116
+ local_scale=self._local_scale,
117
+ flags=flags,
118
+ ai_mode=0,
119
+ )
120
+
121
+ def _current_template(self) -> SpawnTemplate | None:
122
+ if not self._templates:
123
+ return None
124
+ return self._templates[self._index]
125
+
126
+ def _advance_template(self, delta: int) -> None:
127
+ if not self._templates:
128
+ return
129
+ self._index = (self._index + delta) % len(self._templates)
130
+ self._phase = 0.0
131
+ self._apply_template_defaults()
132
+
133
+ def _apply_template_defaults(self) -> None:
134
+ template = self._current_template()
135
+ if template is None:
136
+ return
137
+ self._move_speed = template.move_speed if template.move_speed is not None else 2.0
138
+ self._size = template.size if template.size is not None else 50.0
139
+
140
+ def _handle_input(self) -> None:
141
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
142
+ self._advance_template(1)
143
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
144
+ self._advance_template(-1)
145
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
146
+ self._move_speed = min(10.0, self._move_speed + 0.1)
147
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
148
+ self._move_speed = max(0.0, self._move_speed - 0.1)
149
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
150
+ self._size = min(200.0, self._size + 1.0)
151
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
152
+ self._size = max(1.0, self._size - 1.0)
153
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
154
+ self._local_scale = min(1.0, self._local_scale + 0.05)
155
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
156
+ self._local_scale = max(0.0, self._local_scale - 0.05)
157
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
158
+ self._paused = not self._paused
159
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
160
+ self._phase = 0.0
161
+
162
+ def draw(self) -> None:
163
+ rl.clear_background(rl.Color(12, 12, 14, 255))
164
+ if self._missing_assets:
165
+ message = "Missing assets: " + ", ".join(self._missing_assets)
166
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
167
+ return
168
+ if not self._templates:
169
+ self._draw_ui_text("No spawn templates loaded.", 24, 24, UI_TEXT_COLOR)
170
+ return
171
+
172
+ self._handle_input()
173
+ template = self._current_template()
174
+ if template is None or template.type_id is None or template.creature is None:
175
+ self._draw_ui_text("Invalid template.", 24, 24, UI_TEXT_COLOR)
176
+ return
177
+ texture = self._textures.get(template.creature)
178
+ if texture is None:
179
+ self._draw_ui_text("Missing texture for creature.", 24, 24, UI_TEXT_COLOR)
180
+ return
181
+ info = TYPE_ANIM.get(template.type_id)
182
+ if info is None:
183
+ self._draw_ui_text("Missing anim info.", 24, 24, UI_TEXT_COLOR)
184
+ return
185
+
186
+ frame, mirror_applied, mode = creature_anim_select_frame(
187
+ self._phase,
188
+ base_frame=info.base,
189
+ mirror_long=info.mirror,
190
+ flags=template.flags or CreatureFlags(0),
191
+ )
192
+ grid = 8
193
+ cell = texture.width / grid
194
+ row = frame // grid
195
+ col = frame % grid
196
+
197
+ margin = 24
198
+ title = f"{template.creature} (spawn 0x{template.spawn_id:02x})"
199
+ self._draw_ui_text(title, margin, margin, UI_TEXT_COLOR)
200
+ hint = (
201
+ "Left/Right: spawn template Up/Down: move_speed PgUp/PgDn: size "
202
+ "[/]: local scale Space: pause R: reset"
203
+ )
204
+ self._draw_ui_text(hint, margin, margin + self._ui_line_height() + 6, UI_HINT_COLOR)
205
+
206
+ sheet_scale = min(
207
+ 1.0,
208
+ (rl.get_screen_width() * 0.55 - margin * 2) / texture.width,
209
+ (rl.get_screen_height() - margin * 2 - 60) / texture.height,
210
+ )
211
+ sheet_x = margin
212
+ sheet_y = margin + 60
213
+ sheet_w = texture.width * sheet_scale
214
+ sheet_h = texture.height * sheet_scale
215
+
216
+ src = rl.Rectangle(0.0, 0.0, float(texture.width), float(texture.height))
217
+ dst = rl.Rectangle(float(sheet_x), float(sheet_y), float(sheet_w), float(sheet_h))
218
+ rl.draw_texture_pro(texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
219
+
220
+ cell_w = sheet_w / grid
221
+ cell_h = sheet_h / grid
222
+ highlight = rl.Rectangle(
223
+ float(sheet_x + col * cell_w),
224
+ float(sheet_y + row * cell_h),
225
+ float(cell_w),
226
+ float(cell_h),
227
+ )
228
+ rl.draw_rectangle_lines_ex(highlight, 2, rl.Color(240, 200, 80, 255))
229
+
230
+ preview_scale = 3.0
231
+ preview_size = cell * preview_scale
232
+ preview_x = sheet_x + sheet_w + 40
233
+ preview_y = sheet_y + 40
234
+ src_frame = rl.Rectangle(
235
+ float(col * cell),
236
+ float(row * cell),
237
+ float(cell),
238
+ float(cell),
239
+ )
240
+ dst_frame = rl.Rectangle(float(preview_x), float(preview_y), float(preview_size), float(preview_size))
241
+ tint_r, tint_g, tint_b, tint_a = resolve_tint(template.tint)
242
+ tint = rl.Color(
243
+ max(0, min(255, int(tint_r * 255))),
244
+ max(0, min(255, int(tint_g * 255))),
245
+ max(0, min(255, int(tint_b * 255))),
246
+ max(0, min(255, int(tint_a * 255))),
247
+ )
248
+ rl.draw_texture_pro(texture, src_frame, dst_frame, rl.Vector2(0.0, 0.0), 0.0, tint)
249
+
250
+ info_lines = [
251
+ f"type_id={template.type_id.name}",
252
+ f"flags={int(template.flags or 0):#x}",
253
+ f"mode={mode}",
254
+ f"frame=0x{frame:02x}",
255
+ f"phase={self._phase:.6f}",
256
+ f"template.move_speed={template.move_speed!r}",
257
+ f"template.size={template.size!r}",
258
+ f"template.tint={template.tint!r}",
259
+ f"move_speed={self._move_speed:.2f}",
260
+ f"size={self._size:.1f}",
261
+ f"local_scale={self._local_scale:.2f}",
262
+ f"dt={self._last_dt:.4f}s step={self._last_step:.6f}",
263
+ f"mirror_applied={'yes' if mirror_applied else 'no'}",
264
+ f"paused={'yes' if self._paused else 'no'}",
265
+ ]
266
+ y = int(preview_y + preview_size + 16)
267
+ for line in info_lines:
268
+ self._draw_ui_text(line, preview_x, y, UI_TEXT_COLOR)
269
+ y += self._ui_line_height() + 4
270
+
271
+
272
+ @register_view("animations", "Creature animation preview")
273
+ def build_animation_view(ctx: ViewContext) -> View:
274
+ return CreatureAnimationView(ctx)
@@ -0,0 +1,404 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import random
5
+
6
+ import pyray as rl
7
+
8
+ from grim.audio import AudioState, shutdown_audio, update_audio
9
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
10
+ from grim.view import View, ViewContext
11
+
12
+ from ..creatures.spawn import SpawnId
13
+ from ..game_modes import GameMode
14
+ from ..game_world import GameWorld
15
+ from ..gameplay import PlayerInput, weapon_assign_player
16
+ from ..projectiles import ProjectileTypeId
17
+ from ..ui.cursor import draw_aim_cursor
18
+ from ..weapon_sfx import resolve_weapon_sfx_ref
19
+ from ..weapons import (
20
+ WEAPON_BY_ID,
21
+ WEAPON_TABLE,
22
+ Weapon,
23
+ projectile_type_id_from_weapon_id,
24
+ )
25
+ from .audio_bootstrap import init_view_audio
26
+ from .registry import register_view
27
+
28
+
29
+ WORLD_SIZE = 1024.0
30
+
31
+ BG = rl.Color(10, 10, 12, 255)
32
+
33
+ UI_TEXT = rl.Color(235, 235, 235, 255)
34
+ UI_HINT = rl.Color(180, 180, 180, 255)
35
+ UI_ERROR = rl.Color(240, 80, 80, 255)
36
+
37
+ ARSENAL_PLAYER_MOVE_SPEED_MULTIPLIER = 6.0
38
+ ARSENAL_PLAYER_INVULNERABLE_SHIELD_TIMER = 1e-3
39
+
40
+ DEFAULT_SPAWN_IDS = (
41
+ SpawnId.ZOMBIE_CONST_GREY_42,
42
+ SpawnId.ZOMBIE_CONST_GREEN_BRUTE_43,
43
+ SpawnId.LIZARD_CONST_GREY_2F,
44
+ SpawnId.LIZARD_CONST_YELLOW_BOSS_30,
45
+ SpawnId.ALIEN_CONST_GREEN_24,
46
+ SpawnId.ALIEN_CONST_GREY_BRUTE_29,
47
+ SpawnId.ALIEN_CONST_RED_FAST_2B,
48
+ SpawnId.SPIDER_SP1_CONST_BLUE_40,
49
+ SpawnId.SPIDER_SP1_CONST_WHITE_FAST_3E,
50
+ SpawnId.SPIDER_SP2_RANDOM_35,
51
+ )
52
+
53
+ SPECIAL_PROJECTILES: dict[int, str] = {
54
+ 9: "particle style 0 (plasma rifle)",
55
+ 13: "secondary type 1 (seeker rockets)",
56
+ 14: "secondary type 2 (plasma shotgun)",
57
+ 16: "particle style 1 (hr flamer)",
58
+ 17: "particle style 2 (mini-rocket swarmers)",
59
+ 18: "secondary type 2 (rocket minigun)",
60
+ 19: "secondary type 4 (pulse gun)",
61
+ 43: "particle style 8 (rainbow gun)",
62
+ }
63
+
64
+
65
+ def _clamp(value: float, lo: float, hi: float) -> float:
66
+ if value < lo:
67
+ return lo
68
+ if value > hi:
69
+ return hi
70
+ return value
71
+
72
+
73
+ def _fmt_float(value: float | None, *, digits: int = 3) -> str:
74
+ if value is None:
75
+ return "—"
76
+ return f"{float(value):.{digits}f}"
77
+
78
+
79
+ def _fmt_int(value: int | None) -> str:
80
+ if value is None:
81
+ return "—"
82
+ return f"{int(value)}"
83
+
84
+
85
+ def _fmt_hex(value: int | None) -> str:
86
+ if value is None:
87
+ return "—"
88
+ value = int(value)
89
+ return f"0x{value:02x} ({value})"
90
+
91
+
92
+ def _projectile_type_label(type_id: int) -> str:
93
+ try:
94
+ name = ProjectileTypeId(int(type_id)).name.lower().replace("_", " ")
95
+ except ValueError:
96
+ name = "unknown"
97
+ return f"{name} (id {type_id})"
98
+
99
+
100
+ class ArsenalDebugView:
101
+ def __init__(self, ctx: ViewContext) -> None:
102
+ self._assets_root = ctx.assets_dir
103
+ self._missing_assets: list[str] = []
104
+ self._small: SmallFontData | None = None
105
+
106
+ self._world = GameWorld(
107
+ assets_dir=ctx.assets_dir,
108
+ world_size=WORLD_SIZE,
109
+ demo_mode_active=False,
110
+ difficulty_level=0,
111
+ hardcore=False,
112
+ )
113
+ self._player = self._world.players[0] if self._world.players else None
114
+ self._aim_texture: rl.Texture | None = None
115
+ self._audio: AudioState | None = None
116
+ self._audio_rng: random.Random | None = None
117
+ self._console: ConsoleState | None = None
118
+
119
+ self._weapon_ids = sorted({int(entry.weapon_id) for entry in WEAPON_TABLE})
120
+ self._weapon_index = 0
121
+ self._spawn_ids = [int(spawn_id) for spawn_id in DEFAULT_SPAWN_IDS]
122
+ self._spawn_ring_radius = 280.0
123
+
124
+ self.close_requested = False
125
+ self._paused = False
126
+ self._screenshot_requested = False
127
+
128
+ def _apply_debug_player_cheats(self) -> None:
129
+ player = self._player
130
+ if player is None:
131
+ return
132
+ player.speed_multiplier = float(ARSENAL_PLAYER_MOVE_SPEED_MULTIPLIER)
133
+ player.shield_timer = float(ARSENAL_PLAYER_INVULNERABLE_SHIELD_TIMER)
134
+
135
+ def _ui_line_height(self, scale: float = 1.0) -> int:
136
+ if self._small is not None:
137
+ return int(self._small.cell_size * scale)
138
+ return int(20 * scale)
139
+
140
+ def _draw_ui_text(self, text: str, x: float, y: float, color: rl.Color, scale: float = 1.0) -> None:
141
+ if self._small is not None:
142
+ draw_small_text(self._small, text, x, y, scale, color)
143
+ else:
144
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
145
+
146
+ def _selected_weapon_id(self) -> int:
147
+ if not self._weapon_ids:
148
+ return 0
149
+ return int(self._weapon_ids[self._weapon_index % len(self._weapon_ids)])
150
+
151
+ def _apply_weapon(self) -> None:
152
+ if self._player is None:
153
+ return
154
+ weapon_assign_player(self._player, self._selected_weapon_id())
155
+
156
+ def _reset_scene(self) -> None:
157
+ self._world.reset(seed=0xBEEF, player_count=1, spawn_x=WORLD_SIZE * 0.5, spawn_y=WORLD_SIZE * 0.5)
158
+ self._player = self._world.players[0] if self._world.players else None
159
+ self._apply_weapon()
160
+ self._reset_creatures()
161
+ self._world.update_camera(0.0)
162
+
163
+ def _reset_creatures(self) -> None:
164
+ self._world.creatures.reset()
165
+ self._world.state.projectiles.reset()
166
+ self._world.state.secondary_projectiles.reset()
167
+ self._world.state.particles.reset()
168
+ self._world.state.sprite_effects.reset()
169
+ self._world.state.effects.reset()
170
+ self._world.state.bonus_pool.reset()
171
+ self._world.fx_queue.clear()
172
+ self._world.fx_queue_rotated.clear()
173
+
174
+ player = self._player
175
+ if player is None:
176
+ return
177
+
178
+ count = max(1, len(self._spawn_ids))
179
+ base_x = float(player.pos_x)
180
+ base_y = float(player.pos_y)
181
+ for idx in range(count):
182
+ spawn_id = int(self._spawn_ids[idx % len(self._spawn_ids)])
183
+ angle = float(idx) / float(count) * math.tau
184
+ x = _clamp(base_x + math.cos(angle) * self._spawn_ring_radius, 48.0, WORLD_SIZE - 48.0)
185
+ y = _clamp(base_y + math.sin(angle) * self._spawn_ring_radius, 48.0, WORLD_SIZE - 48.0)
186
+ heading = angle + math.pi
187
+ self._world.creatures.spawn_template(
188
+ spawn_id,
189
+ (x, y),
190
+ heading,
191
+ self._world.state.rng,
192
+ rand=self._world.state.rng.rand,
193
+ )
194
+
195
+ def _handle_debug_input(self) -> None:
196
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
197
+ self.close_requested = True
198
+
199
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
200
+ self._paused = not self._paused
201
+
202
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
203
+ self._weapon_index = (self._weapon_index - 1) % max(1, len(self._weapon_ids))
204
+ self._apply_weapon()
205
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
206
+ self._weapon_index = (self._weapon_index + 1) % max(1, len(self._weapon_ids))
207
+ self._apply_weapon()
208
+
209
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_T):
210
+ self._reset_creatures()
211
+
212
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
213
+ self._reset_scene()
214
+
215
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_P):
216
+ self._screenshot_requested = True
217
+
218
+ def _build_input(self) -> PlayerInput:
219
+ move_x = 0.0
220
+ move_y = 0.0
221
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
222
+ move_x -= 1.0
223
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
224
+ move_x += 1.0
225
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
226
+ move_y -= 1.0
227
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
228
+ move_y += 1.0
229
+
230
+ mouse = rl.get_mouse_position()
231
+ aim_x, aim_y = self._world.screen_to_world(float(mouse.x), float(mouse.y))
232
+
233
+ fire_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
234
+ fire_pressed = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
235
+ reload_pressed = rl.is_key_pressed(rl.KeyboardKey.KEY_R)
236
+
237
+ return PlayerInput(
238
+ move_x=move_x,
239
+ move_y=move_y,
240
+ aim_x=float(aim_x),
241
+ aim_y=float(aim_y),
242
+ fire_down=fire_down,
243
+ fire_pressed=fire_pressed,
244
+ reload_pressed=reload_pressed,
245
+ )
246
+
247
+ def _weapon_projectile_desc(self, weapon_id: int) -> str:
248
+ special = SPECIAL_PROJECTILES.get(int(weapon_id))
249
+ if special is not None:
250
+ return special
251
+ type_id = projectile_type_id_from_weapon_id(int(weapon_id))
252
+ if type_id is None:
253
+ return "particle/secondary"
254
+ return _projectile_type_label(type_id)
255
+
256
+ def _weapon_debug_lines(self, weapon: Weapon | None) -> list[str]:
257
+ player = self._player
258
+ if player is None:
259
+ return ["Arsenal debug: missing player"]
260
+
261
+ weapon_id = int(player.weapon_id)
262
+ name = weapon.name if weapon is not None and weapon.name else f"weapon_{weapon_id}"
263
+ index_label = f"{self._weapon_index + 1}/{max(1, len(self._weapon_ids))}"
264
+
265
+ lines = [
266
+ "Arsenal",
267
+ f"{name} (id {weapon_id}) [{index_label}]",
268
+ f"projectile: {self._weapon_projectile_desc(weapon_id)}",
269
+ f"ammo {player.ammo:.1f}/{player.clip_size:.1f} reload {player.reload_timer:.2f}/{player.reload_timer_max:.2f}",
270
+ f"shot_cd {player.shot_cooldown:.3f} spread {player.spread_heat:.3f} muzzle {player.muzzle_flash_alpha:.2f}",
271
+ ]
272
+
273
+ if weapon is None:
274
+ return lines
275
+
276
+ fire_sfx = resolve_weapon_sfx_ref(weapon.fire_sound)
277
+ reload_sfx = resolve_weapon_sfx_ref(weapon.reload_sound)
278
+ lines.extend(
279
+ [
280
+ f"clip { _fmt_int(weapon.clip_size) } reload { _fmt_float(weapon.reload_time) } cooldown { _fmt_float(weapon.shot_cooldown) }",
281
+ f"pellets { _fmt_int(weapon.pellet_count) } spread_inc { _fmt_float(weapon.spread_heat_inc) } dmg_scale { _fmt_float(weapon.damage_scale) } meta { _fmt_int(weapon.projectile_meta) }",
282
+ f"ammo_class { _fmt_int(weapon.ammo_class) } flags { _fmt_hex(weapon.flags) } icon { _fmt_int(weapon.icon_index) }",
283
+ f"sfx fire {fire_sfx or '—'} reload {reload_sfx or '—'}",
284
+ ]
285
+ )
286
+ return lines
287
+
288
+ def open(self) -> None:
289
+ self._missing_assets.clear()
290
+ try:
291
+ self._small = load_small_font(self._assets_root, self._missing_assets)
292
+ except Exception:
293
+ self._small = None
294
+
295
+ bootstrap = init_view_audio(self._assets_root)
296
+ self._world.config = bootstrap.config
297
+ self._console = bootstrap.console
298
+ self._audio = bootstrap.audio
299
+ self._audio_rng = bootstrap.audio_rng
300
+ self._world.audio = self._audio
301
+ self._world.audio_rng = self._audio_rng
302
+
303
+ self._world.open()
304
+ self._aim_texture = self._world._load_texture(
305
+ "ui_aim",
306
+ cache_path="ui/ui_aim.jaz",
307
+ file_path="ui/ui_aim.png",
308
+ )
309
+ self._reset_scene()
310
+ rl.hide_cursor()
311
+
312
+ def close(self) -> None:
313
+ rl.show_cursor()
314
+ if self._small is not None:
315
+ rl.unload_texture(self._small.texture)
316
+ self._small = None
317
+ if self._audio is not None:
318
+ shutdown_audio(self._audio)
319
+ self._audio = None
320
+ self._audio_rng = None
321
+ self._console = None
322
+ self._world.audio = None
323
+ self._world.audio_rng = None
324
+ self._world.close()
325
+ self._aim_texture = None
326
+
327
+ def consume_screenshot_request(self) -> bool:
328
+ requested = self._screenshot_requested
329
+ self._screenshot_requested = False
330
+ return requested
331
+
332
+ def update(self, dt: float) -> None:
333
+ self._handle_debug_input()
334
+
335
+ if self._paused:
336
+ dt = 0.0
337
+
338
+ player = self._player
339
+ if player is None:
340
+ return
341
+
342
+ self._apply_debug_player_cheats()
343
+ input_state = self._build_input()
344
+ self._world.update(dt, inputs=[input_state], game_mode=int(GameMode.SURVIVAL))
345
+
346
+ if self._audio is not None:
347
+ update_audio(self._audio, dt)
348
+
349
+ def draw(self) -> None:
350
+ rl.clear_background(BG)
351
+
352
+ if self._world.ground is not None:
353
+ self._world._sync_ground_settings()
354
+ self._world.ground.process_pending()
355
+
356
+ self._world.draw(draw_aim_indicators=True)
357
+
358
+ warn_x = 24.0
359
+ warn_y = 24.0
360
+ warn_line = float(self._ui_line_height())
361
+ if self._missing_assets:
362
+ self._draw_ui_text("Missing assets (ui): " + ", ".join(self._missing_assets), warn_x, warn_y, UI_ERROR)
363
+ warn_y += warn_line
364
+ if self._world.missing_assets:
365
+ self._draw_ui_text(
366
+ "Missing assets (world): " + ", ".join(self._world.missing_assets),
367
+ warn_x,
368
+ warn_y,
369
+ UI_ERROR,
370
+ )
371
+ warn_y += warn_line
372
+
373
+ x = 16.0
374
+ y = 12.0
375
+ line = float(self._ui_line_height())
376
+
377
+ weapon = WEAPON_BY_ID.get(int(self._player.weapon_id)) if self._player is not None else None
378
+ for text in self._weapon_debug_lines(weapon):
379
+ self._draw_ui_text(text, x, y, UI_TEXT)
380
+ y += line
381
+
382
+ if self._player is not None:
383
+ alive = sum(1 for c in self._world.creatures.entries if c.active and c.hp > 0.0)
384
+ total = sum(1 for c in self._world.creatures.entries if c.active)
385
+ self._draw_ui_text(f"creatures alive {alive}/{total}", x, y, UI_TEXT)
386
+ y += line
387
+
388
+ y += 6.0
389
+ self._draw_ui_text(
390
+ "WASD move LMB fire R reload [/] cycle weapons Space pause T respawn Backspace reset Esc quit",
391
+ x,
392
+ y,
393
+ UI_HINT,
394
+ )
395
+ y += line
396
+ self._draw_ui_text("P screenshot", x, y, UI_HINT)
397
+
398
+ mouse = rl.get_mouse_position()
399
+ draw_aim_cursor(self._world.particles_texture, self._aim_texture, x=float(mouse.x), y=float(mouse.y))
400
+
401
+
402
+ @register_view("arsenal", "Arsenal")
403
+ def build_arsenal_debug_view(ctx: ViewContext) -> View:
404
+ return ArsenalDebugView(ctx)