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
grim/terrain_render.py ADDED
@@ -0,0 +1,840 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from dataclasses import dataclass, field
5
+ import math
6
+ from typing import Iterator
7
+ from typing import Iterable, Sequence
8
+
9
+ import pyray as rl
10
+
11
+ from .rand import CrtRand
12
+
13
+ TERRAIN_TEXTURE_SIZE = 1024
14
+ TERRAIN_PATCH_SIZE = 128.0
15
+ TERRAIN_PATCH_OVERSCAN = 64.0
16
+ TERRAIN_CLEAR_COLOR = rl.Color(63, 56, 25, 255)
17
+ TERRAIN_BASE_TINT = rl.Color(178, 178, 178, 230)
18
+ TERRAIN_OVERLAY_TINT = rl.Color(178, 178, 178, 230)
19
+ TERRAIN_DETAIL_TINT = rl.Color(178, 178, 178, 153)
20
+ TERRAIN_DENSITY_BASE = 800
21
+ TERRAIN_DENSITY_OVERLAY = 0x23
22
+ TERRAIN_DENSITY_DETAIL = 0x0F
23
+ TERRAIN_DENSITY_SHIFT = 19
24
+ TERRAIN_ROTATION_MAX = 0x13A
25
+
26
+
27
+ _ALPHA_TEST_REF_U8 = 4
28
+ _ALPHA_TEST_REF_F32 = float(_ALPHA_TEST_REF_U8) / 255.0
29
+
30
+ # Grim2D enables alpha test globally with:
31
+ # ALPHATESTENABLE=1, ALPHAFUNC=GREATER, ALPHAREF=4
32
+ # See: analysis/ghidra/raw/grim.dll_decompiled.c (FUN_10004520).
33
+ #
34
+ # raylib does not expose fixed-function alpha test, so we emulate it with a tiny
35
+ # discard shader for stamping into the terrain render target.
36
+ _ALPHA_TEST_SHADER: rl.Shader | None = None
37
+ _ALPHA_TEST_SHADER_TRIED = False
38
+
39
+ _ALPHA_TEST_VS_330 = r"""
40
+ #version 330
41
+
42
+ in vec3 vertexPosition;
43
+ in vec2 vertexTexCoord;
44
+ in vec4 vertexColor;
45
+
46
+ out vec2 fragTexCoord;
47
+ out vec4 fragColor;
48
+
49
+ uniform mat4 mvp;
50
+
51
+ void main() {
52
+ fragTexCoord = vertexTexCoord;
53
+ fragColor = vertexColor;
54
+ gl_Position = mvp * vec4(vertexPosition, 1.0);
55
+ }
56
+ """
57
+
58
+ _ALPHA_TEST_FS_330 = rf"""
59
+ #version 330
60
+
61
+ in vec2 fragTexCoord;
62
+ in vec4 fragColor;
63
+
64
+ uniform sampler2D texture0;
65
+ uniform vec4 colDiffuse;
66
+
67
+ out vec4 finalColor;
68
+
69
+ void main() {{
70
+ vec4 texel = texture(texture0, fragTexCoord) * fragColor * colDiffuse;
71
+ if (texel.a <= {_ALPHA_TEST_REF_F32:.10f}) discard;
72
+ finalColor = texel;
73
+ }}
74
+ """
75
+
76
+
77
+ def _get_alpha_test_shader() -> rl.Shader | None:
78
+ global _ALPHA_TEST_SHADER, _ALPHA_TEST_SHADER_TRIED
79
+ if _ALPHA_TEST_SHADER_TRIED:
80
+ if _ALPHA_TEST_SHADER is not None and int(getattr(_ALPHA_TEST_SHADER, "id", 0)) > 0:
81
+ return _ALPHA_TEST_SHADER
82
+ return None
83
+
84
+ _ALPHA_TEST_SHADER_TRIED = True
85
+ try:
86
+ shader = rl.load_shader_from_memory(_ALPHA_TEST_VS_330, _ALPHA_TEST_FS_330)
87
+ except Exception:
88
+ _ALPHA_TEST_SHADER = None
89
+ return None
90
+
91
+ if int(getattr(shader, "id", 0)) <= 0:
92
+ _ALPHA_TEST_SHADER = None
93
+ return None
94
+
95
+ _ALPHA_TEST_SHADER = shader
96
+ return _ALPHA_TEST_SHADER
97
+
98
+
99
+ @contextmanager
100
+ def _blend_custom(src_factor: int, dst_factor: int, blend_equation: int) -> Iterator[None]:
101
+ # NOTE: raylib/rlgl tracks custom blend factors as state; some backends only
102
+ # apply them when switching the blend mode. Set factors both before and
103
+ # after BeginBlendMode() to ensure the current draw uses the intended values.
104
+ rl.rl_set_blend_factors(src_factor, dst_factor, blend_equation)
105
+ rl.begin_blend_mode(rl.BLEND_CUSTOM)
106
+ rl.rl_set_blend_factors(src_factor, dst_factor, blend_equation)
107
+ try:
108
+ yield
109
+ finally:
110
+ rl.end_blend_mode()
111
+
112
+
113
+ @contextmanager
114
+ def _blend_custom_separate(
115
+ src_rgb: int,
116
+ dst_rgb: int,
117
+ src_alpha: int,
118
+ dst_alpha: int,
119
+ eq_rgb: int,
120
+ eq_alpha: int,
121
+ ) -> Iterator[None]:
122
+ # NOTE: raylib/rlgl tracks custom blend factors as state; some backends only
123
+ # apply them when switching the blend mode. Set factors both before and
124
+ # after BeginBlendMode() to ensure the current draw uses the intended values.
125
+ rl.rl_set_blend_factors_separate(src_rgb, dst_rgb, src_alpha, dst_alpha, eq_rgb, eq_alpha)
126
+ rl.begin_blend_mode(rl.BLEND_CUSTOM_SEPARATE)
127
+ rl.rl_set_blend_factors_separate(src_rgb, dst_rgb, src_alpha, dst_alpha, eq_rgb, eq_alpha)
128
+ try:
129
+ yield
130
+ finally:
131
+ rl.end_blend_mode()
132
+
133
+
134
+ @contextmanager
135
+ def _maybe_alpha_test(enabled: bool) -> Iterator[None]:
136
+ if not enabled:
137
+ yield
138
+ return
139
+ shader = _get_alpha_test_shader()
140
+ if shader is None:
141
+ yield
142
+ return
143
+ rl.begin_shader_mode(shader)
144
+ try:
145
+ yield
146
+ finally:
147
+ rl.end_shader_mode()
148
+
149
+
150
+ @dataclass(slots=True)
151
+ class GroundDecal:
152
+ texture: rl.Texture
153
+ src: rl.Rectangle
154
+ x: float
155
+ y: float
156
+ width: float
157
+ height: float
158
+ rotation_rad: float = 0.0
159
+ tint: rl.Color = rl.WHITE
160
+ centered: bool = True
161
+
162
+
163
+ @dataclass(slots=True)
164
+ class GroundCorpseDecal:
165
+ bodyset_frame: int
166
+ top_left_x: float
167
+ top_left_y: float
168
+ size: float
169
+ rotation_rad: float
170
+ tint: rl.Color = rl.WHITE
171
+
172
+
173
+ @dataclass(slots=True)
174
+ class GroundRenderer:
175
+ texture: rl.Texture
176
+ width: int = TERRAIN_TEXTURE_SIZE
177
+ height: int = TERRAIN_TEXTURE_SIZE
178
+ texture_scale: float = 1.0
179
+ alpha_test: bool = True
180
+ debug_log_stamps: bool = False
181
+ texture_failed: bool = False
182
+ screen_width: float | None = None
183
+ screen_height: float | None = None
184
+ overlay: rl.Texture | None = None
185
+ overlay_detail: rl.Texture | None = None
186
+ terrain_filter: float = 1.0
187
+ render_target: rl.RenderTexture | None = None
188
+ _debug_stamp_log: list[dict[str, object]] = field(default_factory=list, init=False, repr=False)
189
+ _render_target_ready: bool = field(default=False, init=False, repr=False)
190
+ _pending_generate: bool = field(default=False, init=False, repr=False)
191
+ _pending_generate_seed: int | None = field(default=None, init=False, repr=False)
192
+ _pending_generate_layers: int = field(default=3, init=False, repr=False)
193
+ _render_target_warmup_passes: int = field(default=0, init=False, repr=False)
194
+ _fallback_seed: int | None = field(default=None, init=False, repr=False)
195
+ _fallback_layers: int = field(default=0, init=False, repr=False)
196
+ _fallback_patches: list[GroundDecal] = field(default_factory=list, init=False, repr=False)
197
+ _fallback_decals: list[GroundDecal] = field(default_factory=list, init=False, repr=False)
198
+ _fallback_corpse_decals: list[GroundCorpseDecal] = field(default_factory=list, init=False, repr=False)
199
+ _fallback_bodyset_texture: rl.Texture | None = field(default=None, init=False, repr=False)
200
+ _fallback_corpse_shadow: bool = field(default=True, init=False, repr=False)
201
+
202
+ def debug_clear_stamp_log(self) -> None:
203
+ self._debug_stamp_log.clear()
204
+
205
+ def debug_stamp_log(self) -> tuple[dict[str, object], ...]:
206
+ return tuple(self._debug_stamp_log)
207
+
208
+ def _debug_stamp(self, kind: str, **payload: object) -> None:
209
+ if not self.debug_log_stamps:
210
+ return
211
+ self._debug_stamp_log.append({"kind": kind, **payload})
212
+ if len(self._debug_stamp_log) > 96:
213
+ del self._debug_stamp_log[:32]
214
+
215
+ def process_pending(self) -> None:
216
+ # Bound the amount of work per tick. Typical warmup sequence:
217
+ # 1) create RT
218
+ # 2) first fill (may be black/uninitialized on some platforms)
219
+ # 3) warmup retry fill
220
+ steps = 0
221
+ while self._pending_generate and steps < 4:
222
+ steps += 1
223
+ if self.render_target is None:
224
+ self.create_render_target()
225
+ continue
226
+
227
+ seed = self._pending_generate_seed
228
+ layers = self._pending_generate_layers
229
+ self._pending_generate = False
230
+ self.generate_partial(seed=seed, layers=layers)
231
+ if self.render_target is None and not self.texture_failed:
232
+ self._pending_generate = True
233
+ continue
234
+
235
+ if self._render_target_warmup_passes > 0:
236
+ self._render_target_warmup_passes -= 1
237
+ # On some platforms/drivers the first draw into a new RT can come out as
238
+ # black/uninitialized (all-zero). Retry once before marking it ready.
239
+ self._render_target_ready = False
240
+ self._pending_generate = True
241
+ continue
242
+
243
+ def create_render_target(self) -> None:
244
+ if self.texture_failed:
245
+ if self.render_target is not None:
246
+ rl.unload_render_texture(self.render_target)
247
+ self.render_target = None
248
+ self._render_target_ready = False
249
+ return
250
+
251
+ scale = self.texture_scale
252
+ if scale < 0.5:
253
+ scale = 0.5
254
+ elif scale > 4.0:
255
+ scale = 4.0
256
+ self.texture_scale = scale
257
+
258
+ render_w, render_h = self._render_target_size_for(scale)
259
+ if self._ensure_render_target(render_w, render_h):
260
+ return
261
+
262
+ old_scale = scale
263
+ self.texture_scale = scale + scale
264
+ render_w, render_h = self._render_target_size_for(self.texture_scale)
265
+ if self._ensure_render_target(render_w, render_h):
266
+ return
267
+
268
+ self.texture_failed = True
269
+ self.texture_scale = old_scale
270
+ if self.render_target is not None:
271
+ rl.unload_render_texture(self.render_target)
272
+ self.render_target = None
273
+ self._render_target_ready = False
274
+
275
+ def generate(self, seed: int | None = None) -> None:
276
+ self.generate_partial(seed=seed, layers=3)
277
+
278
+ def schedule_generate(self, seed: int | None = None, *, layers: int = 3) -> None:
279
+ self._pending_generate_seed = seed
280
+ self._pending_generate_layers = max(0, min(int(layers), 3))
281
+ self._pending_generate = True
282
+
283
+ def generate_partial(self, seed: int | None = None, *, layers: int) -> None:
284
+ layers = max(0, min(int(layers), 3))
285
+ # Always keep a deterministic fallback representation of the terrain.
286
+ # When the render target is unavailable (or not ready yet), we can render
287
+ # patches + baked decals directly to the screen, matching the exe's
288
+ # `terrain_texture_failed` path.
289
+ self._fallback_seed = seed
290
+ self._fallback_layers = layers
291
+ self._fallback_patches.clear()
292
+ self._fallback_decals.clear()
293
+ self._fallback_corpse_decals.clear()
294
+ self._fallback_bodyset_texture = None
295
+ self._fallback_corpse_shadow = True
296
+
297
+ rng_fallback = CrtRand(seed)
298
+ if layers >= 1:
299
+ self._scatter_texture_fallback(self.texture, TERRAIN_BASE_TINT, rng_fallback, TERRAIN_DENSITY_BASE)
300
+ if layers >= 2 and self.overlay is not None:
301
+ self._scatter_texture_fallback(self.overlay, TERRAIN_OVERLAY_TINT, rng_fallback, TERRAIN_DENSITY_OVERLAY)
302
+ if layers >= 3:
303
+ # Original uses base texture for detail pass, not overlay.
304
+ self._scatter_texture_fallback(self.texture, TERRAIN_DETAIL_TINT, rng_fallback, TERRAIN_DENSITY_DETAIL)
305
+
306
+ self.create_render_target()
307
+ if self.render_target is None:
308
+ return
309
+ rng = CrtRand(seed)
310
+ self._set_stamp_filters(point=True)
311
+ rl.begin_texture_mode(self.render_target)
312
+ rl.clear_background(TERRAIN_CLEAR_COLOR)
313
+ # Keep the ground RT alpha at 1.0 like the original exe (which typically uses
314
+ # an XRGB render target). We still alpha-blend RGB, but preserve destination A.
315
+ with _blend_custom_separate(
316
+ rl.RL_SRC_ALPHA,
317
+ rl.RL_ONE_MINUS_SRC_ALPHA,
318
+ rl.RL_ZERO,
319
+ rl.RL_ONE,
320
+ rl.RL_FUNC_ADD,
321
+ rl.RL_FUNC_ADD,
322
+ ):
323
+ if layers >= 1:
324
+ self._scatter_texture(self.texture, TERRAIN_BASE_TINT, rng, TERRAIN_DENSITY_BASE)
325
+ if layers >= 2 and self.overlay is not None:
326
+ self._scatter_texture(self.overlay, TERRAIN_OVERLAY_TINT, rng, TERRAIN_DENSITY_OVERLAY)
327
+ if layers >= 3:
328
+ # Original uses base texture for detail pass, not overlay
329
+ self._scatter_texture(self.texture, TERRAIN_DETAIL_TINT, rng, TERRAIN_DENSITY_DETAIL)
330
+ rl.end_texture_mode()
331
+ self._set_stamp_filters(point=False)
332
+ self._render_target_ready = True
333
+
334
+ def bake_decals(self, decals: Sequence[GroundDecal]) -> bool:
335
+ if not decals:
336
+ return False
337
+
338
+ self.create_render_target()
339
+ if self.render_target is None:
340
+ self._fallback_decals.extend(decals)
341
+ return True
342
+
343
+ if self.debug_log_stamps:
344
+ head = decals[0]
345
+ self._debug_stamp(
346
+ "bake_decals",
347
+ count=len(decals),
348
+ pos0={"x": float(head.x), "y": float(head.y)},
349
+ rot0=float(head.rotation_rad),
350
+ )
351
+
352
+ inv_scale = 1.0 / self._normalized_texture_scale()
353
+ textures = self._unique_textures([decal.texture for decal in decals])
354
+ self._set_texture_filters(textures, point=True)
355
+
356
+ rl.begin_texture_mode(self.render_target)
357
+ with _blend_custom_separate(
358
+ rl.RL_SRC_ALPHA,
359
+ rl.RL_ONE_MINUS_SRC_ALPHA,
360
+ rl.RL_ZERO,
361
+ rl.RL_ONE,
362
+ rl.RL_FUNC_ADD,
363
+ rl.RL_FUNC_ADD,
364
+ ):
365
+ for decal in decals:
366
+ w = decal.width
367
+ h = decal.height
368
+ if decal.centered:
369
+ pivot_x = decal.x
370
+ pivot_y = decal.y
371
+ else:
372
+ pivot_x = decal.x + w * 0.5
373
+ pivot_y = decal.y + h * 0.5
374
+ pivot_x *= inv_scale
375
+ pivot_y *= inv_scale
376
+ w *= inv_scale
377
+ h *= inv_scale
378
+ dst = rl.Rectangle(pivot_x, pivot_y, w, h)
379
+ origin = rl.Vector2(w * 0.5, h * 0.5)
380
+ rl.draw_texture_pro(
381
+ decal.texture,
382
+ decal.src,
383
+ dst,
384
+ origin,
385
+ math.degrees(decal.rotation_rad),
386
+ decal.tint,
387
+ )
388
+ rl.end_texture_mode()
389
+
390
+ self._set_texture_filters(textures, point=False)
391
+ self._render_target_ready = True
392
+ return True
393
+
394
+ def bake_corpse_decals(
395
+ self,
396
+ bodyset_texture: rl.Texture,
397
+ decals: Sequence[GroundCorpseDecal],
398
+ *,
399
+ shadow: bool = True,
400
+ ) -> bool:
401
+ if not decals:
402
+ return False
403
+
404
+ self.create_render_target()
405
+ if self.render_target is None:
406
+ self._fallback_bodyset_texture = bodyset_texture
407
+ self._fallback_corpse_shadow = bool(shadow)
408
+ self._fallback_corpse_decals.extend(decals)
409
+ return True
410
+
411
+ if self.debug_log_stamps:
412
+ head = decals[0]
413
+ self._debug_stamp(
414
+ "bake_corpse_decals",
415
+ shadow=bool(shadow),
416
+ count=len(decals),
417
+ frame0=int(head.bodyset_frame),
418
+ top_left0={"x": float(head.top_left_x), "y": float(head.top_left_y)},
419
+ size0=float(head.size),
420
+ rot0=float(head.rotation_rad),
421
+ )
422
+
423
+ scale = self._normalized_texture_scale()
424
+ inv_scale = 1.0 / scale
425
+ offset = 2.0 * scale / float(self.width)
426
+ self._set_texture_filters((bodyset_texture,), point=True)
427
+
428
+ rl.begin_texture_mode(self.render_target)
429
+ with _maybe_alpha_test(self.alpha_test):
430
+ if shadow:
431
+ if self.debug_log_stamps:
432
+ self._debug_stamp("corpse_shadow_pass", draws=len(decals))
433
+ self._draw_corpse_shadow_pass(bodyset_texture, decals, inv_scale, offset)
434
+ if self.debug_log_stamps:
435
+ self._debug_stamp("corpse_color_pass", draws=len(decals))
436
+ self._draw_corpse_color_pass(bodyset_texture, decals, inv_scale, offset)
437
+ rl.end_texture_mode()
438
+
439
+ self._set_texture_filters((bodyset_texture,), point=False)
440
+ self._render_target_ready = True
441
+ return True
442
+
443
+ def _draw_fallback(
444
+ self,
445
+ camera_x: float,
446
+ camera_y: float,
447
+ *,
448
+ out_w: float,
449
+ out_h: float,
450
+ screen_w: float,
451
+ screen_h: float,
452
+ ) -> None:
453
+ rl.draw_rectangle(0, 0, int(out_w + 0.5), int(out_h + 0.5), TERRAIN_CLEAR_COLOR)
454
+ if screen_w <= 0.0 or screen_h <= 0.0:
455
+ return
456
+
457
+ scale_x = out_w / screen_w
458
+ scale_y = out_h / screen_h
459
+
460
+ view_x0 = -camera_x
461
+ view_y0 = -camera_y
462
+ view_x1 = view_x0 + screen_w
463
+ view_y1 = view_y0 + screen_h
464
+
465
+ def draw_decal(decal: GroundDecal) -> None:
466
+ texture = decal.texture
467
+ if int(getattr(texture, "id", 0)) <= 0:
468
+ return
469
+ w = float(decal.width)
470
+ h = float(decal.height)
471
+ if w <= 0.0 or h <= 0.0:
472
+ return
473
+
474
+ pivot_x = float(decal.x)
475
+ pivot_y = float(decal.y)
476
+ if not decal.centered:
477
+ pivot_x += w * 0.5
478
+ pivot_y += h * 0.5
479
+
480
+ if pivot_x + w * 0.5 < view_x0 or pivot_x - w * 0.5 > view_x1:
481
+ return
482
+ if pivot_y + h * 0.5 < view_y0 or pivot_y - h * 0.5 > view_y1:
483
+ return
484
+
485
+ sx = (pivot_x + camera_x) * scale_x
486
+ sy = (pivot_y + camera_y) * scale_y
487
+ sw = w * scale_x
488
+ sh = h * scale_y
489
+ dst = rl.Rectangle(float(sx), float(sy), float(sw), float(sh))
490
+ origin = rl.Vector2(float(sw) * 0.5, float(sh) * 0.5)
491
+ rl.draw_texture_pro(
492
+ texture,
493
+ decal.src,
494
+ dst,
495
+ origin,
496
+ math.degrees(float(decal.rotation_rad)),
497
+ decal.tint,
498
+ )
499
+
500
+ with _blend_custom_separate(
501
+ rl.RL_SRC_ALPHA,
502
+ rl.RL_ONE_MINUS_SRC_ALPHA,
503
+ rl.RL_ZERO,
504
+ rl.RL_ONE,
505
+ rl.RL_FUNC_ADD,
506
+ rl.RL_FUNC_ADD,
507
+ ):
508
+ for patch in self._fallback_patches:
509
+ draw_decal(patch)
510
+ for decal in self._fallback_decals:
511
+ draw_decal(decal)
512
+
513
+ bodyset_texture = self._fallback_bodyset_texture
514
+ if bodyset_texture is None or not self._fallback_corpse_decals:
515
+ return
516
+
517
+ def draw_corpse(size: float, pivot_x: float, pivot_y: float, rotation_deg: float, tint: rl.Color, src: rl.Rectangle) -> None:
518
+ if pivot_x + size * 0.5 < view_x0 or pivot_x - size * 0.5 > view_x1:
519
+ return
520
+ if pivot_y + size * 0.5 < view_y0 or pivot_y - size * 0.5 > view_y1:
521
+ return
522
+ sx = (pivot_x + camera_x) * scale_x
523
+ sy = (pivot_y + camera_y) * scale_y
524
+ sw = size * scale_x
525
+ sh = size * scale_y
526
+ dst = rl.Rectangle(float(sx), float(sy), float(sw), float(sh))
527
+ origin = rl.Vector2(float(sw) * 0.5, float(sh) * 0.5)
528
+ rl.draw_texture_pro(bodyset_texture, src, dst, origin, rotation_deg, tint)
529
+
530
+ with _maybe_alpha_test(self.alpha_test):
531
+ if self._fallback_corpse_shadow:
532
+ with _blend_custom_separate(
533
+ rl.RL_ZERO,
534
+ rl.RL_ONE_MINUS_SRC_ALPHA,
535
+ rl.RL_ZERO,
536
+ rl.RL_ONE,
537
+ rl.RL_FUNC_ADD,
538
+ rl.RL_FUNC_ADD,
539
+ ):
540
+ for decal in self._fallback_corpse_decals:
541
+ src = self._corpse_src(bodyset_texture, decal.bodyset_frame)
542
+ size = float(decal.size) * 1.064
543
+ pivot_x = float(decal.top_left_x - 0.5) + size * 0.5
544
+ pivot_y = float(decal.top_left_y - 0.5) + size * 0.5
545
+ tint = rl.Color(decal.tint.r, decal.tint.g, decal.tint.b, int(decal.tint.a * 0.5))
546
+ rotation_deg = math.degrees(float(decal.rotation_rad) - (math.pi * 0.5))
547
+ draw_corpse(size, pivot_x, pivot_y, rotation_deg, tint, src)
548
+
549
+ with _blend_custom_separate(
550
+ rl.RL_SRC_ALPHA,
551
+ rl.RL_ONE_MINUS_SRC_ALPHA,
552
+ rl.RL_ZERO,
553
+ rl.RL_ONE,
554
+ rl.RL_FUNC_ADD,
555
+ rl.RL_FUNC_ADD,
556
+ ):
557
+ for decal in self._fallback_corpse_decals:
558
+ src = self._corpse_src(bodyset_texture, decal.bodyset_frame)
559
+ size = float(decal.size)
560
+ pivot_x = float(decal.top_left_x) + size * 0.5
561
+ pivot_y = float(decal.top_left_y) + size * 0.5
562
+ rotation_deg = math.degrees(float(decal.rotation_rad) - (math.pi * 0.5))
563
+ draw_corpse(size, pivot_x, pivot_y, rotation_deg, decal.tint, src)
564
+
565
+ def draw(
566
+ self,
567
+ camera_x: float,
568
+ camera_y: float,
569
+ *,
570
+ screen_w: float | None = None,
571
+ screen_h: float | None = None,
572
+ ) -> None:
573
+ out_w = float(rl.get_screen_width())
574
+ out_h = float(rl.get_screen_height())
575
+ if screen_w is None:
576
+ screen_w = float(self.screen_width or out_w)
577
+ if screen_h is None:
578
+ screen_h = float(self.screen_height or out_h)
579
+ if screen_w <= 0.0:
580
+ screen_w = out_w
581
+ if screen_h <= 0.0:
582
+ screen_h = out_h
583
+ if screen_w > self.width:
584
+ screen_w = float(self.width)
585
+ if screen_h > self.height:
586
+ screen_h = float(self.height)
587
+ cam_x, cam_y = self._clamp_camera(camera_x, camera_y, screen_w, screen_h)
588
+
589
+ if self.render_target is None or not self._render_target_ready:
590
+ self._draw_fallback(cam_x, cam_y, out_w=out_w, out_h=out_h, screen_w=float(screen_w), screen_h=float(screen_h))
591
+ return
592
+
593
+ target = self.render_target
594
+ u0 = -cam_x / float(self.width)
595
+ v0 = -cam_y / float(self.height)
596
+ u1 = u0 + screen_w / float(self.width)
597
+ v1 = v0 + screen_h / float(self.height)
598
+ src_x = u0 * float(target.texture.width)
599
+ # Render textures are vertically flipped in raylib, so adjust the source
600
+ # rectangle to sample the correct world-space slice before flipping.
601
+ src_y = (1.0 - v1) * float(target.texture.height)
602
+ src_w = (u1 - u0) * float(target.texture.width)
603
+ src_h = (v1 - v0) * float(target.texture.height)
604
+ src = rl.Rectangle(src_x, src_y, src_w, -src_h)
605
+ dst = rl.Rectangle(0.0, 0.0, out_w, out_h)
606
+ if self.terrain_filter == 2.0:
607
+ rl.set_texture_filter(target.texture, rl.TEXTURE_FILTER_POINT)
608
+ # Disable alpha blending when drawing terrain to screen - the render target's
609
+ # alpha channel may be < 1.0 after stamp blending, but terrain should be opaque.
610
+ with _blend_custom(rl.RL_ONE, rl.RL_ZERO, rl.RL_FUNC_ADD):
611
+ rl.draw_texture_pro(target.texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
612
+ if self.terrain_filter == 2.0:
613
+ rl.set_texture_filter(target.texture, rl.TEXTURE_FILTER_BILINEAR)
614
+
615
+ def _scatter_texture(
616
+ self,
617
+ texture: rl.Texture,
618
+ tint: rl.Color,
619
+ rng: CrtRand,
620
+ density: int,
621
+ ) -> None:
622
+ area = self.width * self.height
623
+ count = (area * density) >> TERRAIN_DENSITY_SHIFT
624
+ if count <= 0:
625
+ return
626
+ inv_scale = 1.0 / self._normalized_texture_scale()
627
+ size = TERRAIN_PATCH_SIZE * inv_scale
628
+ src = rl.Rectangle(0.0, 0.0, float(texture.width), float(texture.height))
629
+ origin = rl.Vector2(size * 0.5, size * 0.5)
630
+ span_w = self.width + int(TERRAIN_PATCH_OVERSCAN * 2)
631
+ # The original exe uses `terrain_texture_width` for both axes. Terrain is
632
+ # square (1024x1024) so this is equivalent, but keep it for parity.
633
+ span_h = span_w
634
+ for _ in range(count):
635
+ angle = ((rng.rand() % TERRAIN_ROTATION_MAX) * 0.01) % math.tau
636
+ # IMPORTANT: The exe consumes RNG as rotation, then Y, then X.
637
+ y = ((rng.rand() % span_h) - TERRAIN_PATCH_OVERSCAN) * inv_scale
638
+ x = ((rng.rand() % span_w) - TERRAIN_PATCH_OVERSCAN) * inv_scale
639
+ # raylib's DrawTexturePro positions the quad by the *origin point*,
640
+ # while the original engine uses x/y as the quad top-left.
641
+ dst = rl.Rectangle(float(x + size * 0.5), float(y + size * 0.5), size, size)
642
+ rl.draw_texture_pro(texture, src, dst, origin, math.degrees(angle), tint)
643
+
644
+ def _scatter_texture_fallback(
645
+ self,
646
+ texture: rl.Texture,
647
+ tint: rl.Color,
648
+ rng: CrtRand,
649
+ density: int,
650
+ ) -> None:
651
+ """Record terrain patch draws for the render-target fallback path."""
652
+ area = self.width * self.height
653
+ count = (area * density) >> TERRAIN_DENSITY_SHIFT
654
+ if count <= 0:
655
+ return
656
+
657
+ size = float(TERRAIN_PATCH_SIZE)
658
+ src = rl.Rectangle(0.0, 0.0, float(texture.width), float(texture.height))
659
+ span_w = self.width + int(TERRAIN_PATCH_OVERSCAN * 2)
660
+ # The original exe uses `terrain_texture_width` for both axes. Terrain is
661
+ # square (1024x1024) so this is equivalent, but keep it for parity.
662
+ span_h = span_w
663
+
664
+ for _ in range(count):
665
+ angle = ((rng.rand() % TERRAIN_ROTATION_MAX) * 0.01) % math.tau
666
+ # IMPORTANT: The exe consumes RNG as rotation, then Y, then X.
667
+ y = float((rng.rand() % span_h) - TERRAIN_PATCH_OVERSCAN)
668
+ x = float((rng.rand() % span_w) - TERRAIN_PATCH_OVERSCAN)
669
+ self._fallback_patches.append(
670
+ GroundDecal(
671
+ texture=texture,
672
+ src=src,
673
+ x=float(x + size * 0.5),
674
+ y=float(y + size * 0.5),
675
+ width=size,
676
+ height=size,
677
+ rotation_rad=float(angle),
678
+ tint=tint,
679
+ centered=True,
680
+ )
681
+ )
682
+
683
+ def _clamp_camera(self, camera_x: float, camera_y: float, screen_w: float, screen_h: float) -> tuple[float, float]:
684
+ min_x = screen_w - float(self.width)
685
+ min_y = screen_h - float(self.height)
686
+ if camera_x > -1.0:
687
+ camera_x = -1.0
688
+ if camera_y > -1.0:
689
+ camera_y = -1.0
690
+ if camera_x < min_x:
691
+ camera_x = min_x
692
+ if camera_y < min_y:
693
+ camera_y = min_y
694
+ return camera_x, camera_y
695
+
696
+ def _ensure_render_target(self, render_w: int, render_h: int) -> bool:
697
+ if self.render_target is not None:
698
+ if self.render_target.texture.width == render_w and self.render_target.texture.height == render_h:
699
+ return True
700
+ rl.unload_render_texture(self.render_target)
701
+ self.render_target = None
702
+ self._render_target_ready = False
703
+
704
+ try:
705
+ candidate = rl.load_render_texture(render_w, render_h)
706
+ except Exception:
707
+ return False
708
+
709
+ if not getattr(candidate, "id", 0) or not rl.is_render_texture_valid(candidate):
710
+ if getattr(candidate, "id", 0):
711
+ rl.unload_render_texture(candidate)
712
+ return False
713
+ if (
714
+ getattr(getattr(candidate, "texture", None), "width", 0) <= 0
715
+ or getattr(getattr(candidate, "texture", None), "height", 0) <= 0
716
+ ):
717
+ rl.unload_render_texture(candidate)
718
+ return False
719
+
720
+ self.render_target = candidate
721
+ self._render_target_ready = False
722
+ self._render_target_warmup_passes = 1
723
+ rl.set_texture_filter(self.render_target.texture, rl.TEXTURE_FILTER_BILINEAR)
724
+ rl.set_texture_wrap(self.render_target.texture, rl.TEXTURE_WRAP_CLAMP)
725
+ return True
726
+
727
+ def _render_target_size_for(self, scale: float) -> tuple[int, int]:
728
+ render_w = max(1, int(self.width / scale))
729
+ render_h = max(1, int(self.height / scale))
730
+ return render_w, render_h
731
+
732
+ def _normalized_texture_scale(self) -> float:
733
+ scale = self.texture_scale
734
+ if scale < 0.5:
735
+ scale = 0.5
736
+ return scale
737
+
738
+ def _set_stamp_filters(self, *, point: bool) -> None:
739
+ self._set_texture_filters(
740
+ (self.texture, self.overlay, self.overlay_detail),
741
+ point=point,
742
+ )
743
+
744
+ @staticmethod
745
+ def _unique_textures(textures: Iterable[rl.Texture]) -> list[rl.Texture]:
746
+ unique: list[rl.Texture] = []
747
+ seen: set[int] = set()
748
+ for texture in textures:
749
+ texture_id = int(getattr(texture, "id", 0))
750
+ if texture_id <= 0 or texture_id in seen:
751
+ continue
752
+ seen.add(texture_id)
753
+ unique.append(texture)
754
+ return unique
755
+
756
+ @staticmethod
757
+ def _set_texture_filters(textures: Iterable[rl.Texture | None], *, point: bool) -> None:
758
+ mode = rl.TEXTURE_FILTER_POINT if point else rl.TEXTURE_FILTER_BILINEAR
759
+ for texture in textures:
760
+ if texture is None:
761
+ continue
762
+ if int(getattr(texture, "id", 0)) <= 0:
763
+ continue
764
+ rl.set_texture_filter(texture, mode)
765
+
766
+ def _corpse_src(self, bodyset_texture: rl.Texture, frame: int) -> rl.Rectangle:
767
+ frame = int(frame) & 0xF
768
+ cell_w = float(bodyset_texture.width) * 0.25
769
+ cell_h = float(bodyset_texture.height) * 0.25
770
+ col = frame & 3
771
+ row = frame >> 2
772
+ return rl.Rectangle(cell_w * float(col), cell_h * float(row), cell_w, cell_h)
773
+
774
+ def _draw_corpse_shadow_pass(
775
+ self,
776
+ bodyset_texture: rl.Texture,
777
+ decals: Sequence[GroundCorpseDecal],
778
+ inv_scale: float,
779
+ offset: float,
780
+ ) -> None:
781
+ with _blend_custom_separate(
782
+ rl.RL_ZERO,
783
+ rl.RL_ONE_MINUS_SRC_ALPHA,
784
+ rl.RL_ZERO,
785
+ rl.RL_ONE,
786
+ rl.RL_FUNC_ADD,
787
+ rl.RL_FUNC_ADD,
788
+ ):
789
+ for decal in decals:
790
+ src = self._corpse_src(bodyset_texture, decal.bodyset_frame)
791
+ size = decal.size * inv_scale * 1.064
792
+ x = (decal.top_left_x - 0.5) * inv_scale - offset
793
+ y = (decal.top_left_y - 0.5) * inv_scale - offset
794
+ dst = rl.Rectangle(x + size * 0.5, y + size * 0.5, size, size)
795
+ origin = rl.Vector2(size * 0.5, size * 0.5)
796
+ tint = rl.Color(
797
+ decal.tint.r,
798
+ decal.tint.g,
799
+ decal.tint.b,
800
+ int(decal.tint.a * 0.5),
801
+ )
802
+ rl.draw_texture_pro(
803
+ bodyset_texture,
804
+ src,
805
+ dst,
806
+ origin,
807
+ math.degrees(decal.rotation_rad - (math.pi * 0.5)),
808
+ tint,
809
+ )
810
+
811
+ def _draw_corpse_color_pass(
812
+ self,
813
+ bodyset_texture: rl.Texture,
814
+ decals: Sequence[GroundCorpseDecal],
815
+ inv_scale: float,
816
+ offset: float,
817
+ ) -> None:
818
+ with _blend_custom_separate(
819
+ rl.RL_SRC_ALPHA,
820
+ rl.RL_ONE_MINUS_SRC_ALPHA,
821
+ rl.RL_ZERO,
822
+ rl.RL_ONE,
823
+ rl.RL_FUNC_ADD,
824
+ rl.RL_FUNC_ADD,
825
+ ):
826
+ for decal in decals:
827
+ src = self._corpse_src(bodyset_texture, decal.bodyset_frame)
828
+ size = decal.size * inv_scale
829
+ x = decal.top_left_x * inv_scale - offset
830
+ y = decal.top_left_y * inv_scale - offset
831
+ dst = rl.Rectangle(x + size * 0.5, y + size * 0.5, size, size)
832
+ origin = rl.Vector2(size * 0.5, size * 0.5)
833
+ rl.draw_texture_pro(
834
+ bodyset_texture,
835
+ src,
836
+ dst,
837
+ origin,
838
+ math.degrees(decal.rotation_rad - (math.pi * 0.5)),
839
+ decal.tint,
840
+ )