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,132 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pyray as rl
6
+
7
+ from .registry import register_view
8
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
9
+ from grim.view import View, ViewContext
10
+
11
+ UI_TEXT_SCALE = 1.0
12
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
13
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class TerrainTexture:
18
+ terrain_id: int
19
+ name: str
20
+ texture: rl.Texture
21
+
22
+
23
+ TERRAIN_TEXTURES: list[tuple[int, str, str]] = [
24
+ (0, "ter_q1_base", "ter/ter_q1_base.png"),
25
+ (1, "ter_q1_tex1", "ter/ter_q1_tex1.png"),
26
+ (2, "ter_q2_base", "ter/ter_q2_base.png"),
27
+ (3, "ter_q2_tex1", "ter/ter_q2_tex1.png"),
28
+ (4, "ter_q3_base", "ter/ter_q3_base.png"),
29
+ (5, "ter_q3_tex1", "ter/ter_q3_tex1.png"),
30
+ (6, "ter_q4_base", "ter/ter_q4_base.png"),
31
+ (7, "ter_q4_tex1", "ter/ter_q4_tex1.png"),
32
+ (8, "fb_q1", "ter/fb_q1.png"),
33
+ (9, "fb_q2", "ter/fb_q2.png"),
34
+ (10, "fb_q3", "ter/fb_q3.png"),
35
+ (11, "fb_q4", "ter/fb_q4.png"),
36
+ ]
37
+
38
+
39
+ class TerrainView:
40
+ def __init__(self, ctx: ViewContext) -> None:
41
+ self._assets_root = ctx.assets_dir
42
+ self._missing_assets: list[str] = []
43
+ self._textures: list[TerrainTexture] = []
44
+ self._small: SmallFontData | None = None
45
+
46
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
47
+ if self._small is not None:
48
+ return int(self._small.cell_size * scale)
49
+ return int(20 * scale)
50
+
51
+ def _draw_ui_text(
52
+ self,
53
+ text: str,
54
+ x: float,
55
+ y: float,
56
+ color: rl.Color,
57
+ scale: float = UI_TEXT_SCALE,
58
+ ) -> None:
59
+ if self._small is not None:
60
+ draw_small_text(self._small, text, x, y, scale, color)
61
+ else:
62
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
63
+
64
+ def open(self) -> None:
65
+ self._missing_assets.clear()
66
+ self._textures.clear()
67
+ self._small = load_small_font(self._assets_root, self._missing_assets)
68
+ for terrain_id, name, rel_path in TERRAIN_TEXTURES:
69
+ path = self._assets_root / "crimson" / rel_path
70
+ if not path.is_file():
71
+ self._missing_assets.append(rel_path)
72
+ continue
73
+ texture = rl.load_texture(str(path))
74
+ self._textures.append(TerrainTexture(terrain_id=terrain_id, name=name, texture=texture))
75
+ if self._missing_assets:
76
+ raise FileNotFoundError(f"Missing terrain assets: {', '.join(self._missing_assets)}")
77
+
78
+ def close(self) -> None:
79
+ for entry in self._textures:
80
+ rl.unload_texture(entry.texture)
81
+ self._textures.clear()
82
+ if self._small is not None:
83
+ rl.unload_texture(self._small.texture)
84
+ self._small = None
85
+
86
+ def update(self, dt: float) -> None:
87
+ del dt
88
+
89
+ def draw(self) -> None:
90
+ rl.clear_background(rl.Color(12, 12, 14, 255))
91
+ if self._missing_assets:
92
+ message = "Missing assets: " + ", ".join(self._missing_assets)
93
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
94
+ return
95
+ if not self._textures:
96
+ self._draw_ui_text("No terrain textures loaded.", 24, 24, UI_TEXT_COLOR)
97
+ return
98
+
99
+ cols = 4
100
+ rows = (len(self._textures) + cols - 1) // cols
101
+ margin = 24
102
+ gap_x = 16
103
+ gap_y = 20
104
+ label_height = self._ui_line_height()
105
+
106
+ cell_w = max(entry.texture.width for entry in self._textures)
107
+ cell_h = max(entry.texture.height for entry in self._textures)
108
+ max_width = rl.get_screen_width() - margin * 2 - gap_x * (cols - 1)
109
+ max_height = rl.get_screen_height() - margin * 2 - gap_y * (rows - 1)
110
+ max_height -= rows * label_height
111
+ scale = min(1.0, max_width / (cols * cell_w), max_height / (rows * cell_h))
112
+
113
+ for idx, entry in enumerate(self._textures):
114
+ row = idx // cols
115
+ col = idx % cols
116
+ x = margin + col * (cell_w * scale + gap_x)
117
+ y = margin + row * (cell_h * scale + gap_y + label_height)
118
+ label = f"{entry.terrain_id:02d} {entry.name}"
119
+ self._draw_ui_text(label, x, y, UI_TEXT_COLOR)
120
+ dst = rl.Rectangle(
121
+ float(x),
122
+ float(y + label_height),
123
+ float(entry.texture.width * scale),
124
+ float(entry.texture.height * scale),
125
+ )
126
+ src = rl.Rectangle(0.0, 0.0, float(entry.texture.width), float(entry.texture.height))
127
+ rl.draw_texture_pro(entry.texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
128
+
129
+
130
+ @register_view("terrain", "Terrain textures")
131
+ def build_terrain_view(ctx: ViewContext) -> View:
132
+ return TerrainView(ctx)
crimson/views/ui.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pyray as rl
6
+
7
+ from .registry import register_view
8
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
9
+ from grim.view import View, ViewContext
10
+
11
+ UI_TEXT_SCALE = 1.0
12
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
13
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
14
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class UiTexture:
19
+ name: str
20
+ texture: rl.Texture
21
+
22
+
23
+ class UiTextureView:
24
+ def __init__(self, ctx: ViewContext) -> None:
25
+ self._assets_root = ctx.assets_dir
26
+ self._missing_assets: list[str] = []
27
+ self._textures: list[UiTexture] = []
28
+ self._index = 0
29
+ self._small: SmallFontData | None = None
30
+
31
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
32
+ if self._small is not None:
33
+ return int(self._small.cell_size * scale)
34
+ return int(20 * scale)
35
+
36
+ def _draw_ui_text(
37
+ self,
38
+ text: str,
39
+ x: float,
40
+ y: float,
41
+ color: rl.Color,
42
+ scale: float = UI_TEXT_SCALE,
43
+ ) -> None:
44
+ if self._small is not None:
45
+ draw_small_text(self._small, text, x, y, scale, color)
46
+ else:
47
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
48
+
49
+ def open(self) -> None:
50
+ self._missing_assets.clear()
51
+ self._textures.clear()
52
+ self._small = load_small_font(self._assets_root, self._missing_assets)
53
+ ui_dir = self._assets_root / "crimson" / "ui"
54
+ if not ui_dir.is_dir():
55
+ self._missing_assets.append("ui/")
56
+ raise FileNotFoundError(f"Missing UI assets directory: {ui_dir}")
57
+ for path in sorted(ui_dir.glob("*.png")):
58
+ texture = rl.load_texture(str(path))
59
+ self._textures.append(UiTexture(name=path.name, texture=texture))
60
+
61
+ def close(self) -> None:
62
+ for entry in self._textures:
63
+ rl.unload_texture(entry.texture)
64
+ self._textures.clear()
65
+ if self._small is not None:
66
+ rl.unload_texture(self._small.texture)
67
+ self._small = None
68
+
69
+ def update(self, dt: float) -> None:
70
+ del dt
71
+
72
+ def _advance(self, delta: int) -> None:
73
+ if not self._textures:
74
+ return
75
+ self._index = (self._index + delta) % len(self._textures)
76
+
77
+ def _handle_input(self) -> None:
78
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
79
+ self._advance(1)
80
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
81
+ self._advance(-1)
82
+
83
+ def draw(self) -> None:
84
+ rl.clear_background(rl.Color(12, 12, 14, 255))
85
+ if self._missing_assets:
86
+ message = "Missing assets: " + ", ".join(self._missing_assets)
87
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
88
+ return
89
+ if not self._textures:
90
+ self._draw_ui_text("No UI textures loaded.", 24, 24, UI_TEXT_COLOR)
91
+ return
92
+
93
+ self._handle_input()
94
+ entry = self._textures[self._index]
95
+
96
+ margin = 24
97
+ header_y = margin
98
+ line_height = self._ui_line_height()
99
+ title = f"{self._index + 1}/{len(self._textures)} {entry.name}"
100
+ self._draw_ui_text(title, margin, header_y, UI_TEXT_COLOR)
101
+ header_y += line_height + 6
102
+ self._draw_ui_text("Left/Right: texture", margin, header_y, UI_HINT_COLOR)
103
+
104
+ available_width = rl.get_screen_width() - margin * 2
105
+ available_height = rl.get_screen_height() - (header_y + line_height + margin)
106
+ scale = min(
107
+ 2.0,
108
+ available_width / entry.texture.width,
109
+ available_height / entry.texture.height,
110
+ )
111
+ draw_w = entry.texture.width * scale
112
+ draw_h = entry.texture.height * scale
113
+ x = margin + (available_width - draw_w) / 2
114
+ y = header_y + line_height + (available_height - draw_h) / 2
115
+
116
+ src = rl.Rectangle(0.0, 0.0, float(entry.texture.width), float(entry.texture.height))
117
+ dst = rl.Rectangle(float(x), float(y), float(draw_w), float(draw_h))
118
+ rl.draw_texture_pro(entry.texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
119
+
120
+
121
+ @register_view("ui", "UI texture preview")
122
+ def build_ui_view(ctx: ViewContext) -> View:
123
+ return UiTextureView(ctx)
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import pyray as rl
6
+
7
+ from ..weapons import WEAPON_TABLE, Weapon
8
+ from .registry import register_view
9
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font
10
+ from grim.view import View, ViewContext
11
+
12
+ UI_TEXT_SCALE = 1.0
13
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
14
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
15
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
16
+ UI_HOVER_COLOR = rl.Color(240, 200, 80, 255)
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class WeaponIconGroup:
21
+ icon_index: int
22
+ weapons: tuple[Weapon, ...]
23
+
24
+
25
+ def _build_icon_groups() -> dict[int, WeaponIconGroup]:
26
+ grouped: dict[int, list[Weapon]] = {}
27
+ for entry in WEAPON_TABLE:
28
+ icon_index = entry.icon_index
29
+ if icon_index is None or icon_index < 0 or icon_index > 31:
30
+ continue
31
+ grouped.setdefault(icon_index, []).append(entry)
32
+ return {
33
+ icon_index: WeaponIconGroup(icon_index=icon_index, weapons=tuple(entries))
34
+ for icon_index, entries in grouped.items()
35
+ }
36
+
37
+
38
+ WEAPON_ICON_GROUPS = _build_icon_groups()
39
+
40
+
41
+ class WeaponIconView:
42
+ def __init__(self, ctx: ViewContext) -> None:
43
+ self._assets_root = ctx.assets_dir
44
+ self._missing_assets: list[str] = []
45
+ self._texture: rl.Texture | None = None
46
+ self._small: SmallFontData | None = None
47
+
48
+ def _ui_line_height(self, scale: float = UI_TEXT_SCALE) -> int:
49
+ if self._small is not None:
50
+ return int(self._small.cell_size * scale)
51
+ return int(20 * scale)
52
+
53
+ def _draw_ui_text(
54
+ self,
55
+ text: str,
56
+ x: float,
57
+ y: float,
58
+ color: rl.Color,
59
+ scale: float = UI_TEXT_SCALE,
60
+ ) -> None:
61
+ if self._small is not None:
62
+ draw_small_text(self._small, text, x, y, scale, color)
63
+ else:
64
+ rl.draw_text(text, int(x), int(y), int(20 * scale), color)
65
+
66
+ def open(self) -> None:
67
+ self._missing_assets.clear()
68
+ self._small = load_small_font(self._assets_root, self._missing_assets)
69
+ path = self._assets_root / "crimson" / "ui" / "ui_wicons.png"
70
+ if not path.is_file():
71
+ self._missing_assets.append("ui/ui_wicons.png")
72
+ raise FileNotFoundError(f"Missing asset: {path}")
73
+ self._texture = rl.load_texture(str(path))
74
+
75
+ def close(self) -> None:
76
+ if self._texture is not None:
77
+ rl.unload_texture(self._texture)
78
+ self._texture = None
79
+ if self._small is not None:
80
+ rl.unload_texture(self._small.texture)
81
+ self._small = None
82
+
83
+ def update(self, dt: float) -> None:
84
+ del dt
85
+
86
+ def draw(self) -> None:
87
+ rl.clear_background(rl.Color(12, 12, 14, 255))
88
+ if self._missing_assets:
89
+ message = "Missing assets: " + ", ".join(self._missing_assets)
90
+ self._draw_ui_text(message, 24, 24, UI_ERROR_COLOR)
91
+ return
92
+ if self._texture is None:
93
+ self._draw_ui_text("No weapon icon texture loaded.", 24, 24, UI_TEXT_COLOR)
94
+ return
95
+
96
+ margin = 24
97
+ panel_gap = 32
98
+ panel_width = min(420, int(rl.get_screen_width() * 0.4))
99
+ available_width = rl.get_screen_width() - margin * 2 - panel_gap - panel_width
100
+ available_height = rl.get_screen_height() - margin * 2 - 60
101
+
102
+ cols = 4
103
+ rows = 8
104
+ icon_w = self._texture.width / cols
105
+ icon_h = self._texture.height / rows
106
+ scale = min(2.5, available_width / (cols * icon_w), available_height / (rows * icon_h))
107
+
108
+ x = margin
109
+ y = margin + 60
110
+ hovered_index = None
111
+ mouse = rl.get_mouse_position()
112
+
113
+ for idx in range(cols * rows):
114
+ row = idx // cols
115
+ col = idx % cols
116
+ dst_x = x + col * icon_w * scale
117
+ dst_y = y + row * icon_h * scale
118
+ dst = rl.Rectangle(float(dst_x), float(dst_y), float(icon_w * scale), float(icon_h * scale))
119
+ src = rl.Rectangle(float(col * icon_w), float(row * icon_h), float(icon_w), float(icon_h))
120
+ rl.draw_texture_pro(self._texture, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
121
+
122
+ if dst_x <= mouse.x <= dst_x + dst.width and dst_y <= mouse.y <= dst_y + dst.height:
123
+ hovered_index = idx
124
+ rl.draw_rectangle_lines_ex(dst, 3, UI_HOVER_COLOR)
125
+
126
+ self._draw_ui_text(
127
+ f"{idx:02d}",
128
+ dst_x + 4,
129
+ dst_y + 4,
130
+ UI_HINT_COLOR,
131
+ scale=0.75,
132
+ )
133
+
134
+ info_x = x + cols * icon_w * scale + panel_gap
135
+ info_y = margin
136
+ self._draw_ui_text(
137
+ "ui_wicons.png (8x8 grid, 2x1 subrects)",
138
+ info_x,
139
+ info_y,
140
+ UI_TEXT_COLOR,
141
+ )
142
+ info_y += self._ui_line_height() + 12
143
+
144
+ if hovered_index is not None:
145
+ frame = hovered_index * 2
146
+ self._draw_ui_text(
147
+ f"icon_index {hovered_index} frame {frame}",
148
+ info_x,
149
+ info_y,
150
+ UI_TEXT_COLOR,
151
+ )
152
+ info_y += self._ui_line_height() + 6
153
+ group = WEAPON_ICON_GROUPS.get(hovered_index)
154
+ if group is None:
155
+ self._draw_ui_text("no weapon mapping", info_x, info_y, UI_HINT_COLOR)
156
+ info_y += self._ui_line_height() + 6
157
+ else:
158
+ for weapon in group.weapons:
159
+ name = weapon.name or f"weapon_{weapon.weapon_id}"
160
+ self._draw_ui_text(name, info_x, info_y, UI_TEXT_COLOR)
161
+ info_y += self._ui_line_height() + 4
162
+
163
+
164
+ @register_view("wicons", "Weapon icon preview")
165
+ def build_weapon_icon_view(ctx: ViewContext) -> View:
166
+ return WeaponIconView(ctx)
crimson/weapon_sfx.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from .weapons import WEAPON_BY_ID
4
+
5
+ WEAPON_TABLE_BASE_ADDR = 0x4D7A2C
6
+ WEAPON_TABLE_STRIDE_BYTES = 0x7C
7
+ WEAPON_TABLE_FIRE_SFX_OFFSET = 0x58
8
+ WEAPON_TABLE_RELOAD_SFX_OFFSET = 0x60
9
+
10
+
11
+ def _parse_dat_ref(value: str) -> int | None:
12
+ raw = value.strip()
13
+ if raw.startswith("&"):
14
+ raw = raw[1:]
15
+ raw = raw.lstrip("_")
16
+ if not raw.startswith("DAT_"):
17
+ return None
18
+ try:
19
+ return int(raw.removeprefix("DAT_"), 16)
20
+ except ValueError:
21
+ return None
22
+
23
+
24
+ def resolve_weapon_sfx_ref(value: str | None, *, max_depth: int = 16) -> str | None:
25
+ """
26
+ Resolve weapon-table references like `_DAT_004d93bc` into a concrete sfx key (e.g. `sfx_shotgun_reload`).
27
+ """
28
+
29
+ current = value
30
+ seen_addrs: set[int] = set()
31
+
32
+ for _ in range(max(1, int(max_depth))):
33
+ if current is None:
34
+ return None
35
+ if current.startswith("sfx_"):
36
+ return current
37
+
38
+ addr = _parse_dat_ref(current)
39
+ if addr is None:
40
+ return current
41
+ if addr in seen_addrs:
42
+ return current
43
+ seen_addrs.add(addr)
44
+
45
+ offset = addr - WEAPON_TABLE_BASE_ADDR
46
+ if offset < 0:
47
+ return current
48
+ entry_index, field_offset = divmod(offset, WEAPON_TABLE_STRIDE_BYTES)
49
+ weapon_id = entry_index
50
+ weapon = WEAPON_BY_ID.get(weapon_id)
51
+ if weapon is None:
52
+ return current
53
+
54
+ if field_offset == WEAPON_TABLE_FIRE_SFX_OFFSET:
55
+ current = weapon.fire_sound
56
+ continue
57
+ if field_offset == WEAPON_TABLE_RELOAD_SFX_OFFSET:
58
+ current = weapon.reload_sound
59
+ continue
60
+
61
+ return current
62
+
63
+ return current