crimsonland 0.1.0.dev1__tar.gz

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 (136) hide show
  1. crimsonland-0.1.0.dev1/PKG-INFO +9 -0
  2. crimsonland-0.1.0.dev1/pyproject.toml +81 -0
  3. crimsonland-0.1.0.dev1/src/crimson/__init__.py +24 -0
  4. crimsonland-0.1.0.dev1/src/crimson/assets_fetch.py +60 -0
  5. crimsonland-0.1.0.dev1/src/crimson/atlas.py +92 -0
  6. crimsonland-0.1.0.dev1/src/crimson/audio_router.py +153 -0
  7. crimsonland-0.1.0.dev1/src/crimson/bonuses.py +167 -0
  8. crimsonland-0.1.0.dev1/src/crimson/camera.py +75 -0
  9. crimsonland-0.1.0.dev1/src/crimson/cli.py +377 -0
  10. crimsonland-0.1.0.dev1/src/crimson/creatures/__init__.py +8 -0
  11. crimsonland-0.1.0.dev1/src/crimson/creatures/ai.py +186 -0
  12. crimsonland-0.1.0.dev1/src/crimson/creatures/anim.py +173 -0
  13. crimsonland-0.1.0.dev1/src/crimson/creatures/damage.py +103 -0
  14. crimsonland-0.1.0.dev1/src/crimson/creatures/runtime.py +1019 -0
  15. crimsonland-0.1.0.dev1/src/crimson/creatures/spawn.py +2871 -0
  16. crimsonland-0.1.0.dev1/src/crimson/debug.py +7 -0
  17. crimsonland-0.1.0.dev1/src/crimson/demo.py +1360 -0
  18. crimsonland-0.1.0.dev1/src/crimson/demo_trial.py +140 -0
  19. crimsonland-0.1.0.dev1/src/crimson/effects.py +1086 -0
  20. crimsonland-0.1.0.dev1/src/crimson/effects_atlas.py +73 -0
  21. crimsonland-0.1.0.dev1/src/crimson/frontend/__init__.py +1 -0
  22. crimsonland-0.1.0.dev1/src/crimson/frontend/assets.py +43 -0
  23. crimsonland-0.1.0.dev1/src/crimson/frontend/boot.py +424 -0
  24. crimsonland-0.1.0.dev1/src/crimson/frontend/menu.py +700 -0
  25. crimsonland-0.1.0.dev1/src/crimson/frontend/panels/__init__.py +1 -0
  26. crimsonland-0.1.0.dev1/src/crimson/frontend/panels/base.py +410 -0
  27. crimsonland-0.1.0.dev1/src/crimson/frontend/panels/controls.py +132 -0
  28. crimsonland-0.1.0.dev1/src/crimson/frontend/panels/mods.py +128 -0
  29. crimsonland-0.1.0.dev1/src/crimson/frontend/panels/options.py +409 -0
  30. crimsonland-0.1.0.dev1/src/crimson/frontend/panels/play_game.py +627 -0
  31. crimsonland-0.1.0.dev1/src/crimson/frontend/panels/stats.py +351 -0
  32. crimsonland-0.1.0.dev1/src/crimson/frontend/transitions.py +31 -0
  33. crimsonland-0.1.0.dev1/src/crimson/game.py +2533 -0
  34. crimsonland-0.1.0.dev1/src/crimson/game_modes.py +15 -0
  35. crimsonland-0.1.0.dev1/src/crimson/game_world.py +663 -0
  36. crimsonland-0.1.0.dev1/src/crimson/gameplay.py +2450 -0
  37. crimsonland-0.1.0.dev1/src/crimson/input_codes.py +176 -0
  38. crimsonland-0.1.0.dev1/src/crimson/modes/__init__.py +1 -0
  39. crimsonland-0.1.0.dev1/src/crimson/modes/base_gameplay_mode.py +219 -0
  40. crimsonland-0.1.0.dev1/src/crimson/modes/quest_mode.py +502 -0
  41. crimsonland-0.1.0.dev1/src/crimson/modes/rush_mode.py +300 -0
  42. crimsonland-0.1.0.dev1/src/crimson/modes/survival_mode.py +792 -0
  43. crimsonland-0.1.0.dev1/src/crimson/modes/tutorial_mode.py +648 -0
  44. crimsonland-0.1.0.dev1/src/crimson/modes/typo_mode.py +472 -0
  45. crimsonland-0.1.0.dev1/src/crimson/paths.py +23 -0
  46. crimsonland-0.1.0.dev1/src/crimson/perks.py +828 -0
  47. crimsonland-0.1.0.dev1/src/crimson/persistence/__init__.py +1 -0
  48. crimsonland-0.1.0.dev1/src/crimson/persistence/highscores.py +385 -0
  49. crimsonland-0.1.0.dev1/src/crimson/persistence/save_status.py +245 -0
  50. crimsonland-0.1.0.dev1/src/crimson/player_damage.py +77 -0
  51. crimsonland-0.1.0.dev1/src/crimson/projectiles.py +1039 -0
  52. crimsonland-0.1.0.dev1/src/crimson/quests/__init__.py +18 -0
  53. crimsonland-0.1.0.dev1/src/crimson/quests/helpers.py +147 -0
  54. crimsonland-0.1.0.dev1/src/crimson/quests/registry.py +49 -0
  55. crimsonland-0.1.0.dev1/src/crimson/quests/results.py +164 -0
  56. crimsonland-0.1.0.dev1/src/crimson/quests/runtime.py +91 -0
  57. crimsonland-0.1.0.dev1/src/crimson/quests/tier1.py +620 -0
  58. crimsonland-0.1.0.dev1/src/crimson/quests/tier2.py +652 -0
  59. crimsonland-0.1.0.dev1/src/crimson/quests/tier3.py +579 -0
  60. crimsonland-0.1.0.dev1/src/crimson/quests/tier4.py +721 -0
  61. crimsonland-0.1.0.dev1/src/crimson/quests/tier5.py +886 -0
  62. crimsonland-0.1.0.dev1/src/crimson/quests/timeline.py +115 -0
  63. crimsonland-0.1.0.dev1/src/crimson/quests/types.py +70 -0
  64. crimsonland-0.1.0.dev1/src/crimson/render/__init__.py +1 -0
  65. crimsonland-0.1.0.dev1/src/crimson/render/terrain_fx.py +88 -0
  66. crimsonland-0.1.0.dev1/src/crimson/render/world_renderer.py +1338 -0
  67. crimsonland-0.1.0.dev1/src/crimson/sim/__init__.py +1 -0
  68. crimsonland-0.1.0.dev1/src/crimson/sim/world_defs.py +56 -0
  69. crimsonland-0.1.0.dev1/src/crimson/sim/world_state.py +421 -0
  70. crimsonland-0.1.0.dev1/src/crimson/terrain_assets.py +19 -0
  71. crimsonland-0.1.0.dev1/src/crimson/tutorial/__init__.py +12 -0
  72. crimsonland-0.1.0.dev1/src/crimson/tutorial/timeline.py +291 -0
  73. crimsonland-0.1.0.dev1/src/crimson/typo/__init__.py +2 -0
  74. crimsonland-0.1.0.dev1/src/crimson/typo/names.py +233 -0
  75. crimsonland-0.1.0.dev1/src/crimson/typo/player.py +43 -0
  76. crimsonland-0.1.0.dev1/src/crimson/typo/spawns.py +73 -0
  77. crimsonland-0.1.0.dev1/src/crimson/typo/typing.py +52 -0
  78. crimsonland-0.1.0.dev1/src/crimson/ui/__init__.py +3 -0
  79. crimsonland-0.1.0.dev1/src/crimson/ui/cursor.py +95 -0
  80. crimsonland-0.1.0.dev1/src/crimson/ui/demo_trial_overlay.py +235 -0
  81. crimsonland-0.1.0.dev1/src/crimson/ui/game_over.py +660 -0
  82. crimsonland-0.1.0.dev1/src/crimson/ui/hud.py +601 -0
  83. crimsonland-0.1.0.dev1/src/crimson/ui/perk_menu.py +388 -0
  84. crimsonland-0.1.0.dev1/src/crimson/views/__init__.py +40 -0
  85. crimsonland-0.1.0.dev1/src/crimson/views/aim_debug.py +276 -0
  86. crimsonland-0.1.0.dev1/src/crimson/views/animations.py +274 -0
  87. crimsonland-0.1.0.dev1/src/crimson/views/arsenal_debug.py +414 -0
  88. crimsonland-0.1.0.dev1/src/crimson/views/bonuses.py +201 -0
  89. crimsonland-0.1.0.dev1/src/crimson/views/camera_debug.py +359 -0
  90. crimsonland-0.1.0.dev1/src/crimson/views/camera_shake.py +229 -0
  91. crimsonland-0.1.0.dev1/src/crimson/views/corpse_stamp_debug.py +324 -0
  92. crimsonland-0.1.0.dev1/src/crimson/views/decals_debug.py +739 -0
  93. crimsonland-0.1.0.dev1/src/crimson/views/empty.py +19 -0
  94. crimsonland-0.1.0.dev1/src/crimson/views/fonts.py +114 -0
  95. crimsonland-0.1.0.dev1/src/crimson/views/game_over.py +117 -0
  96. crimsonland-0.1.0.dev1/src/crimson/views/ground.py +259 -0
  97. crimsonland-0.1.0.dev1/src/crimson/views/lighting_debug.py +1166 -0
  98. crimsonland-0.1.0.dev1/src/crimson/views/particles.py +293 -0
  99. crimsonland-0.1.0.dev1/src/crimson/views/perk_menu_debug.py +430 -0
  100. crimsonland-0.1.0.dev1/src/crimson/views/perks.py +398 -0
  101. crimsonland-0.1.0.dev1/src/crimson/views/player.py +433 -0
  102. crimsonland-0.1.0.dev1/src/crimson/views/player_sprite_debug.py +314 -0
  103. crimsonland-0.1.0.dev1/src/crimson/views/projectile_fx.py +608 -0
  104. crimsonland-0.1.0.dev1/src/crimson/views/projectile_render_debug.py +407 -0
  105. crimsonland-0.1.0.dev1/src/crimson/views/projectiles.py +221 -0
  106. crimsonland-0.1.0.dev1/src/crimson/views/quest_title_overlay.py +108 -0
  107. crimsonland-0.1.0.dev1/src/crimson/views/registry.py +34 -0
  108. crimsonland-0.1.0.dev1/src/crimson/views/rush.py +16 -0
  109. crimsonland-0.1.0.dev1/src/crimson/views/small_font_debug.py +204 -0
  110. crimsonland-0.1.0.dev1/src/crimson/views/spawn_plan.py +363 -0
  111. crimsonland-0.1.0.dev1/src/crimson/views/sprites.py +214 -0
  112. crimsonland-0.1.0.dev1/src/crimson/views/survival.py +15 -0
  113. crimsonland-0.1.0.dev1/src/crimson/views/terrain.py +132 -0
  114. crimsonland-0.1.0.dev1/src/crimson/views/ui.py +123 -0
  115. crimsonland-0.1.0.dev1/src/crimson/views/wicons.py +166 -0
  116. crimsonland-0.1.0.dev1/src/crimson/weapon_sfx.py +63 -0
  117. crimsonland-0.1.0.dev1/src/crimson/weapons.py +860 -0
  118. crimsonland-0.1.0.dev1/src/grim/__init__.py +20 -0
  119. crimsonland-0.1.0.dev1/src/grim/app.py +92 -0
  120. crimsonland-0.1.0.dev1/src/grim/assets.py +231 -0
  121. crimsonland-0.1.0.dev1/src/grim/audio.py +106 -0
  122. crimsonland-0.1.0.dev1/src/grim/config.py +294 -0
  123. crimsonland-0.1.0.dev1/src/grim/console.py +737 -0
  124. crimsonland-0.1.0.dev1/src/grim/fonts/__init__.py +7 -0
  125. crimsonland-0.1.0.dev1/src/grim/fonts/grim_mono.py +111 -0
  126. crimsonland-0.1.0.dev1/src/grim/fonts/small.py +120 -0
  127. crimsonland-0.1.0.dev1/src/grim/input.py +44 -0
  128. crimsonland-0.1.0.dev1/src/grim/jaz.py +103 -0
  129. crimsonland-0.1.0.dev1/src/grim/math.py +17 -0
  130. crimsonland-0.1.0.dev1/src/grim/music.py +403 -0
  131. crimsonland-0.1.0.dev1/src/grim/paq.py +76 -0
  132. crimsonland-0.1.0.dev1/src/grim/rand.py +37 -0
  133. crimsonland-0.1.0.dev1/src/grim/sfx.py +276 -0
  134. crimsonland-0.1.0.dev1/src/grim/sfx_map.py +103 -0
  135. crimsonland-0.1.0.dev1/src/grim/terrain_render.py +840 -0
  136. crimsonland-0.1.0.dev1/src/grim/view.py +16 -0
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.3
2
+ Name: crimsonland
3
+ Version: 0.1.0.dev1
4
+ Requires-Dist: construct>=2.10.70
5
+ Requires-Dist: pillow>=12.1.0
6
+ Requires-Dist: platformdirs>=4.5.1
7
+ Requires-Dist: raylib>=5.5.0.4
8
+ Requires-Dist: typer>=0.21.1
9
+ Requires-Python: >=3.13
@@ -0,0 +1,81 @@
1
+ [project]
2
+ name = "crimsonland"
3
+ version = "0.1.0.dev1"
4
+ requires-python = ">=3.13"
5
+ dependencies = [
6
+ "construct>=2.10.70",
7
+ "pillow>=12.1.0",
8
+ "platformdirs>=4.5.1",
9
+ "raylib>=5.5.0.4",
10
+ "typer>=0.21.1",
11
+ ]
12
+
13
+ [project.scripts]
14
+ crimsonland = "crimson.cli:main"
15
+ crimson = "crimson.cli:main"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.9.18,<0.10.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [tool.uv.build-backend]
22
+ module-name = ["crimson", "grim"]
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "import-linter>=2.9",
27
+ "py-spy>=0.4.1",
28
+ "pylint>=4.0.4",
29
+ "pytest>=9.0.2",
30
+ "pytest-cov>=7.0.0",
31
+ "syrupy>=5.0.0",
32
+ "zensical>=0.0.16",
33
+ ]
34
+
35
+ [tool.ruff]
36
+ line-length = 120
37
+
38
+ [tool.ruff.lint]
39
+ extend-ignore = ["E402"]
40
+
41
+ [tool.pytest.ini_options]
42
+ addopts = [
43
+ "--cov=crimson",
44
+ "--cov-report=term-missing",
45
+ ]
46
+
47
+ [tool.coverage.run]
48
+ branch = true
49
+ source = ["crimson"]
50
+ omit = ["tests/*"]
51
+
52
+ [tool.coverage.report]
53
+ show_missing = true
54
+ skip_covered = true
55
+ exclude_lines = [
56
+ "pragma: no cover",
57
+ "if TYPE_CHECKING:",
58
+ "if __name__ == .__main__.:",
59
+ ]
60
+
61
+ [tool.importlinter]
62
+ root_packages = ["crimson", "grim"]
63
+ include_external_packages = true
64
+
65
+ [[tool.importlinter.contracts]]
66
+ name = "grim must not import crimson"
67
+ type = "forbidden"
68
+ source_modules = ["grim"]
69
+ forbidden_modules = ["crimson"]
70
+
71
+ [[tool.importlinter.contracts]]
72
+ name = "crimson.sim must not import pyray"
73
+ type = "forbidden"
74
+ source_modules = ["crimson.sim"]
75
+ forbidden_modules = ["pyray"]
76
+
77
+ [[tool.importlinter.contracts]]
78
+ name = "crimson.frontend must not import simulation"
79
+ type = "forbidden"
80
+ source_modules = ["crimson.frontend"]
81
+ forbidden_modules = ["crimson.sim", "crimson.game_world"]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("crimsonland")
6
+
7
+ __all__ = [
8
+ "atlas",
9
+ "audio_router",
10
+ "bonuses",
11
+ "creatures",
12
+ "gameplay",
13
+ "effects",
14
+ "effects_atlas",
15
+ "modes",
16
+ "perks",
17
+ "persistence",
18
+ "quests",
19
+ "render",
20
+ "sim",
21
+ "ui",
22
+ "views",
23
+ "weapons",
24
+ ]
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import shutil
6
+ import urllib.request
7
+
8
+ from grim.console import ConsoleState
9
+
10
+ ASSET_BASE_URL = "https://paq.crimson.banteg.xyz/v1.9.93"
11
+ DEFAULT_PAQ_FILES = ("crimson.paq", "music.paq", "sfx.paq")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class DownloadResult:
16
+ name: str
17
+ ok: bool
18
+ error: str | None = None
19
+
20
+
21
+ def _download_file(url: str, dest: Path) -> None:
22
+ tmp = dest.with_suffix(dest.suffix + ".tmp")
23
+ if tmp.exists():
24
+ tmp.unlink()
25
+ try:
26
+ req = urllib.request.Request(url, headers={"User-Agent": "crimsonland-decompile"})
27
+ with urllib.request.urlopen(req, timeout=30) as resp, tmp.open("wb") as handle:
28
+ shutil.copyfileobj(resp, handle)
29
+ tmp.replace(dest)
30
+ finally:
31
+ if tmp.exists():
32
+ tmp.unlink()
33
+
34
+
35
+ def download_missing_paqs(
36
+ assets_dir: Path,
37
+ console: ConsoleState,
38
+ *,
39
+ base_url: str = ASSET_BASE_URL,
40
+ names: tuple[str, ...] = DEFAULT_PAQ_FILES,
41
+ ) -> tuple[DownloadResult, ...]:
42
+ assets_dir.mkdir(parents=True, exist_ok=True)
43
+ missing = [name for name in names if not (assets_dir / name).is_file()]
44
+ if not missing:
45
+ return ()
46
+ console.log.log(f"assets: missing {', '.join(missing)} (downloading)")
47
+ results: list[DownloadResult] = []
48
+ for name in missing:
49
+ url = f"{base_url}/{name}"
50
+ dest = assets_dir / name
51
+ try:
52
+ _download_file(url, dest)
53
+ except Exception as exc:
54
+ results.append(DownloadResult(name=name, ok=False, error=str(exc)))
55
+ console.log.log(f"assets: failed to download {name}: {exc}")
56
+ continue
57
+ results.append(DownloadResult(name=name, ok=True))
58
+ console.log.log(f"assets: downloaded {name}")
59
+ console.log.flush()
60
+ return tuple(results)
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Atlas slicing used by the Crimsonland renderer.
5
+
6
+ Findings from decompiled code:
7
+ - FUN_0041fed0 precomputes UV grids for 2x2, 4x4, 8x8, 16x16 (steps 0.5/0.25/0.125/0.0625).
8
+ - FUN_0042e0a0 reads a table at VA 0x004755F0 with pairs (cell_code, group_id).
9
+ cell_code maps to grid size: 0x80->2, 0x40->4, 0x20->8, 0x10->16.
10
+ group_id is passed to the renderer alongside the grid size; semantics unknown.
11
+ - FUN_0042e120 uses the selected UV grid to build quad UVs by frame index.
12
+
13
+ This module replicates the atlas cutting: given a grid size and frame index,
14
+ compute UVs or crop subimages.
15
+ """
16
+
17
+ from typing import Iterable
18
+
19
+ from PIL import Image
20
+
21
+ GRID_SIZE_BY_CODE = {
22
+ 0x80: 2,
23
+ 0x40: 4,
24
+ 0x20: 8,
25
+ 0x10: 16,
26
+ }
27
+
28
+ # DAT_004755f0 table (index -> (cell_code, group_id)) extracted from crimsonland.exe
29
+ SPRITE_TABLE = [
30
+ (0x80, 0x2),
31
+ (0x80, 0x3),
32
+ (0x20, 0x0),
33
+ (0x20, 0x1),
34
+ (0x20, 0x2),
35
+ (0x20, 0x3),
36
+ (0x20, 0x4),
37
+ (0x20, 0x5),
38
+ (0x20, 0x8),
39
+ (0x20, 0x9),
40
+ (0x20, 0xA),
41
+ (0x20, 0xB),
42
+ (0x40, 0x5),
43
+ (0x40, 0x3),
44
+ (0x40, 0x4),
45
+ (0x40, 0x5),
46
+ (0x40, 0x6),
47
+ ]
48
+
49
+
50
+ def grid_size_from_code(code: int) -> int:
51
+ return GRID_SIZE_BY_CODE[code]
52
+
53
+
54
+ def grid_size_for_index(index: int) -> int:
55
+ code, _ = SPRITE_TABLE[index]
56
+ return grid_size_from_code(code)
57
+
58
+
59
+ def uv_for_index(grid: int, index: int) -> tuple[float, float, float, float]:
60
+ row = index // grid
61
+ col = index % grid
62
+ step = 1.0 / grid
63
+ u0 = col * step
64
+ v0 = row * step
65
+ u1 = u0 + step
66
+ v1 = v0 + step
67
+ return u0, v0, u1, v1
68
+
69
+
70
+ def rect_for_index(width: int, height: int, grid: int, index: int) -> tuple[int, int, int, int]:
71
+ row = index // grid
72
+ col = index % grid
73
+ cell_w = width // grid
74
+ cell_h = height // grid
75
+ x0 = col * cell_w
76
+ y0 = row * cell_h
77
+ return x0, y0, x0 + cell_w, y0 + cell_h
78
+
79
+
80
+ def slice_index(image: Image.Image, grid: int, index: int) -> Image.Image:
81
+ return image.crop(rect_for_index(image.width, image.height, grid, index))
82
+
83
+
84
+ def slice_grid(image: Image.Image, grid: int) -> list[Image.Image]:
85
+ frames = []
86
+ for idx in range(grid * grid):
87
+ frames.append(slice_index(image, grid, idx))
88
+ return frames
89
+
90
+
91
+ def slice_by_indices(image: Image.Image, grid: int, indices: Iterable[int]) -> list[Image.Image]:
92
+ return [slice_index(image, grid, idx) for idx in indices]
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import random
5
+ from typing import Callable
6
+
7
+ from grim.audio import AudioState, play_sfx, trigger_game_tune
8
+
9
+ from .creatures.spawn import CreatureTypeId
10
+ from .game_modes import GameMode
11
+ from .weapon_sfx import resolve_weapon_sfx_ref
12
+ from .weapons import WEAPON_BY_ID
13
+
14
+ _MAX_HIT_SFX_PER_FRAME = 4
15
+ _MAX_DEATH_SFX_PER_FRAME = 3
16
+
17
+ _BULLET_HIT_SFX = (
18
+ "sfx_bullet_hit_01",
19
+ "sfx_bullet_hit_02",
20
+ "sfx_bullet_hit_03",
21
+ "sfx_bullet_hit_04",
22
+ "sfx_bullet_hit_05",
23
+ "sfx_bullet_hit_06",
24
+ )
25
+
26
+ _CREATURE_DEATH_SFX: dict[CreatureTypeId, tuple[str, ...]] = {
27
+ CreatureTypeId.ZOMBIE: (
28
+ "sfx_zombie_die_01",
29
+ "sfx_zombie_die_02",
30
+ "sfx_zombie_die_03",
31
+ "sfx_zombie_die_04",
32
+ ),
33
+ CreatureTypeId.LIZARD: (
34
+ "sfx_lizard_die_01",
35
+ "sfx_lizard_die_02",
36
+ "sfx_lizard_die_03",
37
+ "sfx_lizard_die_04",
38
+ ),
39
+ CreatureTypeId.ALIEN: (
40
+ "sfx_alien_die_01",
41
+ "sfx_alien_die_02",
42
+ "sfx_alien_die_03",
43
+ "sfx_alien_die_04",
44
+ ),
45
+ CreatureTypeId.SPIDER_SP1: (
46
+ "sfx_spider_die_01",
47
+ "sfx_spider_die_02",
48
+ "sfx_spider_die_03",
49
+ "sfx_spider_die_04",
50
+ ),
51
+ CreatureTypeId.SPIDER_SP2: (
52
+ "sfx_spider_die_01",
53
+ "sfx_spider_die_02",
54
+ "sfx_spider_die_03",
55
+ "sfx_spider_die_04",
56
+ ),
57
+ CreatureTypeId.TROOPER: (
58
+ "sfx_trooper_die_01",
59
+ "sfx_trooper_die_02",
60
+ "sfx_trooper_die_03",
61
+ "sfx_trooper_die_04",
62
+ ),
63
+ }
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class AudioRouter:
68
+ audio: AudioState | None = None
69
+ audio_rng: random.Random | None = None
70
+ demo_mode_active: bool = False
71
+
72
+ @staticmethod
73
+ def _rand_choice(rand: Callable[[], int], options: tuple[str, ...]) -> str | None:
74
+ if not options:
75
+ return None
76
+ idx = int(rand()) % len(options)
77
+ return options[idx]
78
+
79
+ def play_sfx(self, key: str | None) -> None:
80
+ if self.audio is None:
81
+ return
82
+ play_sfx(self.audio, key, rng=self.audio_rng)
83
+
84
+ def handle_player_audio(
85
+ self,
86
+ player: object,
87
+ *,
88
+ prev_shot_seq: int,
89
+ prev_reload_active: bool,
90
+ prev_reload_timer: float,
91
+ ) -> None:
92
+ if self.audio is None:
93
+ return
94
+ weapon = WEAPON_BY_ID.get(int(getattr(player, "weapon_id", 0)))
95
+ if weapon is None:
96
+ return
97
+
98
+ if int(getattr(player, "shot_seq", 0)) > int(prev_shot_seq):
99
+ self.play_sfx(resolve_weapon_sfx_ref(weapon.fire_sound))
100
+
101
+ reload_active = bool(getattr(player, "reload_active", False))
102
+ reload_timer = float(getattr(player, "reload_timer", 0.0))
103
+ reload_started = (not prev_reload_active and reload_active) or (reload_timer > prev_reload_timer + 1e-6)
104
+ if reload_started:
105
+ self.play_sfx(resolve_weapon_sfx_ref(weapon.reload_sound))
106
+
107
+ def _hit_sfx_for_type(
108
+ self,
109
+ type_id: int,
110
+ *,
111
+ beam_types: frozenset[int],
112
+ rand: Callable[[], int],
113
+ ) -> str | None:
114
+ if type_id in beam_types:
115
+ return "sfx_shock_hit_01"
116
+ return self._rand_choice(rand, _BULLET_HIT_SFX)
117
+
118
+ def play_hit_sfx(
119
+ self,
120
+ hits: list[tuple[int, float, float, float, float, float, float]],
121
+ *,
122
+ game_mode: int,
123
+ rand: Callable[[], int],
124
+ beam_types: frozenset[int],
125
+ ) -> None:
126
+ if self.audio is None or not hits:
127
+ return
128
+
129
+ start_idx = 0
130
+ if (not self.demo_mode_active) and int(game_mode) != int(GameMode.RUSH):
131
+ if trigger_game_tune(self.audio, rand=rand) is not None:
132
+ start_idx = 1
133
+
134
+ end = min(len(hits), start_idx + _MAX_HIT_SFX_PER_FRAME)
135
+ for idx in range(start_idx, end):
136
+ type_id = int(hits[idx][0])
137
+ self.play_sfx(self._hit_sfx_for_type(type_id, beam_types=beam_types, rand=rand))
138
+
139
+ def play_death_sfx(self, deaths: tuple[object, ...], *, rand: Callable[[], int]) -> None:
140
+ if self.audio is None or not deaths:
141
+ return
142
+ for idx in range(min(len(deaths), _MAX_DEATH_SFX_PER_FRAME)):
143
+ death = deaths[idx]
144
+ type_id = getattr(death, "type_id", None)
145
+ if type_id is None:
146
+ continue
147
+ try:
148
+ creature_type = CreatureTypeId(int(type_id))
149
+ except ValueError:
150
+ continue
151
+ options = _CREATURE_DEATH_SFX.get(creature_type)
152
+ if options:
153
+ self.play_sfx(self._rand_choice(rand, options))
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ """Bonus ids extracted from bonus_metadata_init (bonus_meta_label)."""
4
+
5
+ from dataclasses import dataclass
6
+ from enum import IntEnum
7
+
8
+
9
+ class BonusId(IntEnum):
10
+ UNUSED = 0
11
+ POINTS = 1
12
+ ENERGIZER = 2
13
+ WEAPON = 3
14
+ WEAPON_POWER_UP = 4
15
+ NUKE = 5
16
+ DOUBLE_EXPERIENCE = 6
17
+ SHOCK_CHAIN = 7
18
+ FIREBLAST = 8
19
+ REFLEX_BOOST = 9
20
+ SHIELD = 10
21
+ FREEZE = 11
22
+ MEDIKIT = 12
23
+ SPEED = 13
24
+ FIRE_BULLETS = 14
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class BonusMeta:
29
+ bonus_id: BonusId
30
+ name: str
31
+ description: str | None
32
+ icon_id: int | None
33
+ default_amount: int | None
34
+ notes: str | None = None
35
+
36
+
37
+ BONUS_TABLE = [
38
+ BonusMeta(
39
+ bonus_id=BonusId.UNUSED,
40
+ name="(unused)",
41
+ description=None,
42
+ icon_id=None,
43
+ default_amount=None,
44
+ notes="`DAT_004853dc` is set to `0`, disabling this entry.",
45
+ ),
46
+ BonusMeta(
47
+ bonus_id=BonusId.POINTS,
48
+ name="Points",
49
+ description="You gain some experience points.",
50
+ icon_id=12,
51
+ default_amount=500,
52
+ notes="`bonus_apply` adds `default_amount` to score.",
53
+ ),
54
+ BonusMeta(
55
+ bonus_id=BonusId.ENERGIZER,
56
+ name="Energizer",
57
+ description="Suddenly monsters run away from you and you can eat them.",
58
+ icon_id=10,
59
+ default_amount=8,
60
+ notes="`bonus_apply` updates `bonus_energizer_timer`.",
61
+ ),
62
+ BonusMeta(
63
+ bonus_id=BonusId.WEAPON,
64
+ name="Weapon",
65
+ description="You get a new weapon.",
66
+ icon_id=-1,
67
+ default_amount=3,
68
+ notes="`bonus_apply` treats `default_amount` as weapon id; often overridden.",
69
+ ),
70
+ BonusMeta(
71
+ bonus_id=BonusId.WEAPON_POWER_UP,
72
+ name="Weapon Power Up",
73
+ description="Your firerate and load time increase for a short period.",
74
+ icon_id=7,
75
+ default_amount=10,
76
+ notes="`bonus_apply` updates `bonus_weapon_power_up_timer`.",
77
+ ),
78
+ BonusMeta(
79
+ bonus_id=BonusId.NUKE,
80
+ name="Nuke",
81
+ description="An amazing explosion of ATOMIC power.",
82
+ icon_id=1,
83
+ default_amount=0,
84
+ notes="`bonus_apply` performs the large explosion + shake sequence.",
85
+ ),
86
+ BonusMeta(
87
+ bonus_id=BonusId.DOUBLE_EXPERIENCE,
88
+ name="Double Experience",
89
+ description="Every experience point you get is doubled when this bonus is active.",
90
+ icon_id=4,
91
+ default_amount=6,
92
+ notes="`bonus_apply` updates `bonus_double_xp_timer`.",
93
+ ),
94
+ BonusMeta(
95
+ bonus_id=BonusId.SHOCK_CHAIN,
96
+ name="Shock Chain",
97
+ description="Chain of shocks shock the crowd.",
98
+ icon_id=3,
99
+ default_amount=0,
100
+ notes="`bonus_apply` spawns chained lightning via `projectile_spawn` type `0x15`; `shock_chain_links_left` / `shock_chain_projectile_id` track the active chain.",
101
+ ),
102
+ BonusMeta(
103
+ bonus_id=BonusId.FIREBLAST,
104
+ name="Fireblast",
105
+ description="Fireballs all over the place.",
106
+ icon_id=2,
107
+ default_amount=0,
108
+ notes="`bonus_apply` spawns a radial projectile burst (type `9`).",
109
+ ),
110
+ BonusMeta(
111
+ bonus_id=BonusId.REFLEX_BOOST,
112
+ name="Reflex Boost",
113
+ description="You get more time to react as the game slows down.",
114
+ icon_id=5,
115
+ default_amount=3,
116
+ notes="`bonus_apply` updates `bonus_reflex_boost_timer`.",
117
+ ),
118
+ BonusMeta(
119
+ bonus_id=BonusId.SHIELD,
120
+ name="Shield",
121
+ description="Force field protects you for a while.",
122
+ icon_id=6,
123
+ default_amount=7,
124
+ notes="`bonus_apply` updates `player_shield_timer` (`DAT_00490bc8`).",
125
+ ),
126
+ BonusMeta(
127
+ bonus_id=BonusId.FREEZE,
128
+ name="Freeze",
129
+ description="Monsters are frozen.",
130
+ icon_id=8,
131
+ default_amount=5,
132
+ notes="`bonus_apply` updates `bonus_freeze_timer`.",
133
+ ),
134
+ BonusMeta(
135
+ bonus_id=BonusId.MEDIKIT,
136
+ name="MediKit",
137
+ description="You regain some of your health.",
138
+ icon_id=14,
139
+ default_amount=10,
140
+ notes="`bonus_apply` restores health in 10-point increments.",
141
+ ),
142
+ BonusMeta(
143
+ bonus_id=BonusId.SPEED,
144
+ name="Speed",
145
+ description="Your movement speed increases for a while.",
146
+ icon_id=9,
147
+ default_amount=8,
148
+ notes="`bonus_apply` updates `player_speed_bonus_timer` (`DAT_00490bc4`).",
149
+ ),
150
+ BonusMeta(
151
+ bonus_id=BonusId.FIRE_BULLETS,
152
+ name="Fire Bullets",
153
+ description="For few seconds -- make them count.",
154
+ icon_id=11,
155
+ default_amount=5,
156
+ notes="`bonus_apply` updates `player_fire_bullets_timer` (`DAT_00490bcc`). While active, `projectile_spawn` overrides player-owned projectiles to type `0x2d` (pellet count from `weapon_projectile_pellet_count[weapon_id]`).",
157
+ ),
158
+ ]
159
+
160
+ BONUS_BY_ID = {int(entry.bonus_id): entry for entry in BONUS_TABLE}
161
+
162
+
163
+ def bonus_label(bonus_id: int) -> str:
164
+ entry = BONUS_BY_ID.get(bonus_id)
165
+ if entry is None:
166
+ return "unknown"
167
+ return entry.name
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ """Camera helpers recovered from the original crimsonland.exe.
4
+
5
+ This module currently models the `camera_update` screen shake logic, which is
6
+ global state in the original game.
7
+ """
8
+
9
+ from .gameplay import GameplayState
10
+
11
+
12
+ def camera_shake_start(state: GameplayState, *, pulses: int, timer: float) -> None:
13
+ """Start a camera shake sequence.
14
+
15
+ Mirrors the nuke path in `bonus_apply`, which sets:
16
+ - `camera_shake_pulses = 0x14`
17
+ - `camera_shake_timer = 0.2`
18
+ """
19
+
20
+ state.camera_shake_pulses = int(pulses)
21
+ state.camera_shake_timer = float(timer)
22
+
23
+
24
+ def camera_shake_update(state: GameplayState, dt: float) -> None:
25
+ """Update camera shake offsets and timers.
26
+
27
+ Port of `camera_update` (crimsonland.exe @ 0x00409500):
28
+ - timer decays at `dt * 3.0`
29
+ - when timer drops below 0, a "pulse" happens:
30
+ - pulses--
31
+ - timer resets to 0.1 (or 0.06 when time scaling is active)
32
+ - offsets jump to new RNG-derived values
33
+ """
34
+
35
+ if state.camera_shake_timer <= 0.0:
36
+ state.camera_shake_offset_x = 0.0
37
+ state.camera_shake_offset_y = 0.0
38
+ return
39
+
40
+ state.camera_shake_timer -= float(dt) * 3.0
41
+ if state.camera_shake_timer >= 0.0:
42
+ return
43
+
44
+ state.camera_shake_pulses -= 1
45
+ if state.camera_shake_pulses < 1:
46
+ state.camera_shake_timer = 0.0
47
+ return
48
+
49
+ time_scale_active = state.bonuses.reflex_boost > 0.0
50
+ state.camera_shake_timer = 0.06 if time_scale_active else 0.1
51
+
52
+ # Decompiled logic:
53
+ # iVar4 = camera_shake_pulses * 0x3c;
54
+ # iVar1 = rand() % (iVar4 / 0x14) + rand() % 10;
55
+ # ... where (pulses * 0x3c) / 0x14 == pulses * 3.
56
+ max_amp = int(state.camera_shake_pulses) * 3
57
+ if max_amp <= 0:
58
+ state.camera_shake_offset_x = 0.0
59
+ state.camera_shake_offset_y = 0.0
60
+ state.camera_shake_timer = 0.0
61
+ state.camera_shake_pulses = 0
62
+ return
63
+
64
+ rand = state.rng.rand
65
+
66
+ mag_x = (int(rand()) % max_amp) + (int(rand()) % 10)
67
+ if (int(rand()) & 1) == 0:
68
+ mag_x = -mag_x
69
+ state.camera_shake_offset_x = float(mag_x)
70
+
71
+ mag_y = (int(rand()) % max_amp) + (int(rand()) % 10)
72
+ if (int(rand()) & 1) == 0:
73
+ mag_y = -mag_y
74
+ state.camera_shake_offset_y = float(mag_y)
75
+