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,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import random
6
+
7
+ from grim.audio import AudioState, init_audio_state
8
+ from grim.config import CrimsonConfig, ensure_crimson_cfg
9
+ from grim.console import ConsoleLog, ConsoleState
10
+
11
+ from ..assets_fetch import download_missing_paqs
12
+ from ..paths import default_runtime_dir
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ViewAudioBootstrap:
17
+ config: CrimsonConfig | None
18
+ console: ConsoleState | None
19
+ audio: AudioState | None
20
+ audio_rng: random.Random | None
21
+
22
+
23
+ def init_view_audio(assets_dir: Path, *, seed: int = 0xBEEF) -> ViewAudioBootstrap:
24
+ runtime_dir = default_runtime_dir()
25
+ runtime_dir.mkdir(parents=True, exist_ok=True)
26
+ try:
27
+ config = ensure_crimson_cfg(runtime_dir)
28
+ except Exception:
29
+ return ViewAudioBootstrap(None, None, None, None)
30
+
31
+ console = ConsoleState(
32
+ base_dir=runtime_dir,
33
+ log=ConsoleLog(base_dir=runtime_dir),
34
+ assets_dir=assets_dir,
35
+ )
36
+ try:
37
+ download_missing_paqs(assets_dir, console)
38
+ except Exception as exc:
39
+ console.log.log(f"assets: download failed: {exc}")
40
+ console.log.flush()
41
+
42
+ try:
43
+ audio = init_audio_state(config, assets_dir, console)
44
+ except Exception:
45
+ return ViewAudioBootstrap(config, console, None, None)
46
+
47
+ return ViewAudioBootstrap(config, console, audio, random.Random(seed))
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pyray as rl
6
+
7
+ from ..bonuses import BONUS_TABLE, BonusMeta
8
+ from ..weapons import WEAPON_TABLE
9
+ from .registry import register_view
10
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
11
+ from grim.view import View, ViewContext
12
+
13
+ UI_TEXT_SCALE = 1.0
14
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
15
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
16
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
17
+ UI_HOVER_COLOR = rl.Color(240, 200, 80, 255)
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class BonusIconGroup:
22
+ icon_id: int
23
+ bonuses: tuple[BonusMeta, ...]
24
+
25
+
26
+ def _build_icon_groups() -> dict[int, BonusIconGroup]:
27
+ grouped: dict[int, list[BonusMeta]] = {}
28
+ for entry in BONUS_TABLE:
29
+ if entry.icon_id is None or entry.icon_id < 0:
30
+ continue
31
+ grouped.setdefault(entry.icon_id, []).append(entry)
32
+ return {icon_id: BonusIconGroup(icon_id=icon_id, bonuses=tuple(entries)) for icon_id, entries in grouped.items()}
33
+
34
+
35
+ BONUS_ICON_GROUPS = _build_icon_groups()
36
+ WEAPON_BONUS = next(
37
+ (entry for entry in BONUS_TABLE if entry.icon_id is not None and entry.icon_id < 0),
38
+ None,
39
+ )
40
+
41
+
42
+ class BonusIconView:
43
+ def __init__(self, ctx: ViewContext) -> None:
44
+ self._assets_root = ctx.assets_dir
45
+ self._missing_assets: list[str] = []
46
+ self._texture: rl.Texture | None = None
47
+ self._small: SmallFontData | None = None
48
+
49
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
50
+ if self._small is not None:
51
+ return int(self._small.cell_size * scale)
52
+ return int(20 * scale)
53
+
54
+ def _draw_ui_text(
55
+ self,
56
+ text: str,
57
+ x: float,
58
+ y: float,
59
+ color: rl.Color,
60
+ scale: float = UI_TEXT_SCALE,
61
+ ) -> None:
62
+ if self._small is not None:
63
+ draw_small_text(self._small, text, x, y, scale, color)
64
+ else:
65
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
66
+
67
+ def open(self) -> None:
68
+ self._missing_assets.clear()
69
+ self._small = load_small_font(self._assets_root, self._missing_assets)
70
+ path = self._assets_root / "crimson" / "game" / "bonuses.png"
71
+ if not path.is_file():
72
+ self._missing_assets.append("game/bonuses.png")
73
+ raise FileNotFoundError(f"Missing asset: {path}")
74
+ self._texture = rl.load_texture(str(path))
75
+
76
+ def close(self) -> None:
77
+ if self._texture is not None:
78
+ rl.unload_texture(self._texture)
79
+ self._texture = None
80
+ if self._small is not None:
81
+ rl.unload_texture(self._small.texture)
82
+ self._small = None
83
+
84
+ def update(self, dt: float) -> None:
85
+ del dt
86
+
87
+ def draw(self) -> None:
88
+ rl.clear_background(rl.Color(12, 12, 14, 255))
89
+ if self._missing_assets:
90
+ message = "Missing assets: " + ", ".join(self._missing_assets)
91
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
92
+ return
93
+ if self._texture is None:
94
+ self._draw_ui_text("No bonuses texture loaded.", 24, 24, UI_TEXT_COLOR)
95
+ return
96
+
97
+ margin = 24
98
+ panel_gap = 32
99
+ panel_width = min(420, int(rl.get_screen_width() * 0.4))
100
+ available_width = rl.get_screen_width() - margin * 2 - panel_gap - panel_width
101
+ available_height = rl.get_screen_height() - margin * 2 - 60
102
+ scale = min(
103
+ 3.0,
104
+ available_width / self._texture.width,
105
+ available_height / self._texture.height,
106
+ )
107
+ draw_w = self._texture.width * scale
108
+ draw_h = self._texture.height * scale
109
+ x = margin
110
+ y = margin + 60
111
+
112
+ src = rl.Rectangle(0.0, 0.0, float(self._texture.width), float(self._texture.height))
113
+ dst = rl.Rectangle(float(x), float(y), float(draw_w), float(draw_h))
114
+ rl.draw_texture_pro(self._texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
115
+
116
+ grid = 4
117
+ cell_w = draw_w / grid
118
+ cell_h = draw_h / grid
119
+ for i in range(1, grid):
120
+ rl.draw_line(
121
+ int(x + i * cell_w),
122
+ int(y),
123
+ int(x + i * cell_w),
124
+ int(y + draw_h),
125
+ rl.Color(60, 60, 70, 255),
126
+ )
127
+ rl.draw_line(
128
+ int(x),
129
+ int(y + i * cell_h),
130
+ int(x + draw_w),
131
+ int(y + i * cell_h),
132
+ rl.Color(60, 60, 70, 255),
133
+ )
134
+
135
+ hovered_index = None
136
+ mouse = rl.get_mouse_position()
137
+ if x <= mouse.x <= x + draw_w and y <= mouse.y <= y + draw_h:
138
+ col = int((mouse.x - x) // cell_w)
139
+ row = int((mouse.y - y) // cell_h)
140
+ if 0 <= col < grid and 0 <= row < grid:
141
+ hovered_index = row * grid + col
142
+ hl = rl.Rectangle(
143
+ float(x + col * cell_w),
144
+ float(y + row * cell_h),
145
+ float(cell_w),
146
+ float(cell_h),
147
+ )
148
+ rl.draw_rectangle_lines_ex(hl, 3, UI_HOVER_COLOR)
149
+
150
+ info_x = x + draw_w + panel_gap
151
+ info_y = margin
152
+ self._draw_ui_text("bonuses.png (grid 4x4)", info_x, info_y, UI_TEXT_COLOR)
153
+ info_y += self._ui_line_height() + 12
154
+
155
+ if hovered_index is not None:
156
+ group = BONUS_ICON_GROUPS.get(hovered_index)
157
+ self._draw_ui_text(f"icon_id {hovered_index}", info_x, info_y, UI_TEXT_COLOR)
158
+ info_y += self._ui_line_height() + 6
159
+ if group is None:
160
+ self._draw_ui_text("no bonus mapping", info_x, info_y, UI_HINT_COLOR)
161
+ info_y += self._ui_line_height() + 6
162
+ else:
163
+ for entry in group.bonuses:
164
+ bonus_id = int(entry.bonus_id)
165
+ amount = entry.default_amount
166
+ amount_label = f" default={amount}" if amount is not None else ""
167
+ self._draw_ui_text(
168
+ f"id {bonus_id:02d} {entry.name}{amount_label}",
169
+ info_x,
170
+ info_y,
171
+ UI_TEXT_COLOR,
172
+ )
173
+ info_y += self._ui_line_height() + 4
174
+ if entry.description:
175
+ self._draw_ui_text(
176
+ entry.description,
177
+ info_x,
178
+ info_y,
179
+ UI_HINT_COLOR,
180
+ )
181
+ info_y += self._ui_line_height() + 4
182
+ info_y += 8
183
+
184
+ if WEAPON_BONUS is not None:
185
+ self._draw_ui_text("Weapon bonus icon", info_x, info_y, UI_TEXT_COLOR)
186
+ info_y += self._ui_line_height() + 4
187
+ weapon_id = WEAPON_BONUS.default_amount
188
+ weapon_name = None
189
+ if weapon_id is not None:
190
+ for weapon in WEAPON_TABLE:
191
+ if weapon.weapon_id == weapon_id:
192
+ weapon_name = weapon.name
193
+ break
194
+ name_label = f" ({weapon_name})" if weapon_name else ""
195
+ weapon_label = f"icon_id = -1 → ui_wicons (default weapon {weapon_id}{name_label})"
196
+ self._draw_ui_text(weapon_label, info_x, info_y, UI_HINT_COLOR)
197
+
198
+
199
+ @register_view("bonuses", "Bonus icon preview")
200
+ def build_bonus_view(ctx: ViewContext) -> View:
201
+ return BonusIconView(ctx)
@@ -0,0 +1,359 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ import time
8
+
9
+ import pyray as rl
10
+
11
+ from grim.assets import resolve_asset_path
12
+ from grim.config import ensure_crimson_cfg
13
+ from grim.terrain_render import GroundRenderer
14
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
15
+ from grim.view import View, ViewContext
16
+
17
+ from ..paths import default_runtime_dir
18
+ from .registry import register_view
19
+
20
+
21
+ WORLD_SIZE = 1024.0
22
+ WINDOW_W = 640
23
+ WINDOW_H = 480
24
+ GRID_STEP = 64.0
25
+ LOG_INTERVAL_S = 0.1
26
+
27
+ UI_TEXT_SCALE = 1.0
28
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
29
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
30
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class CameraDebugAssets:
35
+ base: rl.Texture
36
+ overlay: rl.Texture | None
37
+ detail: rl.Texture | None
38
+
39
+
40
+ class CameraDebugView:
41
+ def __init__(self, ctx: ViewContext) -> None:
42
+ self._assets_root = ctx.assets_dir
43
+ self._missing_assets: list[str] = []
44
+ self._small: SmallFontData | None = None
45
+ self._assets: CameraDebugAssets | None = None
46
+ self._renderer: GroundRenderer | None = None
47
+ self._config_screen_w = float(WINDOW_W)
48
+ self._config_screen_h = float(WINDOW_H)
49
+ self._texture_scale = 1.0
50
+ self._use_config_screen = False
51
+ self._player_x = WORLD_SIZE * 0.5
52
+ self._player_y = WORLD_SIZE * 0.5
53
+ self._camera_x = -1.0
54
+ self._camera_y = -1.0
55
+ self._camera_target_x = -1.0
56
+ self._camera_target_y = -1.0
57
+ self._log_timer = 0.0
58
+ self._log_path: Path | None = None
59
+ self._log_file = None
60
+
61
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
62
+ if self._small is not None:
63
+ return int(self._small.cell_size * scale)
64
+ return int(20 * scale)
65
+
66
+ def _draw_ui_text(
67
+ self,
68
+ text: str,
69
+ x: float,
70
+ y: float,
71
+ color: rl.Color,
72
+ scale: float = UI_TEXT_SCALE,
73
+ ) -> None:
74
+ if self._small is not None:
75
+ draw_small_text(self._small, text, x, y, scale, color)
76
+ else:
77
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
78
+
79
+ def _load_runtime_config(self) -> None:
80
+ runtime_dir = default_runtime_dir()
81
+ if not runtime_dir.is_dir():
82
+ return
83
+ try:
84
+ cfg = ensure_crimson_cfg(runtime_dir)
85
+ except Exception:
86
+ return
87
+ self._config_screen_w = float(cfg.screen_width)
88
+ self._config_screen_h = float(cfg.screen_height)
89
+ self._texture_scale = float(cfg.texture_scale)
90
+
91
+ def _camera_screen_size(self) -> tuple[float, float]:
92
+ if self._use_config_screen:
93
+ screen_w = float(self._config_screen_w)
94
+ screen_h = float(self._config_screen_h)
95
+ else:
96
+ screen_w = float(rl.get_screen_width())
97
+ screen_h = float(rl.get_screen_height())
98
+ if screen_w > WORLD_SIZE:
99
+ screen_w = WORLD_SIZE
100
+ if screen_h > WORLD_SIZE:
101
+ screen_h = WORLD_SIZE
102
+ return screen_w, screen_h
103
+
104
+ def _clamp_camera(self, cam_x: float, cam_y: float, screen_w: float, screen_h: float) -> tuple[float, float]:
105
+ min_x = screen_w - WORLD_SIZE
106
+ min_y = screen_h - WORLD_SIZE
107
+ if cam_x > -1.0:
108
+ cam_x = -1.0
109
+ if cam_y > -1.0:
110
+ cam_y = -1.0
111
+ if cam_x < min_x:
112
+ cam_x = min_x
113
+ if cam_y < min_y:
114
+ cam_y = min_y
115
+ return cam_x, cam_y
116
+
117
+ def _world_params(self) -> tuple[float, float, float, float, float, float]:
118
+ out_w = float(rl.get_screen_width())
119
+ out_h = float(rl.get_screen_height())
120
+ screen_w, screen_h = self._camera_screen_size()
121
+ cam_x, cam_y = self._clamp_camera(self._camera_x, self._camera_y, screen_w, screen_h)
122
+ scale_x = out_w / screen_w if screen_w > 0 else 1.0
123
+ scale_y = out_h / screen_h if screen_h > 0 else 1.0
124
+ return cam_x, cam_y, scale_x, scale_y, screen_w, screen_h
125
+
126
+ def _write_log(self, payload: dict) -> None:
127
+ if self._log_file is None:
128
+ return
129
+ try:
130
+ self._log_file.write(json.dumps(payload, sort_keys=True) + "\n")
131
+ self._log_file.flush()
132
+ except Exception:
133
+ self._log_file = None
134
+
135
+ def open(self) -> None:
136
+ rl.set_window_size(WINDOW_W, WINDOW_H)
137
+ self._missing_assets.clear()
138
+ self._small = load_small_font(self._assets_root, self._missing_assets)
139
+ base_path = resolve_asset_path(self._assets_root, "ter/ter_q1_base.png")
140
+ overlay_path = resolve_asset_path(self._assets_root, "ter/ter_q1_tex1.png")
141
+ if base_path is None:
142
+ self._missing_assets.append("ter/ter_q1_base.png")
143
+ if overlay_path is None:
144
+ self._missing_assets.append("ter/ter_q1_tex1.png")
145
+ if self._missing_assets:
146
+ raise FileNotFoundError("Missing assets: " + ", ".join(self._missing_assets))
147
+ base = rl.load_texture(str(base_path))
148
+ overlay = rl.load_texture(str(overlay_path)) if overlay_path is not None else None
149
+ detail = overlay or base
150
+ self._assets = CameraDebugAssets(base=base, overlay=overlay, detail=detail)
151
+
152
+ self._load_runtime_config()
153
+ self._renderer = GroundRenderer(
154
+ texture=base,
155
+ overlay=overlay,
156
+ overlay_detail=detail,
157
+ width=int(WORLD_SIZE),
158
+ height=int(WORLD_SIZE),
159
+ texture_scale=self._texture_scale,
160
+ screen_width=self._config_screen_w if self._use_config_screen else None,
161
+ screen_height=self._config_screen_h if self._use_config_screen else None,
162
+ )
163
+ self._renderer.schedule_generate(seed=0, layers=3)
164
+
165
+ log_dir = Path("artifacts") / "debug"
166
+ try:
167
+ log_dir.mkdir(parents=True, exist_ok=True)
168
+ except Exception:
169
+ log_dir = Path("artifacts")
170
+ self._log_path = log_dir / "camera_debug.jsonl"
171
+ try:
172
+ self._log_file = self._log_path.open("w", encoding="utf-8")
173
+ except Exception:
174
+ self._log_file = None
175
+
176
+ def close(self) -> None:
177
+ if self._assets is not None:
178
+ rl.unload_texture(self._assets.base)
179
+ if self._assets.overlay is not None:
180
+ rl.unload_texture(self._assets.overlay)
181
+ self._assets = None
182
+ if self._renderer is not None:
183
+ if self._renderer.render_target is not None:
184
+ rl.unload_render_texture(self._renderer.render_target)
185
+ self._renderer = None
186
+ if self._small is not None:
187
+ rl.unload_texture(self._small.texture)
188
+ self._small = None
189
+ if self._log_file is not None:
190
+ try:
191
+ self._log_file.close()
192
+ except Exception:
193
+ pass
194
+ self._log_file = None
195
+
196
+ def update(self, dt: float) -> None:
197
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F1):
198
+ self._use_config_screen = not self._use_config_screen
199
+ speed = 120.0
200
+ if rl.is_key_down(rl.KeyboardKey.KEY_LEFT_SHIFT) or rl.is_key_down(rl.KeyboardKey.KEY_RIGHT_SHIFT):
201
+ speed *= 2.0
202
+ move_x = 0.0
203
+ move_y = 0.0
204
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
205
+ move_x -= 1.0
206
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
207
+ move_x += 1.0
208
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
209
+ move_y -= 1.0
210
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
211
+ move_y += 1.0
212
+ if move_x != 0.0 or move_y != 0.0:
213
+ length = math.hypot(move_x, move_y)
214
+ if length > 0.0:
215
+ move_x /= length
216
+ move_y /= length
217
+ self._player_x += move_x * speed * dt
218
+ self._player_y += move_y * speed * dt
219
+ self._player_x = max(0.0, min(WORLD_SIZE, self._player_x))
220
+ self._player_y = max(0.0, min(WORLD_SIZE, self._player_y))
221
+
222
+ screen_w, screen_h = self._camera_screen_size()
223
+ desired_x = (screen_w * 0.5) - self._player_x
224
+ desired_y = (screen_h * 0.5) - self._player_y
225
+ desired_x, desired_y = self._clamp_camera(desired_x, desired_y, screen_w, screen_h)
226
+ self._camera_target_x = desired_x
227
+ self._camera_target_y = desired_y
228
+
229
+ t = max(0.0, min(dt * 6.0, 1.0))
230
+ self._camera_x = self._camera_x + (desired_x - self._camera_x) * t
231
+ self._camera_y = self._camera_y + (desired_y - self._camera_y) * t
232
+
233
+ if self._renderer is not None:
234
+ self._renderer.texture_scale = self._texture_scale
235
+ if self._use_config_screen:
236
+ self._renderer.screen_width = self._config_screen_w
237
+ self._renderer.screen_height = self._config_screen_h
238
+ else:
239
+ self._renderer.screen_width = None
240
+ self._renderer.screen_height = None
241
+ self._renderer.process_pending()
242
+
243
+ self._log_timer += dt
244
+ if self._log_timer >= LOG_INTERVAL_S:
245
+ self._log_timer -= LOG_INTERVAL_S
246
+ cam_x, cam_y, scale_x, scale_y, screen_w, screen_h = self._world_params()
247
+ payload = {
248
+ "ts": time.time(),
249
+ "dt": dt,
250
+ "player": {"x": self._player_x, "y": self._player_y},
251
+ "camera": {"x": cam_x, "y": cam_y},
252
+ "camera_raw": {"x": self._camera_x, "y": self._camera_y},
253
+ "camera_target": {"x": self._camera_target_x, "y": self._camera_target_y},
254
+ "world": {"size": WORLD_SIZE},
255
+ "screen": {
256
+ "window": {"w": rl.get_screen_width(), "h": rl.get_screen_height()},
257
+ "camera": {"w": screen_w, "h": screen_h},
258
+ "config": {"w": self._config_screen_w, "h": self._config_screen_h},
259
+ "use_config": self._use_config_screen,
260
+ },
261
+ "texture_scale": self._texture_scale,
262
+ "scale": {"x": scale_x, "y": scale_y},
263
+ "uv": {
264
+ "u0": -cam_x / WORLD_SIZE,
265
+ "v0": -cam_y / WORLD_SIZE,
266
+ "u1": (-cam_x + screen_w) / WORLD_SIZE,
267
+ "v1": (-cam_y + screen_h) / WORLD_SIZE,
268
+ },
269
+ }
270
+ if self._log_path is not None:
271
+ payload["log_path"] = str(self._log_path)
272
+ self._write_log(payload)
273
+
274
+ def draw(self) -> None:
275
+ clear_color = rl.Color(10, 10, 12, 255)
276
+ rl.clear_background(clear_color)
277
+
278
+ if self._renderer is None:
279
+ self._draw_ui_text("Ground renderer not initialized.", 16, 16, UI_ERROR_COLOR)
280
+ return
281
+
282
+ cam_x, cam_y, scale_x, scale_y, screen_w, screen_h = self._world_params()
283
+ self._renderer.draw(cam_x, cam_y, screen_w=screen_w, screen_h=screen_h)
284
+
285
+ # Grid in world space
286
+ grid_major = rl.Color(70, 80, 95, 180)
287
+ grid_minor = rl.Color(40, 50, 65, 140)
288
+ for i in range(0, int(WORLD_SIZE) + 1, int(GRID_STEP)):
289
+ color = grid_major if i % 256 == 0 else grid_minor
290
+ sx = (float(i) + cam_x) * scale_x
291
+ sy0 = (0.0 + cam_y) * scale_y
292
+ sy1 = (WORLD_SIZE + cam_y) * scale_y
293
+ rl.draw_line(int(sx), int(sy0), int(sx), int(sy1), color)
294
+ sy = (float(i) + cam_y) * scale_y
295
+ sx0 = (0.0 + cam_x) * scale_x
296
+ sx1 = (WORLD_SIZE + cam_x) * scale_x
297
+ rl.draw_line(int(sx0), int(sy), int(sx1), int(sy), color)
298
+
299
+ # Player
300
+ px = (self._player_x + cam_x) * scale_x
301
+ py = (self._player_y + cam_y) * scale_y
302
+ rl.draw_circle(int(px), int(py), max(2, int(6 * (scale_x + scale_y) * 0.5)), rl.Color(255, 200, 120, 255))
303
+
304
+ # Minimap
305
+ out_w = float(rl.get_screen_width())
306
+ map_size = 160.0
307
+ margin = 12.0
308
+ map_x = out_w - map_size - margin
309
+ map_y = margin
310
+ rl.draw_rectangle(int(map_x), int(map_y), int(map_size), int(map_size), rl.Color(12, 12, 18, 220))
311
+ rl.draw_rectangle_lines(int(map_x), int(map_y), int(map_size), int(map_size), rl.Color(180, 180, 200, 220))
312
+
313
+ map_scale = map_size / WORLD_SIZE
314
+ view_left = -cam_x
315
+ view_top = -cam_y
316
+ view_w = screen_w
317
+ view_h = screen_h
318
+ vx = map_x + view_left * map_scale
319
+ vy = map_y + view_top * map_scale
320
+ vw = view_w * map_scale
321
+ vh = view_h * map_scale
322
+ rl.draw_rectangle_lines(int(vx), int(vy), int(vw), int(vh), rl.Color(120, 200, 255, 220))
323
+ mx = map_x + self._player_x * map_scale
324
+ my = map_y + self._player_y * map_scale
325
+ rl.draw_circle(int(mx), int(my), 3, rl.Color(255, 200, 120, 255))
326
+
327
+ # HUD
328
+ x = 16.0
329
+ y = 16.0
330
+ line = self._ui_line_height()
331
+ mode = "config" if self._use_config_screen else "window"
332
+ self._draw_ui_text(
333
+ f"window={int(out_w)}x{int(rl.get_screen_height())} camera={int(screen_w)}x{int(screen_h)} ({mode})",
334
+ x,
335
+ y,
336
+ UI_TEXT_COLOR,
337
+ )
338
+ y += line
339
+ self._draw_ui_text(
340
+ f"config={int(self._config_screen_w)}x{int(self._config_screen_h)} "
341
+ f"scale={scale_x:.3f},{scale_y:.3f} tex={self._texture_scale:.2f}",
342
+ x,
343
+ y,
344
+ UI_TEXT_COLOR,
345
+ )
346
+ y += line
347
+ self._draw_ui_text(f"player={self._player_x:.1f},{self._player_y:.1f}", x, y, UI_TEXT_COLOR)
348
+ y += line
349
+ self._draw_ui_text(f"camera={cam_x:.1f},{cam_y:.1f}", x, y, UI_TEXT_COLOR)
350
+ y += line
351
+ if self._log_path is not None:
352
+ self._draw_ui_text(f"log: {self._log_path}", x, y, UI_HINT_COLOR, scale=0.9)
353
+ y += line
354
+ self._draw_ui_text("F1: toggle camera size (config/window)", x, y, UI_HINT_COLOR)
355
+
356
+
357
+ @register_view("camera-debug", "Camera debug")
358
+ def build_camera_debug_view(*, ctx: ViewContext) -> View:
359
+ return CameraDebugView(ctx)