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,609 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+
6
+ import pyray as rl
7
+
8
+ from .registry import register_view
9
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
10
+ from grim.view import View, ViewContext
11
+
12
+ from ..bonuses import BonusId
13
+ from ..effects_atlas import effect_src_rect
14
+ from ..gameplay import GameplayState, PlayerState, bonus_apply
15
+ from ..projectiles import ProjectileTypeId
16
+ from ..weapons import (
17
+ WEAPON_BY_ID,
18
+ WEAPON_TABLE,
19
+ weapon_entry_for_projectile_type_id,
20
+ )
21
+
22
+ WORLD_SIZE = 1024.0
23
+
24
+ UI_TEXT_SCALE = 1.0
25
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
26
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
27
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
28
+ UI_ACCENT_COLOR = rl.Color(240, 200, 80, 255)
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class DummyCreature:
33
+ x: float
34
+ y: float
35
+ hp: float
36
+ size: float = 42.0
37
+ collision_flag: int = 0
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class BeamFx:
42
+ x0: float
43
+ y0: float
44
+ x1: float
45
+ y1: float
46
+ life: float
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class EffectFx:
51
+ effect_id: int
52
+ x: float
53
+ y: float
54
+ life: float
55
+ rotation: float
56
+ scale: float
57
+
58
+
59
+ _KNOWN_PROJ_FRAMES: dict[int, tuple[int, int]] = {
60
+ # Based on docs/atlas.md (projectile `type_id` values index the weapon table).
61
+ ProjectileTypeId.PULSE_GUN: (2, 0),
62
+ ProjectileTypeId.SPLITTER_GUN: (4, 3),
63
+ ProjectileTypeId.BLADE_GUN: (4, 6),
64
+ ProjectileTypeId.ION_MINIGUN: (4, 2),
65
+ ProjectileTypeId.ION_CANNON: (4, 2),
66
+ ProjectileTypeId.SHRINKIFIER: (4, 2),
67
+ ProjectileTypeId.FIRE_BULLETS: (4, 2),
68
+ ProjectileTypeId.ION_RIFLE: (4, 2), # Shock Chain projectile
69
+ }
70
+
71
+ _BEAM_TYPES = frozenset(
72
+ {
73
+ ProjectileTypeId.ION_RIFLE,
74
+ ProjectileTypeId.ION_MINIGUN,
75
+ ProjectileTypeId.ION_CANNON,
76
+ ProjectileTypeId.SHRINKIFIER,
77
+ ProjectileTypeId.FIRE_BULLETS,
78
+ ProjectileTypeId.BLADE_GUN,
79
+ ProjectileTypeId.SPLITTER_GUN,
80
+ }
81
+ )
82
+
83
+
84
+ def _clamp(value: float, lo: float, hi: float) -> float:
85
+ if value < lo:
86
+ return lo
87
+ if value > hi:
88
+ return hi
89
+ return value
90
+
91
+
92
+ def _lerp(a: float, b: float, t: float) -> float:
93
+ return a + (b - a) * t
94
+
95
+
96
+ def _angle_to_target(x0: float, y0: float, x1: float, y1: float) -> float:
97
+ return math.atan2(y1 - y0, x1 - x0) + math.pi / 2.0
98
+
99
+
100
+ class ProjectileFxView:
101
+ def __init__(self, ctx: ViewContext) -> None:
102
+ self._assets_root = ctx.assets_dir
103
+ self._missing_assets: list[str] = []
104
+
105
+ self._small: SmallFontData | None = None
106
+ self._projs: rl.Texture | None = None
107
+ self._particles: rl.Texture | None = None
108
+
109
+ self.close_requested = False
110
+ self._paused = False
111
+ self._show_help = True
112
+ self._show_debug = True
113
+
114
+ self._state = GameplayState()
115
+ self._player = PlayerState(index=0, pos_x=WORLD_SIZE * 0.5, pos_y=WORLD_SIZE * 0.5)
116
+ self._creatures: list[DummyCreature] = []
117
+
118
+ self._camera_x = -1.0
119
+ self._camera_y = -1.0
120
+
121
+ max_type_id = max((int(entry.weapon_id) for entry in WEAPON_TABLE), default=0)
122
+ self._type_ids = list(range(int(max_type_id) + 1))
123
+ self._type_index = 0
124
+
125
+ self._damage_scale_by_type = {}
126
+ for entry in WEAPON_TABLE:
127
+ if entry.weapon_id <= 0:
128
+ continue
129
+ self._damage_scale_by_type[int(entry.weapon_id)] = float(entry.damage_scale or 1.0)
130
+
131
+ self._origin_x = WORLD_SIZE * 0.5
132
+ self._origin_y = WORLD_SIZE * 0.5
133
+
134
+ self._beams: list[BeamFx] = []
135
+ self._effects: list[EffectFx] = []
136
+
137
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
138
+ if self._small is not None:
139
+ return int(self._small.cell_size * scale)
140
+ return int(20 * scale)
141
+
142
+ def _draw_ui_text(
143
+ self,
144
+ text: str,
145
+ x: float,
146
+ y: float,
147
+ color: rl.Color,
148
+ scale: float = UI_TEXT_SCALE,
149
+ ) -> None:
150
+ if self._small is not None:
151
+ draw_small_text(self._small, text, x, y, scale, color)
152
+ else:
153
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
154
+
155
+ def _camera_world_to_screen(self, x: float, y: float) -> tuple[float, float]:
156
+ return self._camera_x + x, self._camera_y + y
157
+
158
+ def _camera_screen_to_world(self, x: float, y: float) -> tuple[float, float]:
159
+ return x - self._camera_x, y - self._camera_y
160
+
161
+ def _update_camera(self, dt: float) -> None:
162
+ screen_w = float(rl.get_screen_width())
163
+ screen_h = float(rl.get_screen_height())
164
+ if screen_w > WORLD_SIZE:
165
+ screen_w = WORLD_SIZE
166
+ if screen_h > WORLD_SIZE:
167
+ screen_h = WORLD_SIZE
168
+
169
+ desired_x = (screen_w * 0.5) - self._origin_x
170
+ desired_y = (screen_h * 0.5) - self._origin_y
171
+
172
+ min_x = screen_w - WORLD_SIZE
173
+ min_y = screen_h - WORLD_SIZE
174
+ if desired_x > -1.0:
175
+ desired_x = -1.0
176
+ if desired_x < min_x:
177
+ desired_x = min_x
178
+ if desired_y > -1.0:
179
+ desired_y = -1.0
180
+ if desired_y < min_y:
181
+ desired_y = min_y
182
+
183
+ t = _clamp(dt * 6.0, 0.0, 1.0)
184
+ self._camera_x = _lerp(self._camera_x, desired_x, t)
185
+ self._camera_y = _lerp(self._camera_y, desired_y, t)
186
+
187
+ def _reset_scene(self) -> None:
188
+ self._state.projectiles.reset()
189
+ self._state.secondary_projectiles.reset()
190
+ self._state.shock_chain_links_left = 0
191
+ self._state.shock_chain_projectile_id = -1
192
+ self._beams.clear()
193
+ self._effects.clear()
194
+ self._creatures = [
195
+ DummyCreature(x=self._origin_x + 180.0, y=self._origin_y, hp=140.0, size=38.0),
196
+ DummyCreature(x=self._origin_x + 260.0, y=self._origin_y + 40.0, hp=140.0, size=42.0),
197
+ DummyCreature(x=self._origin_x - 220.0, y=self._origin_y + 140.0, hp=140.0, size=52.0),
198
+ DummyCreature(x=self._origin_x - 300.0, y=self._origin_y - 120.0, hp=140.0, size=58.0),
199
+ ]
200
+
201
+ def open(self) -> None:
202
+ self._missing_assets.clear()
203
+ try:
204
+ self._small = load_small_font(self._assets_root, self._missing_assets)
205
+ except Exception:
206
+ self._small = None
207
+
208
+ projs_path = self._assets_root / "crimson" / "game" / "projs.png"
209
+ if not projs_path.is_file():
210
+ self._missing_assets.append("game/projs.png")
211
+ raise FileNotFoundError(f"Missing asset: {projs_path}")
212
+ self._projs = rl.load_texture(str(projs_path))
213
+
214
+ particles_path = self._assets_root / "crimson" / "game" / "particles.png"
215
+ if particles_path.is_file():
216
+ self._particles = rl.load_texture(str(particles_path))
217
+ else:
218
+ self._particles = None
219
+ self._missing_assets.append("game/particles.png")
220
+
221
+ self.close_requested = False
222
+ self._paused = False
223
+ self._state.rng.srand(0xBEEF)
224
+ self._reset_scene()
225
+
226
+ self._camera_x = -1.0
227
+ self._camera_y = -1.0
228
+
229
+ def close(self) -> None:
230
+ if self._projs is not None:
231
+ rl.unload_texture(self._projs)
232
+ self._projs = None
233
+ if self._particles is not None:
234
+ rl.unload_texture(self._particles)
235
+ self._particles = None
236
+ if self._small is not None:
237
+ rl.unload_texture(self._small.texture)
238
+ self._small = None
239
+
240
+ def _selected_type_id(self) -> int:
241
+ if not self._type_ids:
242
+ return 0
243
+ return int(self._type_ids[self._type_index % len(self._type_ids)])
244
+
245
+ def _projectile_meta_for(self, type_id: int) -> float:
246
+ entry = weapon_entry_for_projectile_type_id(int(type_id))
247
+ meta = entry.projectile_meta if entry is not None else None
248
+ return float(meta if meta is not None else 45.0)
249
+
250
+ def _spawn_effect(self, *, effect_id: int, x: float, y: float, scale: float, duration: float) -> None:
251
+ if self._particles is None:
252
+ return
253
+ self._effects.append(
254
+ EffectFx(
255
+ effect_id=int(effect_id),
256
+ x=float(x),
257
+ y=float(y),
258
+ life=float(duration),
259
+ rotation=float(int(self._state.rng.rand()) % 0x274) * 0.01,
260
+ scale=float(scale),
261
+ )
262
+ )
263
+
264
+ def _spawn_projectile(self, *, type_id: int, angle: float, owner_id: int = -100) -> None:
265
+ meta = self._projectile_meta_for(type_id)
266
+ self._spawn_effect(effect_id=0x12, x=self._origin_x, y=self._origin_y, scale=0.55, duration=0.18)
267
+ self._state.projectiles.spawn(
268
+ pos_x=self._origin_x,
269
+ pos_y=self._origin_y,
270
+ angle=float(angle),
271
+ type_id=int(type_id),
272
+ owner_id=int(owner_id),
273
+ base_damage=meta,
274
+ )
275
+
276
+ def _spawn_fire_bullets_volley(self, *, angle: float) -> None:
277
+ base = weapon_entry_for_projectile_type_id(self._selected_type_id())
278
+ pellet_count = int(getattr(base, "pellet_count", 1) or 1)
279
+ pellet_count = max(1, pellet_count)
280
+ meta = self._projectile_meta_for(ProjectileTypeId.FIRE_BULLETS)
281
+ self._spawn_effect(effect_id=0x12, x=self._origin_x, y=self._origin_y, scale=0.6, duration=0.2)
282
+ for _ in range(pellet_count):
283
+ jitter = (float(self._state.rng.rand() % 200) - 100.0) * 0.0015
284
+ self._state.projectiles.spawn(
285
+ pos_x=self._origin_x,
286
+ pos_y=self._origin_y,
287
+ angle=float(angle + jitter),
288
+ type_id=ProjectileTypeId.FIRE_BULLETS,
289
+ owner_id=-1,
290
+ base_damage=meta,
291
+ )
292
+
293
+ def _handle_input(self) -> None:
294
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
295
+ self._paused = not self._paused
296
+
297
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
298
+ self.close_requested = True
299
+
300
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_H):
301
+ self._show_help = not self._show_help
302
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F3):
303
+ self._show_debug = not self._show_debug
304
+
305
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
306
+ self._reset_scene()
307
+
308
+ wheel = int(rl.get_mouse_wheel_move())
309
+ if wheel:
310
+ self._type_index = (self._type_index - wheel) % max(1, len(self._type_ids))
311
+
312
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
313
+ self._type_index = (self._type_index - 1) % max(1, len(self._type_ids))
314
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
315
+ self._type_index = (self._type_index + 1) % max(1, len(self._type_ids))
316
+
317
+ mouse = rl.get_mouse_position()
318
+ aim_x, aim_y = self._camera_screen_to_world(float(mouse.x), float(mouse.y))
319
+ angle = _angle_to_target(self._origin_x, self._origin_y, aim_x, aim_y)
320
+
321
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_RIGHT):
322
+ self._origin_x = _clamp(aim_x, 0.0, WORLD_SIZE)
323
+ self._origin_y = _clamp(aim_y, 0.0, WORLD_SIZE)
324
+
325
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
326
+ self._spawn_projectile(type_id=self._selected_type_id(), angle=angle, owner_id=-1)
327
+
328
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
329
+ count = 12
330
+ step = math.tau / float(count)
331
+ for idx in range(count):
332
+ self._spawn_projectile(
333
+ type_id=self._selected_type_id(),
334
+ angle=float(idx) * step,
335
+ owner_id=-1,
336
+ )
337
+
338
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F):
339
+ self._spawn_fire_bullets_volley(angle=angle)
340
+
341
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_S):
342
+ self._player.pos_x = self._origin_x
343
+ self._player.pos_y = self._origin_y
344
+ bonus_apply(self._state, self._player, BonusId.SHOCK_CHAIN, origin=self._player, creatures=self._creatures)
345
+
346
+ def update(self, dt: float) -> None:
347
+ self._handle_input()
348
+ if self._paused:
349
+ dt = 0.0
350
+
351
+ if dt <= 0.0:
352
+ return
353
+
354
+ self._update_camera(dt)
355
+
356
+ self._beams = [beam for beam in self._beams if beam.life > 0.0]
357
+ for beam in self._beams:
358
+ beam.life -= dt
359
+
360
+ self._effects = [fx for fx in self._effects if fx.life > 0.0]
361
+ for fx in self._effects:
362
+ fx.life -= dt
363
+
364
+ hits = self._state.projectiles.update(
365
+ dt,
366
+ self._creatures,
367
+ world_size=WORLD_SIZE,
368
+ damage_scale_by_type=self._damage_scale_by_type,
369
+ detail_preset=5,
370
+ rng=self._state.rng.rand,
371
+ runtime_state=self._state,
372
+ )
373
+ for type_id, origin_x, origin_y, hit_x, hit_y, *_ in hits:
374
+ if type_id in _BEAM_TYPES:
375
+ self._beams.append(BeamFx(x0=origin_x, y0=origin_y, x1=hit_x, y1=hit_y, life=0.08))
376
+ self._spawn_effect(effect_id=0x01, x=hit_x, y=hit_y, scale=0.9, duration=0.25)
377
+ else:
378
+ effect_id = 0x11 if type_id in (ProjectileTypeId.GAUSS_GUN, ProjectileTypeId.FIRE_BULLETS) else 0x00
379
+ self._spawn_effect(effect_id=effect_id, x=hit_x, y=hit_y, scale=1.2, duration=0.35)
380
+
381
+ self._creatures = [c for c in self._creatures if c.hp > 0.0]
382
+
383
+ def _draw_atlas_sprite(
384
+ self,
385
+ texture: rl.Texture,
386
+ *,
387
+ grid: int,
388
+ frame: int,
389
+ x: float,
390
+ y: float,
391
+ scale: float,
392
+ rotation_rad: float = 0.0,
393
+ tint: rl.Color = rl.WHITE,
394
+ ) -> None:
395
+ grid = max(1, int(grid))
396
+ frame = max(0, int(frame))
397
+
398
+ cell_w = float(texture.width) / float(grid)
399
+ cell_h = float(texture.height) / float(grid)
400
+ col = frame % grid
401
+ row = frame // grid
402
+ src = rl.Rectangle(cell_w * float(col), cell_h * float(row), cell_w, cell_h)
403
+
404
+ w = cell_w * float(scale)
405
+ h = cell_h * float(scale)
406
+ dst = rl.Rectangle(float(x), float(y), w, h)
407
+ origin = rl.Vector2(w * 0.5, h * 0.5)
408
+ rl.draw_texture_pro(texture, src, dst, origin, float(rotation_rad * 57.29577951308232), tint)
409
+
410
+ def _draw_projectile(self, proj: object) -> None:
411
+ texture = self._projs
412
+ if texture is None:
413
+ return
414
+
415
+ type_id = int(getattr(proj, "type_id", 0))
416
+ mapping = _KNOWN_PROJ_FRAMES.get(type_id)
417
+ sx, sy = self._camera_world_to_screen(float(getattr(proj, "pos_x", 0.0)), float(getattr(proj, "pos_y", 0.0)))
418
+
419
+ if mapping is None:
420
+ rl.draw_circle(int(sx), int(sy), 3.0, rl.Color(240, 220, 160, 255))
421
+ if self._show_debug:
422
+ rl.draw_text(f"{type_id:02x}", int(sx) + 6, int(sy) - 8, 10, UI_HINT_COLOR)
423
+ return
424
+
425
+ grid, frame = mapping
426
+ life = float(getattr(proj, "life_timer", 0.0))
427
+ angle = float(getattr(proj, "angle", 0.0))
428
+
429
+ color = rl.Color(240, 220, 160, 255)
430
+ if type_id in (ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_MINIGUN, ProjectileTypeId.ION_CANNON):
431
+ color = rl.Color(120, 200, 255, 255)
432
+ elif type_id == ProjectileTypeId.FIRE_BULLETS:
433
+ color = rl.Color(255, 170, 90, 255)
434
+ elif type_id == ProjectileTypeId.SHRINKIFIER:
435
+ color = rl.Color(160, 255, 170, 255)
436
+ elif type_id == ProjectileTypeId.BLADE_GUN:
437
+ color = rl.Color(240, 120, 255, 255)
438
+
439
+ # Beam-style projectiles get a trail from origin to current position in the flight phase.
440
+ if type_id in _BEAM_TYPES and life >= 0.4:
441
+ ox = float(getattr(proj, "origin_x", 0.0))
442
+ oy = float(getattr(proj, "origin_y", 0.0))
443
+ dx = float(getattr(proj, "pos_x", 0.0)) - ox
444
+ dy = float(getattr(proj, "pos_y", 0.0)) - oy
445
+ dist = math.hypot(dx, dy)
446
+ if dist > 1e-6:
447
+ step = 14.0
448
+ seg_count = max(1, int(dist // step) + 1)
449
+ dir_x = dx / dist
450
+ dir_y = dy / dist
451
+ for idx in range(seg_count):
452
+ t = float(idx) / float(max(1, seg_count - 1))
453
+ px = ox + dir_x * dist * t
454
+ py = oy + dir_y * dist * t
455
+ alpha = int(220 * (1.0 - t * 0.75))
456
+ tint = rl.Color(color.r, color.g, color.b, alpha)
457
+ psx, psy = self._camera_world_to_screen(px, py)
458
+ self._draw_atlas_sprite(
459
+ texture,
460
+ grid=grid,
461
+ frame=frame,
462
+ x=psx,
463
+ y=psy,
464
+ scale=0.55,
465
+ rotation_rad=angle,
466
+ tint=tint,
467
+ )
468
+ return
469
+
470
+ alpha = int(_clamp(life / 0.4, 0.0, 1.0) * 255)
471
+ tint = rl.Color(color.r, color.g, color.b, alpha)
472
+ self._draw_atlas_sprite(texture, grid=grid, frame=frame, x=sx, y=sy, scale=0.6, rotation_rad=angle, tint=tint)
473
+
474
+ def draw(self) -> None:
475
+ rl.clear_background(rl.Color(10, 10, 12, 255))
476
+ if self._missing_assets and self._projs is None:
477
+ message = "Missing assets: " + ", ".join(self._missing_assets)
478
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
479
+ return
480
+
481
+ # World bounds.
482
+ x0, y0 = self._camera_world_to_screen(0.0, 0.0)
483
+ x1, y1 = self._camera_world_to_screen(WORLD_SIZE, WORLD_SIZE)
484
+ rl.draw_rectangle_lines(int(x0), int(y0), int(x1 - x0), int(y1 - y0), rl.Color(40, 40, 55, 255))
485
+
486
+ # Spawn origin marker.
487
+ sx, sy = self._camera_world_to_screen(self._origin_x, self._origin_y)
488
+ rl.draw_circle(int(sx), int(sy), 5.0, rl.Color(240, 200, 80, 255))
489
+ rl.draw_circle_lines(int(sx), int(sy), 9.0, rl.Color(70, 70, 90, 255))
490
+
491
+ # Creatures.
492
+ for creature in self._creatures:
493
+ cx, cy = self._camera_world_to_screen(creature.x, creature.y)
494
+ color = rl.Color(220, 90, 90, 255) if creature.collision_flag == 0 else rl.Color(240, 180, 90, 255)
495
+ rl.draw_circle(int(cx), int(cy), float(creature.size * 0.5), color)
496
+ rl.draw_circle_lines(int(cx), int(cy), float(creature.size * 0.5), rl.Color(40, 40, 55, 255))
497
+
498
+ # AOE rings for ion linger types.
499
+ for proj in self._state.projectiles.iter_active():
500
+ life = float(proj.life_timer)
501
+ if life >= 0.4:
502
+ continue
503
+ if proj.type_id == ProjectileTypeId.ION_RIFLE:
504
+ radius = 88.0
505
+ color = rl.Color(120, 200, 255, 50)
506
+ elif proj.type_id == ProjectileTypeId.ION_MINIGUN:
507
+ radius = 60.0
508
+ color = rl.Color(120, 200, 255, 40)
509
+ elif proj.type_id == ProjectileTypeId.ION_CANNON:
510
+ radius = 128.0
511
+ color = rl.Color(120, 200, 255, 40)
512
+ else:
513
+ continue
514
+ px, py = self._camera_world_to_screen(proj.pos_x, proj.pos_y)
515
+ rl.draw_circle(int(px), int(py), radius, color)
516
+ rl.draw_circle_lines(int(px), int(py), radius, rl.Color(120, 200, 255, 120))
517
+
518
+ # Beam flashes from hit events.
519
+ for beam in self._beams:
520
+ t = _clamp(beam.life / 0.08, 0.0, 1.0)
521
+ alpha = int(200 * t)
522
+ x0s, y0s = self._camera_world_to_screen(beam.x0, beam.y0)
523
+ x1s, y1s = self._camera_world_to_screen(beam.x1, beam.y1)
524
+ rl.draw_line_ex(
525
+ rl.Vector2(float(x0s), float(y0s)),
526
+ rl.Vector2(float(x1s), float(y1s)),
527
+ 2.0,
528
+ rl.Color(150, 220, 255, alpha),
529
+ )
530
+
531
+ # Particle sprite effects.
532
+ if self._particles is not None:
533
+ for fx in self._effects:
534
+ src = effect_src_rect(
535
+ fx.effect_id,
536
+ texture_width=float(self._particles.width),
537
+ texture_height=float(self._particles.height),
538
+ )
539
+ if src is None:
540
+ continue
541
+ life = max(0.0, fx.life)
542
+ alpha = int(_clamp(life / 0.35, 0.0, 1.0) * 220)
543
+ tint = rl.Color(255, 255, 255, alpha)
544
+ sx, sy = self._camera_world_to_screen(fx.x, fx.y)
545
+ dst_scale = fx.scale * (1.0 + (0.7 - _clamp(life, 0.0, 0.7)) * 0.6)
546
+ dst = rl.Rectangle(float(sx), float(sy), src[2] * dst_scale, src[3] * dst_scale)
547
+ origin = rl.Vector2(dst.width * 0.5, dst.height * 0.5)
548
+ rl.draw_texture_pro(
549
+ self._particles,
550
+ rl.Rectangle(float(src[0]), float(src[1]), float(src[2]), float(src[3])),
551
+ dst,
552
+ origin,
553
+ float(fx.rotation * 57.29577951308232),
554
+ tint,
555
+ )
556
+
557
+ # Projectiles.
558
+ for proj in self._state.projectiles.iter_active():
559
+ self._draw_projectile(proj)
560
+
561
+ # UI.
562
+ margin = 18
563
+ x = float(margin)
564
+ y = float(margin)
565
+ line = self._ui_line_height()
566
+
567
+ type_id = self._selected_type_id()
568
+ weapon = WEAPON_BY_ID.get(int(type_id))
569
+ label = weapon.name if weapon is not None and weapon.name else f"type_{type_id}"
570
+ self._draw_ui_text(f"{label} (type_id {type_id} / 0x{type_id:02x})", x, y, UI_TEXT_COLOR)
571
+ y += line + 4
572
+
573
+ if self._show_debug:
574
+ meta = self._projectile_meta_for(type_id)
575
+ dmg = self._damage_scale_by_type.get(type_id, 1.0)
576
+ pellets = int(weapon.pellet_count) if weapon is not None and weapon.pellet_count is not None else 1
577
+ self._draw_ui_text(f"meta {meta:.1f} dmg_scale {dmg:.2f} pellet_count {pellets}", x, y, UI_HINT_COLOR)
578
+ y += line + 4
579
+ self._draw_ui_text(
580
+ f"shock_chain links {self._state.shock_chain_links_left} proj {self._state.shock_chain_projectile_id}",
581
+ x,
582
+ y,
583
+ UI_HINT_COLOR,
584
+ )
585
+ y += line + 8
586
+
587
+ if self._show_help:
588
+ self._draw_ui_text("controls:", x, y, UI_ACCENT_COLOR)
589
+ y += line + 2
590
+ self._draw_ui_text("- left/right: select projectile type", x, y, UI_HINT_COLOR)
591
+ y += line + 2
592
+ self._draw_ui_text("- mouse wheel: select type", x, y, UI_HINT_COLOR)
593
+ y += line + 2
594
+ self._draw_ui_text("- LMB: spawn projectile toward mouse", x, y, UI_HINT_COLOR)
595
+ y += line + 2
596
+ self._draw_ui_text("- RMB: move spawn origin", x, y, UI_HINT_COLOR)
597
+ y += line + 2
598
+ self._draw_ui_text("- space: spawn ring", x, y, UI_HINT_COLOR)
599
+ y += line + 2
600
+ self._draw_ui_text("- F: fire-bullets volley (uses pellet_count)", x, y, UI_HINT_COLOR)
601
+ y += line + 2
602
+ self._draw_ui_text("- S: apply Shock Chain bonus", x, y, UI_HINT_COLOR)
603
+ y += line + 2
604
+ self._draw_ui_text("- R: reset Tab: pause H: hide help F3: toggle debug", x, y, UI_HINT_COLOR, scale=0.9)
605
+
606
+
607
+ @register_view("projectile_fx", "Projectile FX lab")
608
+ def build_projectile_fx_view(ctx: ViewContext) -> View:
609
+ return ProjectileFxView(ctx)