crimsonland 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +153 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +377 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +663 -0
  34. crimson/gameplay.py +2450 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1039 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1338 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +56 -0
  67. crimson/sim/world_state.py +421 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +414 -0
  86. crimson/views/bonuses.py +201 -0
  87. crimson/views/camera_debug.py +359 -0
  88. crimson/views/camera_shake.py +229 -0
  89. crimson/views/corpse_stamp_debug.py +324 -0
  90. crimson/views/decals_debug.py +739 -0
  91. crimson/views/empty.py +19 -0
  92. crimson/views/fonts.py +114 -0
  93. crimson/views/game_over.py +117 -0
  94. crimson/views/ground.py +259 -0
  95. crimson/views/lighting_debug.py +1166 -0
  96. crimson/views/particles.py +293 -0
  97. crimson/views/perk_menu_debug.py +430 -0
  98. crimson/views/perks.py +398 -0
  99. crimson/views/player.py +433 -0
  100. crimson/views/player_sprite_debug.py +314 -0
  101. crimson/views/projectile_fx.py +608 -0
  102. crimson/views/projectile_render_debug.py +407 -0
  103. crimson/views/projectiles.py +221 -0
  104. crimson/views/quest_title_overlay.py +108 -0
  105. crimson/views/registry.py +34 -0
  106. crimson/views/rush.py +16 -0
  107. crimson/views/small_font_debug.py +204 -0
  108. crimson/views/spawn_plan.py +363 -0
  109. crimson/views/sprites.py +214 -0
  110. crimson/views/survival.py +15 -0
  111. crimson/views/terrain.py +132 -0
  112. crimson/views/ui.py +123 -0
  113. crimson/views/wicons.py +166 -0
  114. crimson/weapon_sfx.py +63 -0
  115. crimson/weapons.py +860 -0
  116. crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
  117. crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
  118. crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
  119. crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
  120. grim/__init__.py +20 -0
  121. grim/app.py +92 -0
  122. grim/assets.py +231 -0
  123. grim/audio.py +106 -0
  124. grim/config.py +294 -0
  125. grim/console.py +737 -0
  126. grim/fonts/__init__.py +7 -0
  127. grim/fonts/grim_mono.py +111 -0
  128. grim/fonts/small.py +120 -0
  129. grim/input.py +44 -0
  130. grim/jaz.py +103 -0
  131. grim/math.py +17 -0
  132. grim/music.py +403 -0
  133. grim/paq.py +76 -0
  134. grim/rand.py +37 -0
  135. grim/sfx.py +276 -0
  136. grim/sfx_map.py +103 -0
  137. grim/terrain_render.py +840 -0
  138. grim/view.py +16 -0
