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