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,393 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+ import random
6
+
7
+ import pyray as rl
8
+
9
+ from grim.audio import AudioState, shutdown_audio
10
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
11
+ from grim.view import View, ViewContext
12
+
13
+ from ..game_world import GameWorld
14
+ from ..gameplay import PlayerInput, player_update, weapon_assign_player
15
+ from ..ui.cursor import draw_aim_cursor
16
+ from ..weapons import WEAPON_TABLE
17
+ from .audio_bootstrap import init_view_audio
18
+ from .registry import register_view
19
+
20
+
21
+ WORLD_SIZE = 1024.0
22
+
23
+ BG = rl.Color(10, 10, 12, 255)
24
+ GRID_COLOR = rl.Color(255, 255, 255, 14)
25
+
26
+ UI_TEXT = rl.Color(235, 235, 235, 255)
27
+ UI_HINT = rl.Color(180, 180, 180, 255)
28
+ UI_ERROR = rl.Color(240, 80, 80, 255)
29
+
30
+ TARGET_FILL = rl.Color(220, 80, 80, 220)
31
+ TARGET_OUTLINE = rl.Color(140, 40, 40, 255)
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class TargetDummy:
36
+ x: float
37
+ y: float
38
+ hp: float
39
+ size: float = 56.0
40
+
41
+
42
+ def _clamp(value: float, lo: float, hi: float) -> float:
43
+ if value < lo:
44
+ return lo
45
+ if value > hi:
46
+ return hi
47
+ return value
48
+
49
+
50
+ class ProjectileRenderDebugView:
51
+ def __init__(self, ctx: ViewContext) -> None:
52
+ self._assets_root = ctx.assets_dir
53
+ self._missing_assets: list[str] = []
54
+ self._small: SmallFontData | None = None
55
+
56
+ self._world = GameWorld(
57
+ assets_dir=ctx.assets_dir,
58
+ world_size=WORLD_SIZE,
59
+ demo_mode_active=False,
60
+ difficulty_level=0,
61
+ hardcore=False,
62
+ )
63
+ self._player = self._world.players[0] if self._world.players else None
64
+ self._aim_texture: rl.Texture | None = None
65
+ self._audio: AudioState | None = None
66
+ self._audio_rng: random.Random | None = None
67
+ self._console: ConsoleState | None = None
68
+
69
+ self._weapon_ids = [entry.weapon_id for entry in WEAPON_TABLE if entry.name is not None]
70
+ self._weapon_index = 0
71
+
72
+ self._targets: list[TargetDummy] = []
73
+
74
+ self.close_requested = False
75
+ self._paused = False
76
+ self._screenshot_requested = False
77
+
78
+ def _ui_line_height(self, scale: float = 1.0) -> int:
79
+ if self._small is not None:
80
+ return int(self._small.cell_size * scale)
81
+ return int(20 * scale)
82
+
83
+ def _draw_ui_text(self, text: str, x: float, y: float, color: rl.Color, scale: float = 1.0) -> None:
84
+ if self._small is not None:
85
+ draw_small_text(self._small, text, x, y, scale, color)
86
+ else:
87
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
88
+
89
+ def _selected_weapon_id(self) -> int:
90
+ if not self._weapon_ids:
91
+ return 0
92
+ return int(self._weapon_ids[self._weapon_index % len(self._weapon_ids)])
93
+
94
+ def _apply_weapon(self) -> None:
95
+ if self._player is None:
96
+ return
97
+ weapon_assign_player(self._player, self._selected_weapon_id())
98
+
99
+ def _reset_targets(self) -> None:
100
+ self._targets.clear()
101
+ base_x = WORLD_SIZE * 0.5
102
+ base_y = WORLD_SIZE * 0.5
103
+ ring = 260.0
104
+ for idx in range(10):
105
+ angle = float(idx) / 10.0 * math.tau
106
+ x = _clamp(base_x + math.cos(angle) * ring, 40.0, WORLD_SIZE - 40.0)
107
+ y = _clamp(base_y + math.sin(angle) * ring, 40.0, WORLD_SIZE - 40.0)
108
+ self._targets.append(TargetDummy(x=x, y=y, hp=260.0, size=64.0))
109
+
110
+ def _reset_scene(self) -> None:
111
+ self._world.reset(seed=0xBEEF, player_count=1, spawn_x=WORLD_SIZE * 0.5, spawn_y=WORLD_SIZE * 0.5)
112
+ self._player = self._world.players[0] if self._world.players else None
113
+ self._weapon_index = 0
114
+ self._apply_weapon()
115
+ self._reset_targets()
116
+ self._world.update_camera(0.0)
117
+
118
+ def _world_scale(self) -> float:
119
+ _cam_x, _cam_y, scale_x, scale_y = self._world._world_params()
120
+ return (scale_x + scale_y) * 0.5
121
+
122
+ def _draw_grid(self) -> None:
123
+ step = 64.0
124
+ out_w = float(rl.get_screen_width())
125
+ out_h = float(rl.get_screen_height())
126
+ screen_w, screen_h = self._world._camera_screen_size()
127
+ cam_x, cam_y, scale_x, scale_y = self._world._world_params()
128
+
129
+ start_x = math.floor((-cam_x) / step) * step
130
+ end_x = (-cam_x) + screen_w
131
+ x = start_x
132
+ while x <= end_x:
133
+ sx = int((x + cam_x) * scale_x)
134
+ rl.draw_line(sx, 0, sx, int(out_h), GRID_COLOR)
135
+ x += step
136
+
137
+ start_y = math.floor((-cam_y) / step) * step
138
+ end_y = (-cam_y) + screen_h
139
+ y = start_y
140
+ while y <= end_y:
141
+ sy = int((y + cam_y) * scale_y)
142
+ rl.draw_line(0, sy, int(out_w), sy, GRID_COLOR)
143
+ y += step
144
+
145
+ def _handle_debug_input(self) -> None:
146
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
147
+ self.close_requested = True
148
+
149
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
150
+ self._paused = not self._paused
151
+
152
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
153
+ self._weapon_index = (self._weapon_index - 1) % max(1, len(self._weapon_ids))
154
+ self._apply_weapon()
155
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
156
+ self._weapon_index = (self._weapon_index + 1) % max(1, len(self._weapon_ids))
157
+ self._apply_weapon()
158
+
159
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_T):
160
+ self._reset_targets()
161
+
162
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
163
+ self._reset_scene()
164
+
165
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_P):
166
+ self._screenshot_requested = True
167
+
168
+ def _build_input(self) -> PlayerInput:
169
+ move_x = 0.0
170
+ move_y = 0.0
171
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
172
+ move_x -= 1.0
173
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
174
+ move_x += 1.0
175
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
176
+ move_y -= 1.0
177
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
178
+ move_y += 1.0
179
+
180
+ mouse = rl.get_mouse_position()
181
+ aim_x, aim_y = self._world.screen_to_world(float(mouse.x), float(mouse.y))
182
+
183
+ fire_down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
184
+ fire_pressed = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
185
+ reload_pressed = rl.is_key_pressed(rl.KeyboardKey.KEY_R)
186
+
187
+ return PlayerInput(
188
+ move_x=move_x,
189
+ move_y=move_y,
190
+ aim_x=float(aim_x),
191
+ aim_y=float(aim_y),
192
+ fire_down=fire_down,
193
+ fire_pressed=fire_pressed,
194
+ reload_pressed=reload_pressed,
195
+ )
196
+
197
+ def open(self) -> None:
198
+ self._missing_assets.clear()
199
+ try:
200
+ self._small = load_small_font(self._assets_root, self._missing_assets)
201
+ except Exception:
202
+ self._small = None
203
+
204
+ bootstrap = init_view_audio(self._assets_root)
205
+ self._world.config = bootstrap.config
206
+ self._console = bootstrap.console
207
+ self._audio = bootstrap.audio
208
+ self._audio_rng = bootstrap.audio_rng
209
+ self._world.audio = self._audio
210
+ self._world.audio_rng = self._audio_rng
211
+
212
+ self._world.open()
213
+ self._aim_texture = self._world._load_texture(
214
+ "ui_aim",
215
+ cache_path="ui/ui_aim.jaz",
216
+ file_path="ui/ui_aim.png",
217
+ )
218
+ self._reset_scene()
219
+ rl.hide_cursor()
220
+
221
+ def close(self) -> None:
222
+ rl.show_cursor()
223
+ if self._small is not None:
224
+ rl.unload_texture(self._small.texture)
225
+ self._small = None
226
+ if self._audio is not None:
227
+ shutdown_audio(self._audio)
228
+ self._audio = None
229
+ self._audio_rng = None
230
+ self._console = None
231
+ self._world.audio = None
232
+ self._world.audio_rng = None
233
+ self._world.close()
234
+ self._aim_texture = None
235
+
236
+ def consume_screenshot_request(self) -> bool:
237
+ requested = self._screenshot_requested
238
+ self._screenshot_requested = False
239
+ return requested
240
+
241
+ def update(self, dt: float) -> None:
242
+ self._handle_debug_input()
243
+
244
+ if self._paused:
245
+ dt = 0.0
246
+
247
+ if self._world.ground is not None:
248
+ self._world._sync_ground_settings()
249
+ self._world.ground.process_pending()
250
+
251
+ if self._player is None:
252
+ return
253
+
254
+ prev_audio = None
255
+ if self._world.audio is not None:
256
+ prev_audio = (int(self._player.shot_seq), bool(self._player.reload_active), float(self._player.reload_timer))
257
+
258
+ detail_preset = 5
259
+ if self._world.config is not None:
260
+ detail_preset = int(self._world.config.data.get("detail_preset", 5) or 5)
261
+
262
+ # Keep the scene stable: targets are static, only projectiles + player advance.
263
+ hits = self._world.state.projectiles.update(
264
+ float(dt),
265
+ self._targets,
266
+ world_size=WORLD_SIZE,
267
+ damage_scale_by_type=self._world._damage_scale_by_type,
268
+ detail_preset=int(detail_preset),
269
+ rng=self._world.state.rng.rand,
270
+ runtime_state=self._world.state,
271
+ )
272
+ self._world.state.secondary_projectiles.update_pulse_gun(float(dt), self._targets)
273
+ if hits:
274
+ self._world._queue_projectile_decals(hits)
275
+ self._world._play_hit_sfx(hits, game_mode=1)
276
+ self._targets = [target for target in self._targets if target.hp > 0.0]
277
+
278
+ input_state = self._build_input()
279
+ player_update(self._player, input_state, float(dt), self._world.state, world_size=WORLD_SIZE)
280
+
281
+ if prev_audio is not None:
282
+ prev_shot_seq, prev_reload_active, prev_reload_timer = prev_audio
283
+ self._world._handle_player_audio(
284
+ self._player,
285
+ prev_shot_seq=prev_shot_seq,
286
+ prev_reload_active=prev_reload_active,
287
+ prev_reload_timer=prev_reload_timer,
288
+ )
289
+
290
+ self._world._bake_fx_queues()
291
+ self._world.update_camera(float(dt))
292
+
293
+ def draw(self) -> None:
294
+ rl.clear_background(BG)
295
+
296
+ cam_x, cam_y, scale_x, scale_y = self._world._world_params()
297
+ screen_w, screen_h = self._world._camera_screen_size()
298
+
299
+ if self._world.ground is not None:
300
+ self._world.ground.draw(cam_x, cam_y, screen_w=screen_w, screen_h=screen_h)
301
+
302
+ warn_x = 24.0
303
+ warn_y = 24.0
304
+ warn_line = float(self._ui_line_height())
305
+ if self._missing_assets:
306
+ self._draw_ui_text("Missing assets (ui): " + ", ".join(self._missing_assets), warn_x, warn_y, UI_ERROR)
307
+ warn_y += warn_line
308
+ if self._world.missing_assets:
309
+ self._draw_ui_text(
310
+ "Missing assets (world): " + ", ".join(self._world.missing_assets),
311
+ warn_x,
312
+ warn_y,
313
+ UI_ERROR,
314
+ )
315
+ warn_y += warn_line
316
+
317
+ scale = self._world_scale()
318
+
319
+ self._draw_grid()
320
+
321
+ # Targets.
322
+ for target in self._targets:
323
+ sx, sy = self._world.world_to_screen(float(target.x), float(target.y))
324
+ radius = max(2.0, float(target.size) * 0.5 * scale)
325
+ rl.draw_circle(int(sx), int(sy), radius, TARGET_FILL)
326
+ rl.draw_circle_lines(int(sx), int(sy), int(max(1.0, radius)), TARGET_OUTLINE)
327
+
328
+ # Projectiles.
329
+ for proj_index, proj in enumerate(self._world.state.projectiles.entries):
330
+ if not proj.active:
331
+ continue
332
+ self._world._draw_projectile(proj, proj_index=proj_index, scale=scale)
333
+ for proj in self._world.state.secondary_projectiles.iter_active():
334
+ self._world._draw_secondary_projectile(proj, scale=scale)
335
+
336
+ # Player.
337
+ player = self._player
338
+ if player is not None:
339
+ texture = self._world.creature_textures.get("trooper")
340
+ if texture is not None:
341
+ self._world._draw_player_trooper_sprite(
342
+ texture,
343
+ player,
344
+ cam_x=cam_x,
345
+ cam_y=cam_y,
346
+ scale_x=scale_x,
347
+ scale_y=scale_y,
348
+ scale=scale,
349
+ )
350
+ else:
351
+ px, py = self._world.world_to_screen(float(player.pos_x), float(player.pos_y))
352
+ rl.draw_circle(int(px), int(py), max(1.0, 14.0 * scale), rl.Color(90, 190, 120, 255))
353
+
354
+ if player is not None and player.health > 0.0:
355
+ aim_x = float(getattr(player, "aim_x", player.pos_x))
356
+ aim_y = float(getattr(player, "aim_y", player.pos_y))
357
+ dist = math.hypot(aim_x - float(player.pos_x), aim_y - float(player.pos_y))
358
+ radius = max(6.0, dist * float(getattr(player, "spread_heat", 0.0)) * 0.5)
359
+ screen_radius = max(1.0, radius * scale)
360
+ aim_screen_x, aim_screen_y = self._world.world_to_screen(aim_x, aim_y)
361
+ self._world._draw_aim_circle(x=aim_screen_x, y=aim_screen_y, radius=screen_radius)
362
+
363
+ # UI.
364
+ x = 16.0
365
+ y = 12.0
366
+ line = float(self._ui_line_height())
367
+
368
+ weapon_id = int(player.weapon_id) if player is not None else 0
369
+ weapon_name = next((w.name for w in WEAPON_TABLE if w.weapon_id == weapon_id), None) or f"weapon_{weapon_id}"
370
+ self._draw_ui_text("Projectile render debug", x, y, UI_TEXT)
371
+ y += line
372
+ self._draw_ui_text(f"{weapon_name} (weapon_id={weapon_id})", x, y, UI_TEXT)
373
+ y += line
374
+ if player is not None:
375
+ self._draw_ui_text(
376
+ f"ammo {player.ammo}/{player.clip_size} reload {player.reload_timer:.2f}/{player.reload_timer_max:.2f}",
377
+ x,
378
+ y,
379
+ UI_TEXT,
380
+ )
381
+ y += line
382
+ y += 6.0
383
+ self._draw_ui_text("WASD move LMB fire R reload [/] cycle weapons Space pause P screenshot", x, y, UI_HINT)
384
+ y += line
385
+ self._draw_ui_text("T reset targets Backspace reset scene Esc quit", x, y, UI_HINT)
386
+
387
+ mouse = rl.get_mouse_position()
388
+ draw_aim_cursor(self._world.particles_texture, self._aim_texture, x=float(mouse.x), y=float(mouse.y))
389
+
390
+
391
+ @register_view("projectile-render-debug", "Projectile render debug")
392
+ def build_projectile_render_debug_view(ctx: ViewContext) -> View:
393
+ return ProjectileRenderDebugView(ctx)
@@ -0,0 +1,221 @@
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
+ UI_TEXT_SCALE = 1.0
12
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
13
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
14
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
15
+ UI_KNOWN_COLOR = rl.Color(80, 160, 240, 255)
16
+ UI_HOVER_COLOR = rl.Color(240, 200, 80, 255)
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class KnownProjectile:
21
+ type_id: int
22
+ grid: int
23
+ frame: int
24
+ label: str
25
+
26
+
27
+ KNOWN_PROJECTILES = [
28
+ KnownProjectile(type_id=0x13, grid=2, frame=0, label="Pulse Gun"),
29
+ KnownProjectile(type_id=0x1D, grid=4, frame=3, label="Splitter Gun"),
30
+ KnownProjectile(type_id=0x19, grid=4, frame=6, label="Blade Gun"),
31
+ KnownProjectile(type_id=0x15, grid=4, frame=2, label="Ion Rifle"),
32
+ KnownProjectile(type_id=0x16, grid=4, frame=2, label="Ion Minigun"),
33
+ KnownProjectile(type_id=0x17, grid=4, frame=2, label="Ion Cannon"),
34
+ KnownProjectile(type_id=0x18, grid=4, frame=2, label="Shrinkifier 5k"),
35
+ KnownProjectile(type_id=0x2D, grid=4, frame=2, label="Fire Bullets"),
36
+ ]
37
+
38
+
39
+ def _build_known_map() -> dict[int, dict[int, list[KnownProjectile]]]:
40
+ known: dict[int, dict[int, list[KnownProjectile]]] = {}
41
+ for entry in KNOWN_PROJECTILES:
42
+ grid_map = known.setdefault(entry.grid, {})
43
+ grid_map.setdefault(entry.frame, []).append(entry)
44
+ return known
45
+
46
+
47
+ KNOWN_BY_GRID = _build_known_map()
48
+
49
+
50
+ class ProjectileView:
51
+ def __init__(self, ctx: ViewContext) -> None:
52
+ self._assets_root = ctx.assets_dir
53
+ self._missing_assets: list[str] = []
54
+ self._texture: rl.Texture | None = None
55
+ self._small: SmallFontData | None = None
56
+ self._grid = 4
57
+
58
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
59
+ if self._small is not None:
60
+ return int(self._small.cell_size * scale)
61
+ return int(20 * scale)
62
+
63
+ def _draw_ui_text(
64
+ self,
65
+ text: str,
66
+ x: float,
67
+ y: float,
68
+ color: rl.Color,
69
+ scale: float = UI_TEXT_SCALE,
70
+ ) -> None:
71
+ if self._small is not None:
72
+ draw_small_text(self._small, text, x, y, scale, color)
73
+ else:
74
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
75
+
76
+ def open(self) -> None:
77
+ self._missing_assets.clear()
78
+ self._small = load_small_font(self._assets_root, self._missing_assets)
79
+ path = self._assets_root / "crimson" / "game" / "projs.png"
80
+ if not path.is_file():
81
+ self._missing_assets.append("game/projs.png")
82
+ raise FileNotFoundError(f"Missing asset: {path}")
83
+ self._texture = rl.load_texture(str(path))
84
+
85
+ def close(self) -> None:
86
+ if self._texture is not None:
87
+ rl.unload_texture(self._texture)
88
+ self._texture = None
89
+ if self._small is not None:
90
+ rl.unload_texture(self._small.texture)
91
+ self._small = None
92
+
93
+ def update(self, dt: float) -> None:
94
+ del dt
95
+
96
+ def _handle_input(self) -> None:
97
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TWO):
98
+ self._grid = 2
99
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_FOUR):
100
+ self._grid = 4
101
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_G):
102
+ self._grid = 2 if self._grid == 4 else 4
103
+
104
+ def draw(self) -> None:
105
+ rl.clear_background(rl.Color(12, 12, 14, 255))
106
+ if self._missing_assets:
107
+ message = "Missing assets: " + ", ".join(self._missing_assets)
108
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
109
+ return
110
+ if self._texture is None:
111
+ self._draw_ui_text("No projectile texture loaded.", 24, 24, UI_TEXT_COLOR)
112
+ return
113
+
114
+ self._handle_input()
115
+
116
+ margin = 24
117
+ panel_gap = 32
118
+ panel_width = min(360, int(rl.get_screen_width() * 0.35))
119
+ available_width = rl.get_screen_width() - margin * 2 - panel_gap - panel_width
120
+ available_height = rl.get_screen_height() - margin * 2 - 60
121
+ scale = min(
122
+ 2.0,
123
+ available_width / self._texture.width,
124
+ available_height / self._texture.height,
125
+ )
126
+ draw_w = self._texture.width * scale
127
+ draw_h = self._texture.height * scale
128
+ x = margin
129
+ y = margin + 60
130
+
131
+ src = rl.Rectangle(0.0, 0.0, float(self._texture.width), float(self._texture.height))
132
+ dst = rl.Rectangle(float(x), float(y), float(draw_w), float(draw_h))
133
+ rl.draw_texture_pro(self._texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
134
+
135
+ cell_w = draw_w / self._grid
136
+ cell_h = draw_h / self._grid
137
+ for i in range(1, self._grid):
138
+ rl.draw_line(
139
+ int(x + i * cell_w),
140
+ int(y),
141
+ int(x + i * cell_w),
142
+ int(y + draw_h),
143
+ rl.Color(60, 60, 70, 255),
144
+ )
145
+ rl.draw_line(
146
+ int(x),
147
+ int(y + i * cell_h),
148
+ int(x + draw_w),
149
+ int(y + i * cell_h),
150
+ rl.Color(60, 60, 70, 255),
151
+ )
152
+
153
+ known_frames = KNOWN_BY_GRID.get(self._grid, {})
154
+ for frame_index in known_frames:
155
+ row = frame_index // self._grid
156
+ col = frame_index % self._grid
157
+ hl = rl.Rectangle(
158
+ float(x + col * cell_w),
159
+ float(y + row * cell_h),
160
+ float(cell_w),
161
+ float(cell_h),
162
+ )
163
+ rl.draw_rectangle_lines_ex(hl, 2, UI_KNOWN_COLOR)
164
+
165
+ hovered_index = None
166
+ mouse = rl.get_mouse_position()
167
+ if x <= mouse.x <= x + draw_w and y <= mouse.y <= y + draw_h:
168
+ col = int((mouse.x - x) // cell_w)
169
+ row = int((mouse.y - y) // cell_h)
170
+ if 0 <= col < self._grid and 0 <= row < self._grid:
171
+ hovered_index = row * self._grid + col
172
+ hl = rl.Rectangle(
173
+ float(x + col * cell_w),
174
+ float(y + row * cell_h),
175
+ float(cell_w),
176
+ float(cell_h),
177
+ )
178
+ rl.draw_rectangle_lines_ex(hl, 3, UI_HOVER_COLOR)
179
+
180
+ info_x = x + draw_w + panel_gap
181
+ info_y = margin
182
+ self._draw_ui_text(
183
+ f"projs.png (grid {self._grid}x{self._grid})",
184
+ info_x,
185
+ info_y,
186
+ UI_TEXT_COLOR,
187
+ )
188
+ info_y += self._ui_line_height() + 6
189
+ self._draw_ui_text("2/4: grid G: toggle", info_x, info_y, UI_HINT_COLOR)
190
+ info_y += self._ui_line_height() + 12
191
+
192
+ if hovered_index is not None:
193
+ self._draw_ui_text(f"frame {hovered_index:02d}", info_x, info_y, UI_TEXT_COLOR)
194
+ info_y += self._ui_line_height() + 6
195
+ entries = known_frames.get(hovered_index, [])
196
+ if entries:
197
+ for entry in entries:
198
+ self._draw_ui_text(
199
+ f"0x{entry.type_id:02x} {entry.label}",
200
+ info_x,
201
+ info_y,
202
+ UI_TEXT_COLOR,
203
+ )
204
+ info_y += self._ui_line_height() + 4
205
+ else:
206
+ self._draw_ui_text("no known mapping", info_x, info_y, UI_HINT_COLOR)
207
+ info_y += self._ui_line_height() + 4
208
+ info_y += 8
209
+
210
+ self._draw_ui_text("Known frames", info_x, info_y, UI_TEXT_COLOR)
211
+ info_y += self._ui_line_height() + 6
212
+ for frame_index in sorted(known_frames.keys()):
213
+ entries = known_frames[frame_index]
214
+ labels = ", ".join(f"0x{entry.type_id:02x} {entry.label}" for entry in entries)
215
+ self._draw_ui_text(f"{frame_index:02d}: {labels}", info_x, info_y, UI_HINT_COLOR)
216
+ info_y += self._ui_line_height() + 4
217
+
218
+
219
+ @register_view("projectiles", "Projectile atlas preview")
220
+ def build_projectile_view(ctx: ViewContext) -> View:
221
+ return ProjectileView(ctx)