crimson/views/empty.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import pyray as rl
4
+
5
+ from .registry import register_view
6
+ from grim.view import View
7
+
8
+
9
+ class EmptyView:
10
+ def update(self, dt: float) -> None:
11
+ del dt
12
+
13
+ def draw(self) -> None:
14
+ rl.clear_background(rl.BLACK)
15
+
16
+
17
+ @register_view("empty", "Empty window")
18
+ def build_empty_view() -> View:
19
+ return EmptyView()
crimson/views/fonts.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import pyray as rl
4
+ from .quest_title_overlay import (
5
+ draw_quest_title_overlay,
6
+ quest_title_base_scale,
7
+ )
8
+ from .registry import register_view
9
+ from grim.fonts.grim_mono import (
10
+ GrimMonoFont,
11
+ draw_grim_mono_text,
12
+ load_grim_mono_font,
13
+ measure_grim_mono_text_height,
14
+ )
15
+ from grim.fonts.small import (
16
+ SmallFontData,
17
+ draw_small_text,
18
+ load_small_font,
19
+ measure_small_text_height,
20
+ )
21
+ from grim.view import View, ViewContext
22
+
23
+ DEFAULT_SAMPLE = """CRIMSONLAND
24
+ The quick brown fox jumps over the lazy dog.
25
+ 0123456789 !@#$%^&*()[]{}<>?/\\"""
26
+
27
+ SMALL_SAMPLE_SCALE = 1.0
28
+ UI_TEXT_SCALE = 1.0
29
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
30
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
31
+
32
+ GRIM_MONO_FILTER_NAME = "Bilinear"
33
+ GRIM_MONO_FILTER_VALUE = rl.TEXTURE_FILTER_BILINEAR
34
+
35
+
36
+ class FontView:
37
+ def __init__(self, ctx: ViewContext) -> None:
38
+ self._assets_root = ctx.assets_dir
39
+ self._missing_assets: list[str] = []
40
+ self._small: SmallFontData | None = None
41
+ self._grim_mono: GrimMonoFont | None = None
42
+ self._sample = DEFAULT_SAMPLE
43
+
44
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
45
+ if self._small is not None:
46
+ return int(self._small.cell_size * scale)
47
+ return int(20 * scale)
48
+
49
+ def _draw_ui_text(
50
+ self,
51
+ text: str,
52
+ x: float,
53
+ y: float,
54
+ color: rl.Color,
55
+ scale: float = UI_TEXT_SCALE,
56
+ ) -> None:
57
+ if self._small is not None:
58
+ draw_small_text(self._small, text, x, y, scale, color)
59
+ else:
60
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
61
+
62
+ def close(self) -> None:
63
+ if self._small is not None:
64
+ rl.unload_texture(self._small.texture)
65
+ if self._grim_mono is not None:
66
+ rl.unload_texture(self._grim_mono.texture)
67
+
68
+ def open(self) -> None:
69
+ self._missing_assets.clear()
70
+ self._small = load_small_font(self._assets_root, self._missing_assets)
71
+ self._grim_mono = load_grim_mono_font(self._assets_root, self._missing_assets)
72
+ self._apply_grim_filter()
73
+
74
+ def update(self, dt: float) -> None:
75
+ del dt
76
+
77
+ def _apply_grim_filter(self) -> None:
78
+ if self._grim_mono is None:
79
+ return
80
+ rl.set_texture_filter(self._grim_mono.texture, GRIM_MONO_FILTER_VALUE)
81
+
82
+ def draw(self) -> None:
83
+ rl.clear_background(rl.Color(12, 12, 14, 255))
84
+ if self._missing_assets:
85
+ message = "Missing assets: " + ", ".join(self._missing_assets)
86
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
87
+ return
88
+ y = 24
89
+ self._draw_ui_text("Small font", 24, y, UI_TEXT_COLOR)
90
+ y += self._ui_line_height() + 12
91
+ if self._small is not None:
92
+ draw_small_text(self._small, self._sample, 24, y, SMALL_SAMPLE_SCALE, rl.WHITE)
93
+ y += int(measure_small_text_height(self._small, self._sample, SMALL_SAMPLE_SCALE)) + 40
94
+
95
+ self._draw_ui_text("Grim2D mono font", 24, y, UI_TEXT_COLOR)
96
+ y += self._ui_line_height() + 12
97
+ if self._grim_mono is not None:
98
+ self._draw_ui_text(f"Filter: {GRIM_MONO_FILTER_NAME}", 24, y, UI_TEXT_COLOR)
99
+ y += self._ui_line_height(0.9) + 6
100
+ mono_scale = quest_title_base_scale(rl.get_screen_width())
101
+ draw_grim_mono_text(self._grim_mono, self._sample, 24, y, mono_scale, rl.WHITE)
102
+ y += int(measure_grim_mono_text_height(self._grim_mono, self._sample, mono_scale)) + 20
103
+
104
+ self._draw_quest_title_overlay()
105
+
106
+ def _draw_quest_title_overlay(self) -> None:
107
+ if self._grim_mono is None:
108
+ return
109
+ draw_quest_title_overlay(self._grim_mono, "Land Hostile", "1.10")
110
+
111
+
112
+ @register_view("fonts", "Font preview")
113
+ def build_font_view(ctx: ViewContext) -> View:
114
+ return FontView(ctx)
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pyray as rl
6
+
7
+ from grim.config import CrimsonConfig, default_crimson_cfg_data
8
+ from grim.view import ViewContext
9
+
10
+ from ..persistence.highscores import HighScoreRecord, scores_path_for_config, write_highscore_records
11
+ from ..ui.game_over import GameOverUi
12
+ from ..ui.hud import HudAssets, load_hud_assets
13
+ from .registry import register_view
14
+
15
+
16
+ _BASE_DIR = Path("artifacts") / "game_over_debug"
17
+
18
+
19
+ def _config_player_name_bytes(name: str) -> bytes:
20
+ raw = name.encode("latin-1", errors="ignore")[: 0x20 - 1]
21
+ return raw + b"\x00" * (0x20 - len(raw))
22
+
23
+
24
+ def _seed_highscores(config: CrimsonConfig) -> None:
25
+ base_dir = _BASE_DIR
26
+ path = scores_path_for_config(base_dir, config)
27
+ records: list[HighScoreRecord] = []
28
+ for idx in range(100):
29
+ record = HighScoreRecord.blank()
30
+ record.game_mode_id = int(config.data.get("game_mode", 1))
31
+ record.set_name(f"bot{idx:03d}")
32
+ record.score_xp = 10_000 - idx
33
+ record.survival_elapsed_ms = (idx + 1) * 1000
34
+ record.creature_kill_count = 500 - idx
35
+ record.most_used_weapon_id = 1
36
+ record.shots_fired = 100
37
+ record.shots_hit = 42
38
+ records.append(record)
39
+ write_highscore_records(path, records)
40
+
41
+
42
+ class GameOverDebugView:
43
+ def __init__(self, ctx: ViewContext) -> None:
44
+ self._assets_root = ctx.assets_dir
45
+ data = default_crimson_cfg_data()
46
+ data["game_mode"] = 1
47
+ data["player_name"] = _config_player_name_bytes("debugger")
48
+ self._config = CrimsonConfig(path=_BASE_DIR / "crimson.cfg", data=data)
49
+ self._hud: HudAssets | None = None
50
+
51
+ self._ui = GameOverUi(assets_root=self._assets_root, base_dir=_BASE_DIR, config=self._config)
52
+ self._record = HighScoreRecord.blank()
53
+ self._banner = "reaper"
54
+ self._qualifies = True
55
+
56
+ self.close_requested = False
57
+
58
+ def open(self) -> None:
59
+ self.close_requested = False
60
+ rl.hide_cursor()
61
+ self._hud = load_hud_assets(self._assets_root)
62
+ _seed_highscores(self._config)
63
+ self._reset_record()
64
+ self._ui.open()
65
+
66
+ def close(self) -> None:
67
+ rl.show_cursor()
68
+ self._ui.close()
69
+ if self._hud is not None:
70
+ self._hud = None
71
+
72
+ def _reset_record(self) -> None:
73
+ record = HighScoreRecord.blank()
74
+ record.game_mode_id = int(self._config.data.get("game_mode", 1))
75
+ record.score_xp = 20_000 if self._qualifies else 1
76
+ record.survival_elapsed_ms = 123_456
77
+ record.creature_kill_count = 123
78
+ record.most_used_weapon_id = 1
79
+ record.shots_fired = 120
80
+ record.shots_hit = 37
81
+ self._record = record
82
+
83
+ def update(self, dt: float) -> None:
84
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
85
+ self.close_requested = True
86
+ return
87
+
88
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_R):
89
+ _seed_highscores(self._config)
90
+ self._reset_record()
91
+ self._ui.open()
92
+
93
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F1):
94
+ self._qualifies = not self._qualifies
95
+ self._reset_record()
96
+ self._ui.open()
97
+
98
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_B):
99
+ self._banner = "well_done" if self._banner == "reaper" else "reaper"
100
+
101
+ action = self._ui.update(dt, record=self._record, player_name_default="debugger")
102
+ if action == "play_again":
103
+ self._reset_record()
104
+ self._ui.open()
105
+ return
106
+ if action in {"main_menu", "high_scores"}:
107
+ self.close_requested = True
108
+
109
+ def draw(self) -> None:
110
+ rl.clear_background(rl.Color(8, 8, 10, 255))
111
+ self._ui.draw(record=self._record, banner_kind=self._banner, hud_assets=self._hud)
112
+ rl.draw_text("F1 toggle qualify | B toggle banner | R reset | ESC close", 18, 18, 18, rl.Color(200, 200, 200, 255))
113
+
114
+
115
+ @register_view("game_over", "Game Over")
116
+ def _create_game_over_view(*, ctx: ViewContext) -> GameOverDebugView:
117
+ return GameOverDebugView(ctx)
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pyray as rl
6
+
7
+ from crimson.creatures.anim import creature_corpse_frame_for_type
8
+ from crimson.effects import FxQueue, FxQueueRotated
9
+ from crimson.render.terrain_fx import FxQueueTextures, bake_fx_queues
10
+ from grim.assets import resolve_asset_path
11
+ from grim.config import ensure_crimson_cfg
12
+ from grim.terrain_render import GroundRenderer
13
+ from ..paths import default_runtime_dir
14
+ from ..quests import all_quests
15
+ from ..quests.types import QuestDefinition
16
+ from .quest_title_overlay import draw_quest_title_overlay
17
+ from .registry import register_view
18
+ from grim.fonts.grim_mono import GrimMonoFont, load_grim_mono_font
19
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
20
+ from grim.view import View, ViewContext
21
+
22
+
23
+ UI_TEXT_SCALE = 1.0
24
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
25
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
26
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class GroundAssets:
31
+ textures: dict[int, rl.Texture]
32
+
33
+
34
+ TERRAIN_TEXTURES: list[tuple[int, str]] = [
35
+ (0, "ter/ter_q1_base.png"),
36
+ (1, "ter/ter_q1_tex1.png"),
37
+ (2, "ter/ter_q2_base.png"),
38
+ (3, "ter/ter_q2_tex1.png"),
39
+ (4, "ter/ter_q3_base.png"),
40
+ (5, "ter/ter_q3_tex1.png"),
41
+ (6, "ter/ter_q4_base.png"),
42
+ (7, "ter/ter_q4_tex1.png"),
43
+ ]
44
+
45
+
46
+ class GroundView:
47
+ def __init__(self, ctx: ViewContext) -> None:
48
+ self._assets_root = ctx.assets_dir
49
+ self._missing_assets: list[str] = []
50
+ self._small: SmallFontData | None = None
51
+ self._grim_mono: GrimMonoFont | None = None
52
+ self._assets: GroundAssets | None = None
53
+ self._renderer: GroundRenderer | None = None
54
+ self._camera_x = 0.0
55
+ self._camera_y = 0.0
56
+ self._quests: list[QuestDefinition] = []
57
+ self._quest_index = 0
58
+ self._terrain_seed: int | None = None
59
+ self._fx_queue = FxQueue()
60
+ self._fx_queue_rotated = FxQueueRotated()
61
+ self._fx_textures: FxQueueTextures | None = None
62
+
63
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
64
+ if self._small is not None:
65
+ return int(self._small.cell_size * scale)
66
+ return int(20 * scale)
67
+
68
+ def _draw_ui_text(
69
+ self,
70
+ text: str,
71
+ x: float,
72
+ y: float,
73
+ color: rl.Color,
74
+ scale: float = UI_TEXT_SCALE,
75
+ ) -> None:
76
+ if self._small is not None:
77
+ draw_small_text(self._small, text, x, y, scale, color)
78
+ else:
79
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
80
+
81
+ def open(self) -> None:
82
+ self._missing_assets.clear()
83
+ self._small = load_small_font(self._assets_root, self._missing_assets)
84
+ self._grim_mono = load_grim_mono_font(self._assets_root, self._missing_assets)
85
+ textures: dict[int, rl.Texture] = {}
86
+ for terrain_id, rel_path in TERRAIN_TEXTURES:
87
+ path = resolve_asset_path(self._assets_root, rel_path)
88
+ if path is None:
89
+ self._missing_assets.append(rel_path)
90
+ continue
91
+ textures[terrain_id] = rl.load_texture(str(path))
92
+ if self._missing_assets:
93
+ raise FileNotFoundError(f"Missing ground assets: {', '.join(self._missing_assets)}")
94
+ self._assets = GroundAssets(textures=textures)
95
+ self._quests = all_quests()
96
+ self._fx_queue.clear()
97
+ self._fx_queue_rotated.clear()
98
+ particles_path = resolve_asset_path(self._assets_root, "game/particles.png")
99
+ if particles_path is None:
100
+ self._missing_assets.append("game/particles.png")
101
+ bodyset_path = resolve_asset_path(self._assets_root, "game/bodyset.png")
102
+ if bodyset_path is None:
103
+ self._missing_assets.append("game/bodyset.png")
104
+ if self._missing_assets:
105
+ raise FileNotFoundError(f"Missing ground assets: {', '.join(self._missing_assets)}")
106
+ self._fx_textures = FxQueueTextures(
107
+ particles=rl.load_texture(str(particles_path)),
108
+ bodyset=rl.load_texture(str(bodyset_path)),
109
+ )
110
+ texture_scale, screen_w, screen_h = self._load_runtime_config()
111
+ if self._renderer is not None:
112
+ self._renderer.texture_scale = texture_scale
113
+ self._renderer.screen_width = screen_w
114
+ self._renderer.screen_height = screen_h
115
+ self._quest_index = 0
116
+ self._apply_quest()
117
+
118
+ def close(self) -> None:
119
+ if self._assets is not None:
120
+ for texture in self._assets.textures.values():
121
+ rl.unload_texture(texture)
122
+ self._assets = None
123
+ if self._renderer is not None and self._renderer.render_target is not None:
124
+ rl.unload_render_texture(self._renderer.render_target)
125
+ if self._small is not None:
126
+ rl.unload_texture(self._small.texture)
127
+ self._small = None
128
+ if self._grim_mono is not None:
129
+ rl.unload_texture(self._grim_mono.texture)
130
+ self._grim_mono = None
131
+ if self._fx_textures is not None:
132
+ rl.unload_texture(self._fx_textures.particles)
133
+ rl.unload_texture(self._fx_textures.bodyset)
134
+ self._fx_textures = None
135
+ self._fx_queue.clear()
136
+ self._fx_queue_rotated.clear()
137
+
138
+ def update(self, dt: float) -> None:
139
+ speed = 240.0
140
+ if rl.is_key_down(rl.KeyboardKey.KEY_A):
141
+ self._camera_x += speed * dt
142
+ if rl.is_key_down(rl.KeyboardKey.KEY_D):
143
+ self._camera_x -= speed * dt
144
+ if rl.is_key_down(rl.KeyboardKey.KEY_W):
145
+ self._camera_y += speed * dt
146
+ if rl.is_key_down(rl.KeyboardKey.KEY_S):
147
+ self._camera_y -= speed * dt
148
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
149
+ self._quest_index = (self._quest_index - 1) % max(1, len(self._quests))
150
+ self._apply_quest()
151
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
152
+ self._quest_index = (self._quest_index + 1) % max(1, len(self._quests))
153
+ self._apply_quest()
154
+ if self._renderer is not None:
155
+ self._renderer.process_pending()
156
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
157
+ mouse = rl.get_mouse_position()
158
+ world_x = -self._camera_x + float(mouse.x)
159
+ world_y = -self._camera_y + float(mouse.y)
160
+ self._fx_queue.add(
161
+ effect_id=0x07, # blood
162
+ pos_x=world_x,
163
+ pos_y=world_y,
164
+ width=30.0,
165
+ height=30.0,
166
+ rotation=0.0,
167
+ rgba=(1.0, 1.0, 1.0, 1.0),
168
+ )
169
+ if self._fx_textures is not None and (self._fx_queue.count or self._fx_queue_rotated.count):
170
+ bake_fx_queues(
171
+ self._renderer,
172
+ fx_queue=self._fx_queue,
173
+ fx_queue_rotated=self._fx_queue_rotated,
174
+ textures=self._fx_textures,
175
+ corpse_frame_for_type=creature_corpse_frame_for_type,
176
+ )
177
+
178
+ def draw(self) -> None:
179
+ rl.clear_background(rl.Color(12, 12, 14, 255))
180
+ if self._missing_assets:
181
+ message = "Missing assets: " + ", ".join(self._missing_assets)
182
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
183
+ return
184
+ if self._renderer is None:
185
+ self._draw_ui_text("Ground renderer not initialized.", 24, 24, UI_ERROR_COLOR)
186
+ return
187
+ self._renderer.draw(self._camera_x, self._camera_y)
188
+ self._draw_quest_title_overlay()
189
+
190
+ def _load_runtime_config(self) -> tuple[float, float | None, float | None]:
191
+ runtime_dir = default_runtime_dir()
192
+ if runtime_dir.is_dir():
193
+ try:
194
+ cfg = ensure_crimson_cfg(runtime_dir)
195
+ return (
196
+ cfg.texture_scale,
197
+ float(cfg.screen_width),
198
+ float(cfg.screen_height),
199
+ )
200
+ except Exception:
201
+ return 1.0, None, None
202
+ return 1.0, None, None
203
+
204
+ def _apply_quest(self) -> None:
205
+ if not self._quests or self._assets is None:
206
+ return
207
+ quest = self._quests[self._quest_index]
208
+ base_id, overlay_id, detail_id = quest.terrain_ids or (0, 1, 0)
209
+ textures = self._assets.textures
210
+ base = textures.get(base_id)
211
+ if base is None:
212
+ return
213
+ overlay = textures.get(overlay_id)
214
+ detail = textures.get(detail_id)
215
+ if self._renderer is None:
216
+ texture_scale, screen_w, screen_h = self._load_runtime_config()
217
+ self._renderer = GroundRenderer(
218
+ texture=base,
219
+ overlay=overlay,
220
+ overlay_detail=detail,
221
+ width=1024,
222
+ height=1024,
223
+ texture_scale=texture_scale,
224
+ screen_width=screen_w,
225
+ screen_height=screen_h,
226
+ )
227
+ else:
228
+ self._renderer.texture = base
229
+ self._renderer.overlay = overlay
230
+ self._renderer.overlay_detail = detail
231
+ self._terrain_seed = self._quest_seed(quest.level)
232
+ self._regenerate_terrain(reset_camera=True)
233
+
234
+ def _regenerate_terrain(self, *, reset_camera: bool = False) -> None:
235
+ renderer = self._renderer
236
+ if renderer is None:
237
+ return
238
+ renderer.schedule_generate(seed=self._terrain_seed, layers=3)
239
+ if reset_camera:
240
+ self._camera_x = 0.0
241
+ self._camera_y = 0.0
242
+
243
+ def _quest_seed(self, level: str) -> int:
244
+ tier_text, quest_text = level.split(".", 1)
245
+ try:
246
+ return int(tier_text) * 100 + int(quest_text)
247
+ except ValueError:
248
+ return sum(ord(ch) for ch in level)
249
+
250
+ def _draw_quest_title_overlay(self) -> None:
251
+ if self._grim_mono is None or not self._quests:
252
+ return
253
+ quest = self._quests[self._quest_index]
254
+ draw_quest_title_overlay(self._grim_mono, quest.title, quest.level)
255
+
256
+
257
+ @register_view("ground", "Ground texture")
258
+ def build_ground_view(ctx: ViewContext) -> View:
259
+ return GroundView(ctx)