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,1166 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import random
6
+ import os
7
+ from pathlib import Path
8
+ from dataclasses import dataclass
9
+
10
+ import pyray as rl
11
+
12
+ from grim.config import ensure_crimson_cfg
13
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
14
+ from grim.view import ViewContext
15
+
16
+ from ..creatures.spawn import CreatureInit, CreatureTypeId
17
+ from ..game_world import GameWorld
18
+ from ..gameplay import PlayerInput
19
+ from ..paths import default_runtime_dir
20
+ from .registry import register_view
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
+
29
+ _SDF_SHADOW_MAX_CIRCLES = 64
30
+ _SDF_SHADOW_MAX_STEPS = 64
31
+ _SDF_SHADOW_EPSILON = 0.25
32
+ _SDF_SHADOW_MIN_STEP = 0.25
33
+
34
+ _SDF_SHADOW_VS_330 = r"""
35
+ #version 330
36
+
37
+ in vec3 vertexPosition;
38
+ in vec2 vertexTexCoord;
39
+ in vec4 vertexColor;
40
+
41
+ out vec2 fragTexCoord;
42
+ out vec4 fragColor;
43
+
44
+ uniform mat4 mvp;
45
+
46
+ void main() {
47
+ fragTexCoord = vertexTexCoord;
48
+ fragColor = vertexColor;
49
+ gl_Position = mvp * vec4(vertexPosition, 1.0);
50
+ }
51
+ """
52
+
53
+ _SDF_SHADOW_FS_330 = rf"""
54
+ #version 330
55
+
56
+ in vec2 fragTexCoord;
57
+ in vec4 fragColor;
58
+
59
+ out vec4 finalColor;
60
+
61
+ #define MAX_CIRCLES {_SDF_SHADOW_MAX_CIRCLES}
62
+
63
+ uniform vec2 u_resolution;
64
+ uniform vec4 u_light_color;
65
+ uniform vec2 u_light_pos;
66
+ uniform float u_light_range;
67
+ uniform float u_light_source_radius;
68
+ uniform float u_shadow_k;
69
+ uniform float u_shadow_floor;
70
+ uniform int u_debug_mode;
71
+ uniform int u_circle_count;
72
+ uniform vec4 u_circles[MAX_CIRCLES];
73
+
74
+ float map_skip(vec2 p, int skip_idx)
75
+ {{
76
+ // Keep finite to avoid overflow in shadow math when there are no occluders
77
+ // or when uniform data is missing.
78
+ float d = 1e6;
79
+ for (int i = 0; i < MAX_CIRCLES; i++)
80
+ {{
81
+ if (i >= u_circle_count) break;
82
+ if (i == skip_idx) continue;
83
+ vec4 c = u_circles[i];
84
+ d = min(d, length(p - c.xy) - c.z);
85
+ }}
86
+ return d;
87
+ }}
88
+
89
+ float map_with_index(vec2 p, out int hit_idx)
90
+ {{
91
+ float d = 1e6;
92
+ hit_idx = -1;
93
+ for (int i = 0; i < MAX_CIRCLES; i++)
94
+ {{
95
+ if (i >= u_circle_count) break;
96
+ vec4 c = u_circles[i];
97
+ float di = length(p - c.xy) - c.z;
98
+ if (di < d)
99
+ {{
100
+ d = di;
101
+ hit_idx = i;
102
+ }}
103
+ }}
104
+ return d;
105
+ }}
106
+
107
+ // Raymarched SDF soft shadows (Inigo Quilez + Sebastian Aaltonen improvement).
108
+ // `u_shadow_k` behaves like the original `k` hardness parameter.
109
+ float softshadow(vec2 ro, vec2 rd, float mint, float maxt, float k, int skip_idx)
110
+ {{
111
+ if (u_circle_count <= 0) return 1.0;
112
+ float res = 1.0;
113
+ float t = mint;
114
+ float ph = 1e6;
115
+ for (int i = 0; i < {_SDF_SHADOW_MAX_STEPS} && t < maxt; i++)
116
+ {{
117
+ float h = map_skip(ro + rd * t, skip_idx);
118
+ if (h < {_SDF_SHADOW_EPSILON:.4f}) return 0.0;
119
+ float y = h*h/(2.0*ph);
120
+ float d = sqrt(max(0.0, h*h - y*y));
121
+ res = min(res, k * d / max(0.001, t - y));
122
+ ph = h;
123
+ t += max(h, {_SDF_SHADOW_MIN_STEP:.4f});
124
+ }}
125
+ return clamp(res, 0.0, 1.0);
126
+ }}
127
+
128
+ void main()
129
+ {{
130
+ // Match raylib 2D screen coords: origin top-left.
131
+ vec2 p = vec2(gl_FragCoord.x, u_resolution.y - gl_FragCoord.y);
132
+
133
+ if (u_debug_mode == 1)
134
+ {{
135
+ finalColor = vec4(1.0, 1.0, 1.0, 1.0);
136
+ return;
137
+ }}
138
+
139
+ if (u_debug_mode == 2)
140
+ {{
141
+ vec2 uv = p / max(u_resolution, vec2(1.0));
142
+ finalColor = vec4(uv.x, uv.y, 0.0, 1.0);
143
+ return;
144
+ }}
145
+
146
+ vec2 to_light = u_light_pos - p;
147
+ float dist = length(to_light);
148
+ if (dist <= 1e-4 || dist > u_light_range)
149
+ {{
150
+ finalColor = vec4(0.0, 0.0, 0.0, 1.0);
151
+ return;
152
+ }}
153
+
154
+ if (u_debug_mode == 3)
155
+ {{
156
+ finalColor = vec4(1.0, 1.0, 1.0, 1.0);
157
+ return;
158
+ }}
159
+
160
+ float atten = 1.0 - clamp(dist / u_light_range, 0.0, 1.0);
161
+ atten = atten * atten;
162
+
163
+ if (u_debug_mode == 4)
164
+ {{
165
+ finalColor = vec4(vec3(atten), 1.0);
166
+ return;
167
+ }}
168
+
169
+ int self_idx = -1;
170
+ float d0 = map_with_index(p, self_idx);
171
+
172
+ float k = u_shadow_k;
173
+ // Heuristic: larger disc lights soften shadows.
174
+ if (u_light_source_radius > 0.0)
175
+ {{
176
+ k = u_shadow_k / max(1.0, u_light_source_radius * 0.25);
177
+ }}
178
+ vec2 rd = to_light / max(dist, 1e-4);
179
+ float maxt = max(0.0, dist - max(0.0, u_light_source_radius));
180
+ float shadow = 0.0;
181
+ if (d0 >= 0.0)
182
+ {{
183
+ shadow = softshadow(p, rd, 0.5, maxt, k, -1);
184
+ }}
185
+
186
+ float a = 1.0 - clamp(dist / u_light_range, 0.0, 1.0);
187
+ float floor = clamp(u_shadow_floor, 0.0, 1.0);
188
+ float fill = min(floor * a, atten);
189
+ float shade = mix(fill, atten, shadow);
190
+
191
+ if (u_debug_mode == 5)
192
+ {{
193
+ finalColor = vec4(vec3(shade), 1.0);
194
+ return;
195
+ }}
196
+
197
+ vec3 add = u_light_color.rgb * shade;
198
+ finalColor = vec4(clamp(add, 0.0, 1.0), 1.0);
199
+ }}
200
+ """
201
+
202
+
203
+ def _clamp(value: float, lo: float, hi: float) -> float:
204
+ if value < lo:
205
+ return lo
206
+ if value > hi:
207
+ return hi
208
+ return value
209
+
210
+
211
+ @dataclass
212
+ class _EmissiveProjectile:
213
+ x: float
214
+ y: float
215
+ vx: float
216
+ vy: float
217
+ age: float
218
+ ttl: float
219
+
220
+
221
+ @dataclass
222
+ class _FlyingLight:
223
+ x: float
224
+ y: float
225
+ angle: float
226
+ radius: float
227
+ omega: float
228
+ range: float
229
+ source_radius: float
230
+ color: rl.Color
231
+
232
+
233
+ class LightingDebugView:
234
+ def __init__(self, ctx: ViewContext) -> None:
235
+ self._assets_root = ctx.assets_dir
236
+ self._missing_assets: list[str] = []
237
+ self._small: SmallFontData | None = None
238
+
239
+ self._world = GameWorld(
240
+ assets_dir=ctx.assets_dir,
241
+ world_size=WORLD_SIZE,
242
+ demo_mode_active=False,
243
+ difficulty_level=0,
244
+ hardcore=False,
245
+ )
246
+ self._player = self._world.players[0] if self._world.players else None
247
+
248
+ self.close_requested = False
249
+
250
+ self._ui_mouse_x = 0.0
251
+ self._ui_mouse_y = 0.0
252
+
253
+ self._simulate = True
254
+ self._draw_debug = True
255
+ self._draw_occluders = False
256
+ self._debug_lightmap_preview = False
257
+ self._debug_dump_next_frame = False
258
+ self._debug_dump_count = 0
259
+ self._debug_auto_dump = os.environ.get("CRIMSON_LIGHTING_DEBUG_AUTODUMP", "0") not in ("", "0", "false", "False")
260
+
261
+ self._sdf_shadow_k = 12.0
262
+ self._sdf_shadow_floor = 0.25
263
+ try:
264
+ self._sdf_debug_mode = int(os.environ.get("CRIMSON_LIGHTING_SDF_DEBUG_MODE", "0"))
265
+ except Exception:
266
+ self._sdf_debug_mode = 0
267
+
268
+ self._light_radius = 360.0
269
+ self._light_source_radius = 14.0
270
+ self._ambient_base = rl.Color(26, 26, 34, 255)
271
+ self._ambient_mul = 1.0
272
+ self._ambient = rl.Color(26, 26, 34, 255)
273
+ self._light_tint = rl.Color(255, 245, 220, 255)
274
+ self._cursor_light_enabled = True
275
+
276
+ self._last_sdf_circles: list[tuple[float, float, float]] = []
277
+ self._occluder_radius_mul = 0.25
278
+ self._occluder_radius_pad_px = 0.0
279
+
280
+ self._projectiles: list[_EmissiveProjectile] = []
281
+ self._proj_fire_cd = 0.0
282
+ self._proj_fire_interval = 0.08
283
+ self._proj_speed = 350.0
284
+ self._proj_ttl = 1.25
285
+ self._proj_radius_px = 3.0
286
+ self._proj_light_range = 220.0
287
+ self._proj_light_source_radius = 10.0
288
+ self._proj_light_tint = rl.Color(255, 190, 140, 255)
289
+ self._max_projectiles = 128
290
+ self._max_projectile_lights = 16
291
+
292
+ self._fly_lights_enabled = False
293
+ self._fly_lights: list[_FlyingLight] = []
294
+ self._fly_light_count = 6
295
+ self._fly_light_range = 320.0
296
+ self._fly_light_source_radius = 18.0
297
+
298
+ self._sdf_shader: rl.Shader | None = None
299
+ self._sdf_shader_tried: bool = False
300
+ self._sdf_shader_locs: dict[str, int] = {}
301
+ self._sdf_shader_missing: list[str] = []
302
+ self._light_rt: rl.RenderTexture | None = None
303
+ self._solid_white: rl.Texture | None = None
304
+
305
+ self._update_ambient()
306
+
307
+ def _update_ambient(self) -> None:
308
+ base = self._ambient_base
309
+ m = max(0.0, float(self._ambient_mul))
310
+ self._ambient = rl.Color(
311
+ int(_clamp(float(base.r) * m, 0.0, 255.0)),
312
+ int(_clamp(float(base.g) * m, 0.0, 255.0)),
313
+ int(_clamp(float(base.b) * m, 0.0, 255.0)),
314
+ 255,
315
+ )
316
+
317
+ def _spawn_fly_lights(self, *, seed: int) -> None:
318
+ if self._player is None:
319
+ return
320
+ rng = random.Random(int(seed))
321
+ palette = [
322
+ rl.Color(120, 220, 255, 255),
323
+ rl.Color(255, 110, 200, 255),
324
+ rl.Color(140, 255, 160, 255),
325
+ rl.Color(255, 220, 120, 255),
326
+ rl.Color(180, 140, 255, 255),
327
+ rl.Color(255, 160, 90, 255),
328
+ ]
329
+ px = float(self._player.pos_x)
330
+ py = float(self._player.pos_y)
331
+ self._fly_lights.clear()
332
+ for i in range(int(self._fly_light_count)):
333
+ angle = rng.random() * math.tau
334
+ radius = 160.0 + rng.random() * 260.0
335
+ omega = (0.8 + rng.random() * 1.6) * (-1.0 if (i % 2) else 1.0)
336
+ c = palette[i % len(palette)]
337
+ if rng.random() < 0.5:
338
+ c = palette[int(rng.random() * len(palette)) % len(palette)]
339
+ x = _clamp(px + math.cos(angle) * radius, 0.0, WORLD_SIZE)
340
+ y = _clamp(py + math.sin(angle) * radius, 0.0, WORLD_SIZE)
341
+ r = float(self._fly_light_range) * (0.8 + rng.random() * 0.5)
342
+ sr = float(self._fly_light_source_radius) * (0.7 + rng.random() * 0.7)
343
+ self._fly_lights.append(
344
+ _FlyingLight(
345
+ x=float(x),
346
+ y=float(y),
347
+ angle=float(angle),
348
+ radius=float(radius),
349
+ omega=float(omega),
350
+ range=float(r),
351
+ source_radius=float(sr),
352
+ color=c,
353
+ )
354
+ )
355
+
356
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
357
+ if self._small is not None:
358
+ return int(self._small.cell_size * scale)
359
+ return int(20 * scale)
360
+
361
+ def _draw_ui_text(self, text: str, x: float, y: float, color: rl.Color, scale: float = UI_TEXT_SCALE) -> None:
362
+ if self._small is not None:
363
+ draw_small_text(self._small, text, x, y, scale, color)
364
+ else:
365
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
366
+
367
+ def _update_ui_mouse(self) -> None:
368
+ if self._debug_auto_dump:
369
+ return
370
+ mouse = rl.get_mouse_position()
371
+ screen_w = float(rl.get_screen_width())
372
+ screen_h = float(rl.get_screen_height())
373
+ self._ui_mouse_x = _clamp(float(mouse.x), 0.0, max(0.0, screen_w - 1.0))
374
+ self._ui_mouse_y = _clamp(float(mouse.y), 0.0, max(0.0, screen_h - 1.0))
375
+
376
+ def _handle_debug_input(self) -> None:
377
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
378
+ self.close_requested = True
379
+
380
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
381
+ self._simulate = not self._simulate
382
+
383
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ONE):
384
+ self._draw_debug = not self._draw_debug
385
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TWO):
386
+ self._draw_occluders = not self._draw_occluders
387
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_THREE):
388
+ self._cursor_light_enabled = not self._cursor_light_enabled
389
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_FIVE):
390
+ self._fly_lights_enabled = not self._fly_lights_enabled
391
+ if self._fly_lights_enabled and not self._fly_lights:
392
+ self._spawn_fly_lights(seed=0xF17_0BEE)
393
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_FOUR):
394
+ self._debug_lightmap_preview = not self._debug_lightmap_preview
395
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F5):
396
+ self._debug_dump_next_frame = True
397
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F6):
398
+ self._sdf_debug_mode = (self._sdf_debug_mode + 1) % 6
399
+
400
+ shift = rl.is_key_down(rl.KeyboardKey.KEY_LEFT_SHIFT) or rl.is_key_down(rl.KeyboardKey.KEY_RIGHT_SHIFT)
401
+ occ_mul_step = 0.05 if not shift else 0.10
402
+ occ_pad_step = 1.0 if not shift else 4.0
403
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_O):
404
+ self._occluder_radius_mul = _clamp(self._occluder_radius_mul - occ_mul_step, 0.25, 2.50)
405
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_P):
406
+ self._occluder_radius_mul = _clamp(self._occluder_radius_mul + occ_mul_step, 0.25, 2.50)
407
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_K):
408
+ self._occluder_radius_pad_px = _clamp(self._occluder_radius_pad_px - occ_pad_step, -20.0, 60.0)
409
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_L):
410
+ self._occluder_radius_pad_px = _clamp(self._occluder_radius_pad_px + occ_pad_step, -20.0, 60.0)
411
+
412
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_MINUS) or rl.is_key_pressed(rl.KeyboardKey.KEY_KP_SUBTRACT):
413
+ self._sdf_shadow_floor = _clamp(self._sdf_shadow_floor - 0.05, 0.0, 0.9)
414
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_EQUAL) or rl.is_key_pressed(rl.KeyboardKey.KEY_KP_ADD):
415
+ self._sdf_shadow_floor = _clamp(self._sdf_shadow_floor + 0.05, 0.0, 0.9)
416
+
417
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
418
+ if shift:
419
+ self._light_radius = max(80.0, self._light_radius - 20.0)
420
+ else:
421
+ self._light_source_radius = max(1.0, self._light_source_radius - 2.0)
422
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
423
+ if shift:
424
+ self._light_radius = min(1200.0, self._light_radius + 20.0)
425
+ else:
426
+ self._light_source_radius = min(80.0, self._light_source_radius + 2.0)
427
+
428
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_COMMA):
429
+ self._sdf_shadow_k = max(1.0, self._sdf_shadow_k / 1.25)
430
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_PERIOD):
431
+ self._sdf_shadow_k = min(512.0, self._sdf_shadow_k * 1.25)
432
+
433
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
434
+ self._reset_scene(seed=0xBEEF)
435
+
436
+ amb_step = 0.10 if not shift else 0.25
437
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_N):
438
+ self._ambient_mul = _clamp(self._ambient_mul - amb_step, 0.0, 8.0)
439
+ self._update_ambient()
440
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_M):
441
+ self._ambient_mul = _clamp(self._ambient_mul + amb_step, 0.0, 8.0)
442
+ self._update_ambient()
443
+
444
+ def _ensure_sdf_shader(self) -> rl.Shader | None:
445
+ if (
446
+ self._sdf_shader is not None
447
+ and int(getattr(self._sdf_shader, "id", 0)) > 0
448
+ and rl.is_shader_valid(self._sdf_shader)
449
+ ):
450
+ return self._sdf_shader
451
+ if self._sdf_shader_tried:
452
+ return None
453
+ self._sdf_shader_tried = True
454
+
455
+ try:
456
+ # Prefer raylib's default vertex shader to avoid attribute binding
457
+ # mismatches across platforms/backends.
458
+ shader = rl.load_shader_from_memory(None, _SDF_SHADOW_FS_330)
459
+ except Exception:
460
+ try:
461
+ shader = rl.load_shader_from_memory(_SDF_SHADOW_VS_330, _SDF_SHADOW_FS_330)
462
+ except Exception:
463
+ self._sdf_shader = None
464
+ return None
465
+
466
+ if int(getattr(shader, "id", 0)) <= 0 or not rl.is_shader_valid(shader):
467
+ self._sdf_shader = None
468
+ return None
469
+
470
+ self._sdf_shader = shader
471
+
472
+ circles_loc = rl.get_shader_location(shader, "u_circles")
473
+ if circles_loc < 0:
474
+ circles_loc = rl.get_shader_location(shader, "u_circles[0]")
475
+
476
+ self._sdf_shader_locs = {
477
+ "u_resolution": rl.get_shader_location(shader, "u_resolution"),
478
+ "u_light_color": rl.get_shader_location(shader, "u_light_color"),
479
+ "u_light_pos": rl.get_shader_location(shader, "u_light_pos"),
480
+ "u_light_range": rl.get_shader_location(shader, "u_light_range"),
481
+ "u_light_source_radius": rl.get_shader_location(shader, "u_light_source_radius"),
482
+ "u_shadow_k": rl.get_shader_location(shader, "u_shadow_k"),
483
+ "u_shadow_floor": rl.get_shader_location(shader, "u_shadow_floor"),
484
+ "u_debug_mode": rl.get_shader_location(shader, "u_debug_mode"),
485
+ "u_circle_count": rl.get_shader_location(shader, "u_circle_count"),
486
+ "u_circles": circles_loc,
487
+ }
488
+ self._sdf_shader_missing = [name for name, loc in self._sdf_shader_locs.items() if loc < 0]
489
+
490
+ return self._sdf_shader
491
+
492
+ def _ensure_render_target(self, rt: rl.RenderTexture | None, w: int, h: int) -> rl.RenderTexture:
493
+ if rt is not None and int(getattr(rt, "id", 0)) > 0:
494
+ if int(getattr(getattr(rt, "texture", None), "width", 0)) == w and int(getattr(getattr(rt, "texture", None), "height", 0)) == h:
495
+ return rt
496
+ rl.unload_render_texture(rt)
497
+ return rl.load_render_texture(w, h)
498
+
499
+ def _ensure_render_targets(self) -> None:
500
+ w = int(max(1, rl.get_screen_width()))
501
+ h = int(max(1, rl.get_screen_height()))
502
+ self._light_rt = self._ensure_render_target(self._light_rt, w, h)
503
+
504
+ def _reset_scene(self, *, seed: int) -> None:
505
+ self._world.reset(seed=int(seed), player_count=1)
506
+ self._player = self._world.players[0] if self._world.players else None
507
+ self._world.update_camera(0.0)
508
+ self._projectiles.clear()
509
+ self._proj_fire_cd = 0.0
510
+ self._cursor_light_enabled = True
511
+ self._ambient_mul = 1.0
512
+ self._update_ambient()
513
+ if self._fly_lights_enabled:
514
+ self._spawn_fly_lights(seed=int(seed) ^ 0xF17_0BEE)
515
+ else:
516
+ self._fly_lights.clear()
517
+
518
+ rng = random.Random(int(seed))
519
+ if self._player is None:
520
+ return
521
+ center_x = float(self._player.pos_x)
522
+ center_y = float(self._player.pos_y)
523
+
524
+ self._world.creatures.reset()
525
+ types = [
526
+ CreatureTypeId.ZOMBIE,
527
+ CreatureTypeId.ALIEN,
528
+ CreatureTypeId.SPIDER_SP1,
529
+ CreatureTypeId.LIZARD,
530
+ ]
531
+ for idx in range(20):
532
+ t = types[idx % len(types)]
533
+ angle = rng.random() * math.tau
534
+ radius = 120.0 + rng.random() * 260.0
535
+ x = center_x + math.cos(angle) * radius
536
+ y = center_y + math.sin(angle) * radius
537
+ x = _clamp(x, 40.0, WORLD_SIZE - 40.0)
538
+ y = _clamp(y, 40.0, WORLD_SIZE - 40.0)
539
+ init = CreatureInit(
540
+ origin_template_id=0,
541
+ pos_x=float(x),
542
+ pos_y=float(y),
543
+ heading=float(rng.random() * math.tau),
544
+ phase_seed=float(rng.random() * 999.0),
545
+ type_id=t,
546
+ health=80.0,
547
+ max_health=80.0,
548
+ move_speed=1.0,
549
+ reward_value=0.0,
550
+ size=48.0 + rng.random() * 18.0,
551
+ contact_damage=0.0,
552
+ )
553
+ self._world.creatures.spawn_init(init, rand=self._world.state.rng.rand)
554
+
555
+ def open(self) -> None:
556
+ self._missing_assets.clear()
557
+ try:
558
+ self._small = load_small_font(self._assets_root, self._missing_assets)
559
+ except Exception:
560
+ self._small = None
561
+
562
+ runtime_dir = default_runtime_dir()
563
+ if runtime_dir.is_dir():
564
+ try:
565
+ self._world.config = ensure_crimson_cfg(runtime_dir)
566
+ except Exception:
567
+ self._world.config = None
568
+ else:
569
+ self._world.config = None
570
+
571
+ self._world.open()
572
+ self._reset_scene(seed=0xBEEF)
573
+ self._ensure_render_targets()
574
+
575
+ try:
576
+ img = rl.gen_image_color(1, 1, rl.WHITE)
577
+ self._solid_white = rl.load_texture_from_image(img)
578
+ rl.unload_image(img)
579
+ except Exception:
580
+ self._solid_white = None
581
+
582
+ self._ui_mouse_x = float(rl.get_screen_width()) * 0.5
583
+ self._ui_mouse_y = float(rl.get_screen_height()) * 0.5
584
+ if self._debug_auto_dump:
585
+ self._debug_dump_next_frame = True
586
+
587
+ def close(self) -> None:
588
+ if self._small is not None:
589
+ rl.unload_texture(self._small.texture)
590
+ self._small = None
591
+ if self._sdf_shader is not None and int(getattr(self._sdf_shader, "id", 0)) > 0:
592
+ rl.unload_shader(self._sdf_shader)
593
+ self._sdf_shader = None
594
+ self._sdf_shader_locs.clear()
595
+ self._sdf_shader_missing.clear()
596
+ if self._light_rt is not None and int(getattr(self._light_rt, "id", 0)) > 0:
597
+ rl.unload_render_texture(self._light_rt)
598
+ self._light_rt = None
599
+ if self._solid_white is not None and int(getattr(self._solid_white, "id", 0)) > 0:
600
+ rl.unload_texture(self._solid_white)
601
+ self._solid_white = None
602
+ self._world.close()
603
+
604
+ def update(self, dt: float) -> None:
605
+ dt_frame = float(dt)
606
+ self._update_ui_mouse()
607
+ self._handle_debug_input()
608
+
609
+ aim_x, aim_y = self._world.screen_to_world(self._ui_mouse_x, self._ui_mouse_y)
610
+ if self._player is not None:
611
+ self._player.aim_x = float(aim_x)
612
+ self._player.aim_y = float(aim_y)
613
+
614
+ move_x = 0.0
615
+ move_y = 0.0
616
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
617
+ move_x -= 1.0
618
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
619
+ move_x += 1.0
620
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
621
+ move_y -= 1.0
622
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
623
+ move_y += 1.0
624
+
625
+ dt_world = dt_frame if self._simulate else 0.0
626
+ self._world.update(
627
+ dt_world,
628
+ inputs=[
629
+ PlayerInput(
630
+ move_x=move_x,
631
+ move_y=move_y,
632
+ aim_x=float(aim_x),
633
+ aim_y=float(aim_y),
634
+ fire_down=False,
635
+ fire_pressed=False,
636
+ reload_pressed=False,
637
+ )
638
+ ],
639
+ auto_pick_perks=False,
640
+ perk_progression_enabled=False,
641
+ )
642
+
643
+ if not self._debug_auto_dump and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
644
+ self._proj_fire_cd -= dt_frame
645
+ while self._proj_fire_cd <= 0.0:
646
+ self._spawn_projectile(aim_x=float(aim_x), aim_y=float(aim_y))
647
+ self._proj_fire_cd += float(self._proj_fire_interval)
648
+ else:
649
+ self._proj_fire_cd = max(0.0, self._proj_fire_cd - dt_frame)
650
+
651
+ if dt_world > 0.0:
652
+ if self._fly_lights_enabled and self._fly_lights and self._player is not None:
653
+ px = float(self._player.pos_x)
654
+ py = float(self._player.pos_y)
655
+ for fl in self._fly_lights:
656
+ fl.angle += fl.omega * dt_world
657
+ wobble = 1.0 + 0.10 * math.sin(fl.angle * 0.7)
658
+ r = fl.radius * wobble
659
+ fl.x = _clamp(px + math.cos(fl.angle) * r, 0.0, WORLD_SIZE)
660
+ fl.y = _clamp(py + math.sin(fl.angle) * r, 0.0, WORLD_SIZE)
661
+
662
+ keep: list[_EmissiveProjectile] = []
663
+ margin = 80.0
664
+ for proj in self._projectiles:
665
+ proj.age += dt_world
666
+ proj.x += proj.vx * dt_world
667
+ proj.y += proj.vy * dt_world
668
+ if proj.age >= proj.ttl:
669
+ continue
670
+ if proj.x < -margin or proj.x > WORLD_SIZE + margin or proj.y < -margin or proj.y > WORLD_SIZE + margin:
671
+ continue
672
+ keep.append(proj)
673
+ self._projectiles = keep[-self._max_projectiles :]
674
+
675
+ def _spawn_projectile(self, *, aim_x: float, aim_y: float) -> None:
676
+ if self._player is None:
677
+ return
678
+ px = float(self._player.pos_x)
679
+ py = float(self._player.pos_y)
680
+ dx = float(aim_x) - px
681
+ dy = float(aim_y) - py
682
+ d = math.hypot(dx, dy)
683
+ if d <= 1e-3:
684
+ dx = 1.0
685
+ dy = 0.0
686
+ d = 1.0
687
+ dx /= d
688
+ dy /= d
689
+ muzzle = 18.0
690
+ x = px + dx * muzzle
691
+ y = py + dy * muzzle
692
+ speed = float(self._proj_speed)
693
+ self._projectiles.append(
694
+ _EmissiveProjectile(
695
+ x=float(x),
696
+ y=float(y),
697
+ vx=float(dx) * speed,
698
+ vy=float(dy) * speed,
699
+ age=0.0,
700
+ ttl=float(self._proj_ttl),
701
+ )
702
+ )
703
+
704
+ def _dump_debug(self, *, light_x: float, light_y: float, sdf_ok: bool) -> None:
705
+ if self._light_rt is None:
706
+ return
707
+ out_dir = Path("artifacts") / "lighting-debug"
708
+ try:
709
+ out_dir.mkdir(parents=True, exist_ok=True)
710
+ except Exception:
711
+ return
712
+
713
+ self._debug_dump_count += 1
714
+ prefix = f"{self._debug_dump_count:04d}"
715
+
716
+ w = int(self._light_rt.texture.width)
717
+ h = int(self._light_rt.texture.height)
718
+
719
+ # Lightmap readback + samples.
720
+ lightmap_path = out_dir / f"{prefix}_lightmap.png"
721
+ samples: dict[str, list[int]] = {}
722
+ approx_min_rgb = [255, 255, 255]
723
+ approx_max_rgb = [0, 0, 0]
724
+ try:
725
+ img = rl.load_image_from_texture(self._light_rt.texture)
726
+ rl.export_image(img, str(lightmap_path))
727
+
728
+ iw = int(img.width)
729
+ ih = int(img.height)
730
+
731
+ def sample(x: float, y: float) -> list[int]:
732
+ xi = max(0, min(iw - 1, int(x)))
733
+ yi = max(0, min(ih - 1, int(y)))
734
+ c = rl.get_image_color(img, xi, yi)
735
+ return [int(c.r), int(c.g), int(c.b), int(c.a)]
736
+
737
+ samples["light_xy"] = sample(light_x, light_y)
738
+ samples["light_xy_flip_y"] = sample(light_x, float(ih - 1) - light_y)
739
+ samples["center"] = sample(float(iw) * 0.5, float(ih) * 0.5)
740
+ samples["center_flip_y"] = sample(float(iw) * 0.5, float(ih - 1) - float(ih) * 0.5)
741
+ samples["tl"] = sample(0.0, 0.0)
742
+ samples["bl"] = sample(0.0, float(ih - 1))
743
+
744
+ step_x = max(1, iw // 32)
745
+ step_y = max(1, ih // 32)
746
+ for y in range(0, ih, step_y):
747
+ for x in range(0, iw, step_x):
748
+ c = rl.get_image_color(img, x, y)
749
+ approx_min_rgb[0] = min(approx_min_rgb[0], int(c.r))
750
+ approx_min_rgb[1] = min(approx_min_rgb[1], int(c.g))
751
+ approx_min_rgb[2] = min(approx_min_rgb[2], int(c.b))
752
+ approx_max_rgb[0] = max(approx_max_rgb[0], int(c.r))
753
+ approx_max_rgb[1] = max(approx_max_rgb[1], int(c.g))
754
+ approx_max_rgb[2] = max(approx_max_rgb[2], int(c.b))
755
+
756
+ rl.unload_image(img)
757
+ except Exception:
758
+ pass
759
+
760
+ # Full-screen screenshot (after the frame is drawn).
761
+ screenshot_path = out_dir / f"{prefix}_screen.png"
762
+ fallback = Path.cwd() / screenshot_path.name
763
+ try:
764
+ if screenshot_path.exists():
765
+ screenshot_path.unlink()
766
+ except Exception:
767
+ pass
768
+ try:
769
+ if fallback.exists():
770
+ fallback.unlink()
771
+ except Exception:
772
+ pass
773
+ try:
774
+ rl.take_screenshot(str(screenshot_path))
775
+ except Exception:
776
+ pass
777
+ if fallback.exists():
778
+ try:
779
+ fallback.replace(screenshot_path)
780
+ except Exception:
781
+ pass
782
+
783
+ lt = self._light_tint
784
+ builtin_locs: dict[str, int | None] = {}
785
+ if self._sdf_shader is not None:
786
+ try:
787
+ locs = self._sdf_shader.locs
788
+ builtin_locs = {
789
+ "map_diffuse": int(locs[rl.SHADER_LOC_MAP_DIFFUSE]),
790
+ "map_normal": int(locs[rl.SHADER_LOC_MAP_NORMAL]),
791
+ "vector_view": int(locs[rl.SHADER_LOC_VECTOR_VIEW]),
792
+ "matrix_mvp": int(locs[rl.SHADER_LOC_MATRIX_MVP]),
793
+ "matrix_model": int(locs[rl.SHADER_LOC_MATRIX_MODEL]),
794
+ "matrix_view": int(locs[rl.SHADER_LOC_MATRIX_VIEW]),
795
+ "matrix_projection": int(locs[rl.SHADER_LOC_MATRIX_PROJECTION]),
796
+ "color_diffuse": int(locs[rl.SHADER_LOC_COLOR_DIFFUSE]),
797
+ "color_ambient": int(locs[rl.SHADER_LOC_COLOR_AMBIENT]),
798
+ "color_specular": int(locs[rl.SHADER_LOC_COLOR_SPECULAR]),
799
+ }
800
+ except Exception:
801
+ builtin_locs = {}
802
+ stats = {
803
+ "sdf_ok": bool(sdf_ok),
804
+ "screen_size": [int(rl.get_screen_width()), int(rl.get_screen_height())],
805
+ "light_rt_size": [w, h],
806
+ "light_pos": [float(light_x), float(light_y)],
807
+ "light_radius": float(self._light_radius),
808
+ "light_source_radius": float(self._light_source_radius),
809
+ "light_tint_rgba": [int(lt.r), int(lt.g), int(lt.b), int(lt.a)],
810
+ "ambient_rgba": [int(self._ambient.r), int(self._ambient.g), int(self._ambient.b), int(self._ambient.a)],
811
+ "ambient_mul": float(self._ambient_mul),
812
+ "cursor_light_enabled": bool(self._cursor_light_enabled),
813
+ "fly_lights_enabled": bool(self._fly_lights_enabled),
814
+ "fly_light_count": int(len(self._fly_lights)),
815
+ "shadow_k": float(self._sdf_shadow_k),
816
+ "shadow_floor": float(self._sdf_shadow_floor),
817
+ "occluder_radius_mul": float(self._occluder_radius_mul),
818
+ "occluder_radius_pad_px": float(self._occluder_radius_pad_px),
819
+ "debug_mode": int(self._sdf_debug_mode),
820
+ "circle_count": int(len(self._last_sdf_circles)),
821
+ "circles": [[float(x), float(y), float(r)] for (x, y, r) in self._last_sdf_circles[:16]],
822
+ "projectile_count": int(len(self._projectiles)),
823
+ "shader_uniform_locs": dict(self._sdf_shader_locs),
824
+ "shader_uniform_missing": list(self._sdf_shader_missing),
825
+ "shader_builtin_locs": builtin_locs,
826
+ "lightmap_samples_rgba": samples,
827
+ "lightmap_approx_min_rgb": approx_min_rgb,
828
+ "lightmap_approx_max_rgb": approx_max_rgb,
829
+ "paths": {"lightmap": str(lightmap_path), "screenshot": str(screenshot_path)},
830
+ }
831
+ stats_path = out_dir / f"{prefix}_stats.json"
832
+ try:
833
+ stats_path.write_text(json.dumps(stats, indent=2, sort_keys=True) + "\n", encoding="utf-8")
834
+ except Exception:
835
+ pass
836
+
837
+ def _render_lightmap_sdf(self, *, light_x: float, light_y: float) -> bool:
838
+ if self._light_rt is None:
839
+ return False
840
+ shader = self._ensure_sdf_shader()
841
+ if shader is None:
842
+ return False
843
+
844
+ locs = self._sdf_shader_locs
845
+
846
+ w = float(self._light_rt.texture.width)
847
+ h = float(self._light_rt.texture.height)
848
+ _cam_x, _cam_y, scale_x, scale_y = self._world.renderer._world_params()
849
+ scale = (scale_x + scale_y) * 0.5
850
+
851
+ def occ_radius(size: float) -> float:
852
+ r = float(size) * 0.5 * scale
853
+ r = r * float(self._occluder_radius_mul) + float(self._occluder_radius_pad_px)
854
+ return max(1.0, r)
855
+
856
+ circles: list[tuple[float, float, float]] = []
857
+
858
+ if self._player is not None:
859
+ px, py = self._world.world_to_screen(float(self._player.pos_x), float(self._player.pos_y))
860
+ pr = occ_radius(float(self._player.size))
861
+ circles.append((float(px), float(py), float(pr)))
862
+
863
+ for creature in self._world.creatures.entries:
864
+ if not creature.active:
865
+ continue
866
+ sx, sy = self._world.world_to_screen(float(creature.x), float(creature.y))
867
+ cr = occ_radius(float(creature.size))
868
+ circles.append((float(sx), float(sy), float(cr)))
869
+
870
+ if len(circles) > _SDF_SHADOW_MAX_CIRCLES:
871
+ circles = circles[:_SDF_SHADOW_MAX_CIRCLES]
872
+ self._last_sdf_circles = circles
873
+
874
+ def set_vec2(name: str, x: float, y: float) -> None:
875
+ loc = locs.get(name, -1)
876
+ if loc < 0:
877
+ return
878
+ buf = rl.ffi.new("float[2]", [float(x), float(y)])
879
+ rl.set_shader_value(shader, loc, rl.ffi.cast("float *", buf), rl.SHADER_UNIFORM_VEC2)
880
+
881
+ def set_vec4(name: str, x: float, y: float, z: float, q: float) -> None:
882
+ loc = locs.get(name, -1)
883
+ if loc < 0:
884
+ return
885
+ buf = rl.ffi.new("float[4]", [float(x), float(y), float(z), float(q)])
886
+ rl.set_shader_value(shader, loc, rl.ffi.cast("float *", buf), rl.SHADER_UNIFORM_VEC4)
887
+
888
+ def set_float(name: str, value: float) -> None:
889
+ loc = locs.get(name, -1)
890
+ if loc < 0:
891
+ return
892
+ rl.set_shader_value(shader, loc, rl.ffi.new("float *", float(value)), rl.SHADER_UNIFORM_FLOAT)
893
+
894
+ def set_int(name: str, value: int) -> None:
895
+ loc = locs.get(name, -1)
896
+ if loc < 0:
897
+ return
898
+ rl.set_shader_value(shader, loc, rl.ffi.new("int *", int(value)), rl.SHADER_UNIFORM_INT)
899
+
900
+ rl.begin_texture_mode(self._light_rt)
901
+ rl.clear_background(self._ambient)
902
+ # Ensure 2D lightmap passes are not affected by whatever depth state the
903
+ # world renderer left behind.
904
+ rl.rl_disable_depth_test()
905
+ rl.rl_disable_depth_mask()
906
+ rl.begin_shader_mode(shader)
907
+ set_vec2("u_resolution", w, h)
908
+ set_float("u_shadow_k", float(self._sdf_shadow_k))
909
+ set_float("u_shadow_floor", float(self._sdf_shadow_floor))
910
+ set_int("u_debug_mode", int(self._sdf_debug_mode))
911
+
912
+ set_int("u_circle_count", len(circles))
913
+ circles_loc = locs.get("u_circles", -1)
914
+ if circles and circles_loc >= 0:
915
+ flat: list[float] = []
916
+ for cx, cy, cr in circles:
917
+ flat.extend((float(cx), float(cy), float(cr), 1.0))
918
+ buf = rl.ffi.new("float[]", flat)
919
+ rl.set_shader_value_v(
920
+ shader,
921
+ circles_loc,
922
+ rl.ffi.cast("float *", buf),
923
+ rl.SHADER_UNIFORM_VEC4,
924
+ len(circles),
925
+ )
926
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
927
+
928
+ lights: list[tuple[float, float, float, float, float, float, float]] = []
929
+
930
+ def cursor_light() -> tuple[float, float, float, float, float, float, float]:
931
+ lt = self._light_tint
932
+ return (
933
+ float(light_x),
934
+ float(light_y),
935
+ float(self._light_radius),
936
+ float(self._light_source_radius),
937
+ float(lt.r) / 255.0,
938
+ float(lt.g) / 255.0,
939
+ float(lt.b) / 255.0,
940
+ )
941
+
942
+ def proj_light(proj: _EmissiveProjectile) -> tuple[float, float, float, float, float, float, float]:
943
+ sx, sy = self._world.world_to_screen(float(proj.x), float(proj.y))
944
+ fade = _clamp(1.0 - float(proj.age) / max(0.001, float(proj.ttl)), 0.0, 1.0)
945
+ pr = self._proj_light_tint
946
+ return (
947
+ float(sx),
948
+ float(sy),
949
+ float(self._proj_light_range),
950
+ float(self._proj_light_source_radius),
951
+ float(pr.r) / 255.0 * fade,
952
+ float(pr.g) / 255.0 * fade,
953
+ float(pr.b) / 255.0 * fade,
954
+ )
955
+
956
+ if self._sdf_debug_mode != 0:
957
+ if self._cursor_light_enabled:
958
+ lights.append(cursor_light())
959
+ elif self._projectiles:
960
+ lights.append(proj_light(self._projectiles[-1]))
961
+ elif self._fly_lights_enabled and self._fly_lights:
962
+ fl = self._fly_lights[0]
963
+ sx, sy = self._world.world_to_screen(float(fl.x), float(fl.y))
964
+ c = fl.color
965
+ lights.append(
966
+ (
967
+ float(sx),
968
+ float(sy),
969
+ float(fl.range),
970
+ float(fl.source_radius),
971
+ float(c.r) / 255.0,
972
+ float(c.g) / 255.0,
973
+ float(c.b) / 255.0,
974
+ )
975
+ )
976
+ else:
977
+ # Debug mode still needs a pass to visualize shader output.
978
+ lights.append(cursor_light())
979
+ else:
980
+ if self._cursor_light_enabled:
981
+ lights.append(cursor_light())
982
+ if self._projectiles:
983
+ for proj in self._projectiles[-self._max_projectile_lights :]:
984
+ lights.append(proj_light(proj))
985
+ if self._fly_lights_enabled and self._fly_lights:
986
+ for fl in self._fly_lights[:12]:
987
+ sx, sy = self._world.world_to_screen(float(fl.x), float(fl.y))
988
+ c = fl.color
989
+ lights.append(
990
+ (
991
+ float(sx),
992
+ float(sy),
993
+ float(fl.range),
994
+ float(fl.source_radius),
995
+ float(c.r) / 255.0,
996
+ float(c.g) / 255.0,
997
+ float(c.b) / 255.0,
998
+ )
999
+ )
1000
+
1001
+ def draw_fullscreen() -> None:
1002
+ if self._solid_white is not None and int(getattr(self._solid_white, "id", 0)) > 0:
1003
+ src = rl.Rectangle(0.0, 0.0, float(self._solid_white.width), float(self._solid_white.height))
1004
+ dst = rl.Rectangle(0.0, 0.0, float(w), float(h))
1005
+ rl.draw_texture_pro(self._solid_white, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
1006
+ else:
1007
+ rl.draw_rectangle(0, 0, int(w), int(h), rl.WHITE)
1008
+
1009
+ for lx, ly, lrange, lsrc, lr, lg, lb in lights:
1010
+ if lx < -lrange or lx > w + lrange or ly < -lrange or ly > h + lrange:
1011
+ continue
1012
+ set_vec4("u_light_color", lr, lg, lb, 1.0)
1013
+ set_vec2("u_light_pos", lx, ly)
1014
+ set_float("u_light_range", lrange)
1015
+ set_float("u_light_source_radius", lsrc)
1016
+ draw_fullscreen()
1017
+ # Make sure each fullscreen pass is flushed before changing light
1018
+ # uniforms (raylib batches draws).
1019
+ rl.rl_draw_render_batch_active()
1020
+
1021
+ rl.end_blend_mode()
1022
+ rl.end_shader_mode()
1023
+ rl.end_texture_mode()
1024
+ return True
1025
+
1026
+ def draw(self) -> None:
1027
+ if self._player is None:
1028
+ rl.clear_background(rl.Color(10, 10, 12, 255))
1029
+ self._draw_ui_text("Lighting debug view: missing player", 16.0, 16.0, UI_ERROR_COLOR)
1030
+ return
1031
+
1032
+ self._ensure_render_targets()
1033
+ if self._light_rt is None:
1034
+ rl.clear_background(rl.Color(10, 10, 12, 255))
1035
+ self._draw_ui_text("Lighting debug view: missing render targets", 16.0, 16.0, UI_ERROR_COLOR)
1036
+ return
1037
+
1038
+ light_x = float(self._ui_mouse_x)
1039
+ light_y = float(self._ui_mouse_y)
1040
+ sdf_ok = self._render_lightmap_sdf(light_x=light_x, light_y=light_y)
1041
+ if not sdf_ok:
1042
+ rl.begin_texture_mode(self._light_rt)
1043
+ rl.clear_background(self._ambient)
1044
+ rl.end_texture_mode()
1045
+
1046
+ # Draw the world, then multiply by the lightmap.
1047
+ rl.clear_background(rl.BLACK)
1048
+ self._world.draw(draw_aim_indicators=False, entity_alpha=1.0)
1049
+
1050
+ src_light = rl.Rectangle(0.0, 0.0, float(self._light_rt.texture.width), -float(self._light_rt.texture.height))
1051
+ dst_light = rl.Rectangle(0.0, 0.0, float(rl.get_screen_width()), float(rl.get_screen_height()))
1052
+ rl.begin_blend_mode(rl.BLEND_MULTIPLIED)
1053
+ rl.draw_texture_pro(self._light_rt.texture, src_light, dst_light, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
1054
+ rl.end_blend_mode()
1055
+
1056
+ if self._projectiles:
1057
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1058
+ for proj in self._projectiles:
1059
+ sx, sy = self._world.world_to_screen(float(proj.x), float(proj.y))
1060
+ fade = _clamp(1.0 - float(proj.age) / max(0.001, float(proj.ttl)), 0.0, 1.0)
1061
+ c = self._proj_light_tint
1062
+ rl.draw_circle(
1063
+ int(sx),
1064
+ int(sy),
1065
+ float(self._proj_radius_px),
1066
+ rl.Color(int(c.r), int(c.g), int(c.b), int(220.0 * fade)),
1067
+ )
1068
+ rl.end_blend_mode()
1069
+
1070
+ if self._fly_lights_enabled and self._fly_lights:
1071
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1072
+ for fl in self._fly_lights:
1073
+ sx, sy = self._world.world_to_screen(float(fl.x), float(fl.y))
1074
+ c = fl.color
1075
+ rl.draw_circle(
1076
+ int(sx),
1077
+ int(sy),
1078
+ 4.0,
1079
+ rl.Color(int(c.r), int(c.g), int(c.b), 220),
1080
+ )
1081
+ rl.end_blend_mode()
1082
+
1083
+ if self._debug_lightmap_preview:
1084
+ screen_w = float(rl.get_screen_width())
1085
+ scale = 0.25
1086
+ pad = 16.0
1087
+ preview_w = float(self._light_rt.texture.width) * scale
1088
+ preview_h = float(self._light_rt.texture.height) * scale
1089
+ dst_preview = rl.Rectangle(screen_w - preview_w - pad, pad, preview_w, preview_h)
1090
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
1091
+ rl.draw_texture_pro(
1092
+ self._light_rt.texture,
1093
+ src_light,
1094
+ dst_preview,
1095
+ rl.Vector2(0.0, 0.0),
1096
+ 0.0,
1097
+ rl.WHITE,
1098
+ )
1099
+ rl.end_blend_mode()
1100
+ rl.draw_rectangle_lines(int(dst_preview.x), int(dst_preview.y), int(dst_preview.width), int(dst_preview.height), rl.Color(255, 255, 255, 120))
1101
+
1102
+ _cam_x, _cam_y, scale_x, scale_y = self._world.renderer._world_params()
1103
+ scale = (scale_x + scale_y) * 0.5
1104
+
1105
+ if self._draw_occluders:
1106
+ px, py = self._world.world_to_screen(float(self._player.pos_x), float(self._player.pos_y))
1107
+ rl.draw_circle_lines(
1108
+ int(px),
1109
+ int(py),
1110
+ int(max(1.0, float(self._player.size) * 0.5 * scale * float(self._occluder_radius_mul) + float(self._occluder_radius_pad_px))),
1111
+ rl.Color(80, 220, 120, 180),
1112
+ )
1113
+ for creature in self._world.creatures.entries:
1114
+ if not creature.active:
1115
+ continue
1116
+ sx, sy = self._world.world_to_screen(float(creature.x), float(creature.y))
1117
+ r = float(creature.size) * 0.5 * scale * float(self._occluder_radius_mul) + float(self._occluder_radius_pad_px)
1118
+ rl.draw_circle_lines(int(sx), int(sy), int(max(1.0, r)), rl.Color(220, 80, 80, 180))
1119
+
1120
+ rl.draw_circle_lines(int(light_x), int(light_y), 6, rl.Color(255, 255, 255, 220))
1121
+ if self._cursor_light_enabled:
1122
+ rl.draw_circle_lines(int(light_x), int(light_y), int(max(1.0, self._light_radius)), rl.Color(255, 255, 255, 40))
1123
+ rl.draw_circle_lines(
1124
+ int(light_x),
1125
+ int(light_y),
1126
+ int(max(1.0, self._light_source_radius)),
1127
+ rl.Color(255, 255, 255, 100),
1128
+ )
1129
+
1130
+ if self._debug_dump_next_frame:
1131
+ self._debug_dump_next_frame = False
1132
+ self._dump_debug(light_x=light_x, light_y=light_y, sdf_ok=sdf_ok)
1133
+ if self._debug_auto_dump:
1134
+ self.close_requested = True
1135
+
1136
+ if self._draw_debug:
1137
+ title = "Lighting debug view (night + SDF shadows)"
1138
+ lines = [
1139
+ title,
1140
+ "WASD move MOUSE light pos",
1141
+ "SPACE simulate R reset",
1142
+ f",/. shadow_k={self._sdf_shadow_k:.1f}",
1143
+ f"F6 sdf_debug={self._sdf_debug_mode} (1 solid, 2 uv, 3 range, 4 atten, 5 shade)",
1144
+ f"+/- shadow_floor={self._sdf_shadow_floor:.2f}",
1145
+ f"[ ] disc_radius={self._light_source_radius:.0f} shift+[ ] light_radius={self._light_radius:.0f}",
1146
+ f"O/P occ_mul={self._occluder_radius_mul:.2f} K/L occ_pad_px={self._occluder_radius_pad_px:.1f} (hold shift for bigger steps)",
1147
+ f"LMB shoot proj={len(self._projectiles)} proj_lights<= {self._max_projectile_lights}",
1148
+ f"3 cursor_light={'on' if self._cursor_light_enabled else 'off'} N/M ambient_mul={self._ambient_mul:.2f} (hold shift for bigger steps)",
1149
+ f"5 fly_lights={'on' if self._fly_lights_enabled else 'off'} count={len(self._fly_lights)}",
1150
+ "1 ui 2 occluders 4 lightmap preview",
1151
+ "F5 dump debug (artifacts/lighting-debug/)",
1152
+ ]
1153
+ if not sdf_ok:
1154
+ lines.append("SDF shader unavailable (ambient-only fallback)")
1155
+ elif self._sdf_shader_missing:
1156
+ lines.append("SDF uniforms missing: " + ", ".join(self._sdf_shader_missing))
1157
+ x0 = 16.0
1158
+ y0 = 16.0
1159
+ lh = float(self._ui_line_height())
1160
+ for idx, line in enumerate(lines):
1161
+ self._draw_ui_text(line, x0, y0 + lh * float(idx), UI_TEXT_COLOR if idx < 5 else UI_HINT_COLOR)
1162
+
1163
+
1164
+ @register_view("lighting-debug", "Lighting (SDF)")
1165
+ def _create_lighting_debug_view(*, ctx: ViewContext) -> LightingDebugView:
1166
+ return LightingDebugView(ctx)