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,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,414 @@
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, init_audio_state, shutdown_audio, update_audio
9
+ from grim.config import ensure_crimson_cfg
10
+ from grim.console import ConsoleLog, ConsoleState
11
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
12
+ from grim.view import View, ViewContext
13
+
14
+ from ..creatures.spawn import SpawnId
15
+ from ..game_modes import GameMode
16
+ from ..game_world import GameWorld
17
+ from ..gameplay import PlayerInput, weapon_assign_player
18
+ from ..paths import default_runtime_dir
19
+ from ..projectiles import ProjectileTypeId
20
+ from ..ui.cursor import draw_aim_cursor
21
+ from ..weapon_sfx import resolve_weapon_sfx_ref
22
+ from ..weapons import (
23
+ WEAPON_BY_ID,
24
+ WEAPON_TABLE,
25
+ Weapon,
26
+ projectile_type_id_from_weapon_id,
27
+ )
28
+ from .registry import register_view
29
+
30
+
31
+ WORLD_SIZE = 1024.0
32
+
33
+ BG = rl.Color(10, 10, 12, 255)
34
+
35
+ UI_TEXT = rl.Color(235, 235, 235, 255)
36
+ UI_HINT = rl.Color(180, 180, 180, 255)
37
+ UI_ERROR = rl.Color(240, 80, 80, 255)
38
+
39
+ DEFAULT_SPAWN_IDS = (
40
+ SpawnId.ZOMBIE_CONST_GREY_42,
41
+ SpawnId.ZOMBIE_CONST_GREEN_BRUTE_43,
42
+ SpawnId.LIZARD_CONST_GREY_2F,
43
+ SpawnId.LIZARD_CONST_YELLOW_BOSS_30,
44
+ SpawnId.ALIEN_CONST_GREEN_24,
45
+ SpawnId.ALIEN_CONST_GREY_BRUTE_29,
46
+ SpawnId.ALIEN_CONST_RED_FAST_2B,
47
+ SpawnId.SPIDER_SP1_CONST_BLUE_40,
48
+ SpawnId.SPIDER_SP1_CONST_WHITE_FAST_3E,
49
+ SpawnId.SPIDER_SP2_RANDOM_35,
50
+ )
51
+
52
+ SPECIAL_PROJECTILES: dict[int, str] = {
53
+ 9: "particle style 0 (plasma rifle)",
54
+ 13: "secondary type 1 (seeker rockets)",
55
+ 14: "secondary type 2 (plasma shotgun)",
56
+ 16: "particle style 1 (hr flamer)",
57
+ 17: "particle style 2 (mini-rocket swarmers)",
58
+ 18: "secondary type 2 (rocket minigun)",
59
+ 19: "secondary type 4 (pulse gun)",
60
+ 43: "particle style 8 (rainbow gun)",
61
+ }
62
+
63
+
64
+ def _clamp(value: float, lo: float, hi: float) -> float:
65
+ if value < lo:
66
+ return lo
67
+ if value > hi:
68
+ return hi
69
+ return value
70
+
71
+
72
+ def _fmt_float(value: float | None, *, digits: int = 3) -> str:
73
+ if value is None:
74
+ return "—"
75
+ return f"{float(value):.{digits}f}"
76
+
77
+
78
+ def _fmt_int(value: int | None) -> str:
79
+ if value is None:
80
+ return "—"
81
+ return f"{int(value)}"
82
+
83
+
84
+ def _fmt_hex(value: int | None) -> str:
85
+ if value is None:
86
+ return "—"
87
+ value = int(value)
88
+ return f"0x{value:02x} ({value})"
89
+
90
+
91
+ def _projectile_type_label(type_id: int) -> str:
92
+ try:
93
+ name = ProjectileTypeId(int(type_id)).name.lower().replace("_", " ")
94
+ except ValueError:
95
+ name = "unknown"
96
+ return f"{name} (id {type_id})"
97
+
98
+
99
+ class ArsenalDebugView:
100
+ def __init__(self, ctx: ViewContext) -> None:
101
+ self._assets_root = ctx.assets_dir
102
+ self._missing_assets: list[str] = []
103
+ self._small: SmallFontData | None = None
104
+
105
+ self._world = GameWorld(
106
+ assets_dir=ctx.assets_dir,
107
+ world_size=WORLD_SIZE,
108
+ demo_mode_active=False,
109
+ difficulty_level=0,
110
+ hardcore=False,
111
+ )
112
+ self._player = self._world.players[0] if self._world.players else None
113
+ self._aim_texture: rl.Texture | None = None
114
+ self._audio: AudioState | None = None
115
+ self._audio_rng: random.Random | None = None
116
+ self._console: ConsoleState | None = None
117
+
118
+ self._weapon_ids = sorted({int(entry.weapon_id) for entry in WEAPON_TABLE})
119
+ self._weapon_index = 0
120
+ self._spawn_ids = [int(spawn_id) for spawn_id in DEFAULT_SPAWN_IDS]
121
+ self._spawn_ring_radius = 280.0
122
+
123
+ self.close_requested = False
124
+ self._paused = False
125
+ self._screenshot_requested = False
126
+
127
+ def _ui_line_height(self, scale: float = 1.0) -> int:
128
+ if self._small is not None:
129
+ return int(self._small.cell_size * scale)
130
+ return int(20 * scale)
131
+
132
+ def _draw_ui_text(self, text: str, x: float, y: float, color: rl.Color, scale: float = 1.0) -> None:
133
+ if self._small is not None:
134
+ draw_small_text(self._small, text, x, y, scale, color)
135
+ else:
136
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
137
+
138
+ def _selected_weapon_id(self) -> int:
139
+ if not self._weapon_ids:
140
+ return 0
141
+ return int(self._weapon_ids[self._weapon_index % len(self._weapon_ids)])
142
+
143
+ def _apply_weapon(self) -> None:
144
+ if self._player is None:
145
+ return
146
+ weapon_assign_player(self._player, self._selected_weapon_id())
147
+
148
+ def _reset_scene(self) -> None:
149
+ self._world.reset(seed=0xBEEF, player_count=1, spawn_x=WORLD_SIZE * 0.5, spawn_y=WORLD_SIZE * 0.5)
150
+ self._player = self._world.players[0] if self._world.players else None
151
+ self._apply_weapon()
152
+ self._reset_creatures()
153
+ self._world.update_camera(0.0)
154
+
155
+ def _reset_creatures(self) -> None:
156
+ self._world.creatures.reset()
157
+ self._world.state.projectiles.reset()
158
+ self._world.state.secondary_projectiles.reset()
159
+ self._world.state.particles.reset()
160
+ self._world.state.sprite_effects.reset()
161
+ self._world.state.effects.reset()
162
+ self._world.state.bonus_pool.reset()
163
+ self._world.fx_queue.clear()
164
+ self._world.fx_queue_rotated.clear()
165
+
166
+ player = self._player
167
+ if player is None:
168
+ return
169
+
170
+ count = max(1, len(self._spawn_ids))
171
+ base_x = float(player.pos_x)
172
+ base_y = float(player.pos_y)
173
+ for idx in range(count):
174
+ spawn_id = int(self._spawn_ids[idx % len(self._spawn_ids)])
175
+ angle = float(idx) / float(count) * math.tau
176
+ x = _clamp(base_x + math.cos(angle) * self._spawn_ring_radius, 48.0, WORLD_SIZE - 48.0)
177
+ y = _clamp(base_y + math.sin(angle) * self._spawn_ring_radius, 48.0, WORLD_SIZE - 48.0)
178
+ heading = angle + math.pi
179
+ self._world.creatures.spawn_template(
180
+ spawn_id,
181
+ (x, y),
182
+ heading,
183
+ self._world.state.rng,
184
+ rand=self._world.state.rng.rand,
185
+ )
186
+
187
+ def _handle_debug_input(self) -> None:
188
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
189
+ self.close_requested = True
190
+
191
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
192
+ self._paused = not self._paused
193
+
194
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
195
+ self._weapon_index = (self._weapon_index - 1) % max(1, len(self._weapon_ids))
196
+ self._apply_weapon()
197
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
198
+ self._weapon_index = (self._weapon_index + 1) % max(1, len(self._weapon_ids))
199
+ self._apply_weapon()
200
+
201
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_T):
202
+ self._reset_creatures()
203
+
204
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
205
+ self._reset_scene()
206
+
207
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_P):
208
+ self._screenshot_requested = True
209
+
210
+ def _build_input(self) -> PlayerInput:
211
+ move_x = 0.0
212
+ move_y = 0.0
213
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
214
+ move_x -= 1.0
215
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
216
+ move_x += 1.0
217
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
218
+ move_y -= 1.0
219
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
220
+ move_y += 1.0
221
+
222
+ mouse = rl.get_mouse_position()
223
+ aim_x, aim_y = self._world.screen_to_world(float(mouse.x), float(mouse.y))
224
+
225
+ fire_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
226
+ fire_pressed = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
227
+ reload_pressed = rl.is_key_pressed(rl.KeyboardKey.KEY_R)
228
+
229
+ return PlayerInput(
230
+ move_x=move_x,
231
+ move_y=move_y,
232
+ aim_x=float(aim_x),
233
+ aim_y=float(aim_y),
234
+ fire_down=fire_down,
235
+ fire_pressed=fire_pressed,
236
+ reload_pressed=reload_pressed,
237
+ )
238
+
239
+ def _weapon_projectile_desc(self, weapon_id: int) -> str:
240
+ special = SPECIAL_PROJECTILES.get(int(weapon_id))
241
+ if special is not None:
242
+ return special
243
+ type_id = projectile_type_id_from_weapon_id(int(weapon_id))
244
+ if type_id is None:
245
+ return "particle/secondary"
246
+ return _projectile_type_label(type_id)
247
+
248
+ def _weapon_debug_lines(self, weapon: Weapon | None) -> list[str]:
249
+ player = self._player
250
+ if player is None:
251
+ return ["Arsenal debug: missing player"]
252
+
253
+ weapon_id = int(player.weapon_id)
254
+ name = weapon.name if weapon is not None and weapon.name else f"weapon_{weapon_id}"
255
+ index_label = f"{self._weapon_index + 1}/{max(1, len(self._weapon_ids))}"
256
+
257
+ lines = [
258
+ "Arsenal",
259
+ f"{name} (id {weapon_id}) [{index_label}]",
260
+ f"projectile: {self._weapon_projectile_desc(weapon_id)}",
261
+ f"ammo {player.ammo:.1f}/{player.clip_size:.1f} reload {player.reload_timer:.2f}/{player.reload_timer_max:.2f}",
262
+ f"shot_cd {player.shot_cooldown:.3f} spread {player.spread_heat:.3f} muzzle {player.muzzle_flash_alpha:.2f}",
263
+ ]
264
+
265
+ if weapon is None:
266
+ return lines
267
+
268
+ fire_sfx = resolve_weapon_sfx_ref(weapon.fire_sound)
269
+ reload_sfx = resolve_weapon_sfx_ref(weapon.reload_sound)
270
+ lines.extend(
271
+ [
272
+ f"clip { _fmt_int(weapon.clip_size) } reload { _fmt_float(weapon.reload_time) } cooldown { _fmt_float(weapon.shot_cooldown) }",
273
+ 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) }",
274
+ f"ammo_class { _fmt_int(weapon.ammo_class) } flags { _fmt_hex(weapon.flags) } icon { _fmt_int(weapon.icon_index) }",
275
+ f"sfx fire {fire_sfx or '—'} reload {reload_sfx or '—'}",
276
+ ]
277
+ )
278
+ return lines
279
+
280
+ def open(self) -> None:
281
+ self._missing_assets.clear()
282
+ try:
283
+ self._small = load_small_font(self._assets_root, self._missing_assets)
284
+ except Exception:
285
+ self._small = None
286
+
287
+ runtime_dir = default_runtime_dir()
288
+ if runtime_dir.is_dir():
289
+ try:
290
+ self._world.config = ensure_crimson_cfg(runtime_dir)
291
+ except Exception:
292
+ self._world.config = None
293
+ else:
294
+ self._world.config = None
295
+
296
+ if self._world.config is not None:
297
+ try:
298
+ self._console = ConsoleState(
299
+ base_dir=runtime_dir,
300
+ log=ConsoleLog(base_dir=runtime_dir),
301
+ assets_dir=self._assets_root,
302
+ )
303
+ self._audio = init_audio_state(self._world.config, self._assets_root, self._console)
304
+ self._audio_rng = random.Random(0xBEEF)
305
+ self._world.audio = self._audio
306
+ self._world.audio_rng = self._audio_rng
307
+ except Exception:
308
+ self._audio = None
309
+ self._audio_rng = None
310
+ self._console = None
311
+ self._world.audio = None
312
+ self._world.audio_rng = None
313
+
314
+ self._world.open()
315
+ self._aim_texture = self._world._load_texture(
316
+ "ui_aim",
317
+ cache_path="ui/ui_aim.jaz",
318
+ file_path="ui/ui_aim.png",
319
+ )
320
+ self._reset_scene()
321
+ rl.hide_cursor()
322
+
323
+ def close(self) -> None:
324
+ rl.show_cursor()
325
+ if self._small is not None:
326
+ rl.unload_texture(self._small.texture)
327
+ self._small = None
328
+ if self._audio is not None:
329
+ shutdown_audio(self._audio)
330
+ self._audio = None
331
+ self._audio_rng = None
332
+ self._console = None
333
+ self._world.audio = None
334
+ self._world.audio_rng = None
335
+ self._world.close()
336
+ self._aim_texture = None
337
+
338
+ def consume_screenshot_request(self) -> bool:
339
+ requested = self._screenshot_requested
340
+ self._screenshot_requested = False
341
+ return requested
342
+
343
+ def update(self, dt: float) -> None:
344
+ self._handle_debug_input()
345
+
346
+ if self._paused:
347
+ dt = 0.0
348
+
349
+ player = self._player
350
+ if player is None:
351
+ return
352
+
353
+ input_state = self._build_input()
354
+ self._world.update(dt, inputs=[input_state], game_mode=int(GameMode.SURVIVAL))
355
+
356
+ if self._audio is not None:
357
+ update_audio(self._audio, dt)
358
+
359
+ def draw(self) -> None:
360
+ rl.clear_background(BG)
361
+
362
+ if self._world.ground is not None:
363
+ self._world._sync_ground_settings()
364
+ self._world.ground.process_pending()
365
+
366
+ self._world.draw(draw_aim_indicators=True)
367
+
368
+ warn_x = 24.0
369
+ warn_y = 24.0
370
+ warn_line = float(self._ui_line_height())
371
+ if self._missing_assets:
372
+ self._draw_ui_text("Missing assets (ui): " + ", ".join(self._missing_assets), warn_x, warn_y, UI_ERROR)
373
+ warn_y += warn_line
374
+ if self._world.missing_assets:
375
+ self._draw_ui_text(
376
+ "Missing assets (world): " + ", ".join(self._world.missing_assets),
377
+ warn_x,
378
+ warn_y,
379
+ UI_ERROR,
380
+ )
381
+ warn_y += warn_line
382
+
383
+ x = 16.0
384
+ y = 12.0
385
+ line = float(self._ui_line_height())
386
+
387
+ weapon = WEAPON_BY_ID.get(int(self._player.weapon_id)) if self._player is not None else None
388
+ for text in self._weapon_debug_lines(weapon):
389
+ self._draw_ui_text(text, x, y, UI_TEXT)
390
+ y += line
391
+
392
+ if self._player is not None:
393
+ alive = sum(1 for c in self._world.creatures.entries if c.active and c.hp > 0.0)
394
+ total = sum(1 for c in self._world.creatures.entries if c.active)
395
+ self._draw_ui_text(f"creatures alive {alive}/{total}", x, y, UI_TEXT)
396
+ y += line
397
+
398
+ y += 6.0
399
+ self._draw_ui_text(
400
+ "WASD move LMB fire R reload [/] cycle weapons Space pause T respawn Backspace reset Esc quit",
401
+ x,
402
+ y,
403
+ UI_HINT,
404
+ )
405
+ y += line
406
+ self._draw_ui_text("P screenshot", x, y, UI_HINT)
407
+
408
+ mouse = rl.get_mouse_position()
409
+ draw_aim_cursor(self._world.particles_texture, self._aim_texture, x=float(mouse.x), y=float(mouse.y))
410
+
411
+
412
+ @register_view("arsenal", "Arsenal")
413
+ def build_arsenal_debug_view(ctx: ViewContext) -> View:
414
+ return ArsenalDebugView(ctx)