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.
- crimson/__init__.py +24 -0
- crimson/assets_fetch.py +60 -0
- crimson/atlas.py +92 -0
- crimson/audio_router.py +155 -0
- crimson/bonuses.py +167 -0
- crimson/camera.py +75 -0
- crimson/cli.py +380 -0
- crimson/creatures/__init__.py +8 -0
- crimson/creatures/ai.py +186 -0
- crimson/creatures/anim.py +173 -0
- crimson/creatures/damage.py +103 -0
- crimson/creatures/runtime.py +1019 -0
- crimson/creatures/spawn.py +2871 -0
- crimson/debug.py +7 -0
- crimson/demo.py +1360 -0
- crimson/demo_trial.py +140 -0
- crimson/effects.py +1086 -0
- crimson/effects_atlas.py +73 -0
- crimson/frontend/__init__.py +1 -0
- crimson/frontend/assets.py +43 -0
- crimson/frontend/boot.py +424 -0
- crimson/frontend/menu.py +700 -0
- crimson/frontend/panels/__init__.py +1 -0
- crimson/frontend/panels/base.py +410 -0
- crimson/frontend/panels/controls.py +132 -0
- crimson/frontend/panels/mods.py +128 -0
- crimson/frontend/panels/options.py +409 -0
- crimson/frontend/panels/play_game.py +627 -0
- crimson/frontend/panels/stats.py +351 -0
- crimson/frontend/transitions.py +31 -0
- crimson/game.py +2533 -0
- crimson/game_modes.py +15 -0
- crimson/game_world.py +652 -0
- crimson/gameplay.py +2467 -0
- crimson/input_codes.py +176 -0
- crimson/modes/__init__.py +1 -0
- crimson/modes/base_gameplay_mode.py +219 -0
- crimson/modes/quest_mode.py +502 -0
- crimson/modes/rush_mode.py +300 -0
- crimson/modes/survival_mode.py +792 -0
- crimson/modes/tutorial_mode.py +648 -0
- crimson/modes/typo_mode.py +472 -0
- crimson/paths.py +23 -0
- crimson/perks.py +828 -0
- crimson/persistence/__init__.py +1 -0
- crimson/persistence/highscores.py +385 -0
- crimson/persistence/save_status.py +245 -0
- crimson/player_damage.py +77 -0
- crimson/projectiles.py +1133 -0
- crimson/quests/__init__.py +18 -0
- crimson/quests/helpers.py +147 -0
- crimson/quests/registry.py +49 -0
- crimson/quests/results.py +164 -0
- crimson/quests/runtime.py +91 -0
- crimson/quests/tier1.py +620 -0
- crimson/quests/tier2.py +652 -0
- crimson/quests/tier3.py +579 -0
- crimson/quests/tier4.py +721 -0
- crimson/quests/tier5.py +886 -0
- crimson/quests/timeline.py +115 -0
- crimson/quests/types.py +70 -0
- crimson/render/__init__.py +1 -0
- crimson/render/terrain_fx.py +88 -0
- crimson/render/world_renderer.py +1941 -0
- crimson/sim/__init__.py +1 -0
- crimson/sim/world_defs.py +67 -0
- crimson/sim/world_state.py +422 -0
- crimson/terrain_assets.py +19 -0
- crimson/tutorial/__init__.py +12 -0
- crimson/tutorial/timeline.py +291 -0
- crimson/typo/__init__.py +2 -0
- crimson/typo/names.py +233 -0
- crimson/typo/player.py +43 -0
- crimson/typo/spawns.py +73 -0
- crimson/typo/typing.py +52 -0
- crimson/ui/__init__.py +3 -0
- crimson/ui/cursor.py +95 -0
- crimson/ui/demo_trial_overlay.py +235 -0
- crimson/ui/game_over.py +660 -0
- crimson/ui/hud.py +601 -0
- crimson/ui/perk_menu.py +388 -0
- crimson/views/__init__.py +40 -0
- crimson/views/aim_debug.py +276 -0
- crimson/views/animations.py +274 -0
- crimson/views/arsenal_debug.py +404 -0
- crimson/views/audio_bootstrap.py +47 -0
- crimson/views/bonuses.py +201 -0
- crimson/views/camera_debug.py +359 -0
- crimson/views/camera_shake.py +229 -0
- crimson/views/corpse_stamp_debug.py +324 -0
- crimson/views/decals_debug.py +739 -0
- crimson/views/empty.py +19 -0
- crimson/views/fonts.py +114 -0
- crimson/views/game_over.py +117 -0
- crimson/views/ground.py +259 -0
- crimson/views/lighting_debug.py +1166 -0
- crimson/views/particles.py +293 -0
- crimson/views/perk_menu_debug.py +430 -0
- crimson/views/perks.py +398 -0
- crimson/views/player.py +434 -0
- crimson/views/player_sprite_debug.py +314 -0
- crimson/views/projectile_fx.py +609 -0
- crimson/views/projectile_render_debug.py +393 -0
- crimson/views/projectiles.py +221 -0
- crimson/views/quest_title_overlay.py +108 -0
- crimson/views/registry.py +34 -0
- crimson/views/rush.py +16 -0
- crimson/views/small_font_debug.py +204 -0
- crimson/views/spawn_plan.py +363 -0
- crimson/views/sprites.py +214 -0
- crimson/views/survival.py +15 -0
- crimson/views/terrain.py +132 -0
- crimson/views/ui.py +123 -0
- crimson/views/wicons.py +166 -0
- crimson/weapon_sfx.py +63 -0
- crimson/weapons.py +860 -0
- crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
- crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
- crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
- crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
- grim/__init__.py +20 -0
- grim/app.py +92 -0
- grim/assets.py +231 -0
- grim/audio.py +106 -0
- grim/config.py +294 -0
- grim/console.py +737 -0
- grim/fonts/__init__.py +7 -0
- grim/fonts/grim_mono.py +111 -0
- grim/fonts/small.py +120 -0
- grim/input.py +44 -0
- grim/jaz.py +103 -0
- grim/math.py +17 -0
- grim/music.py +403 -0
- grim/paq.py +76 -0
- grim/rand.py +37 -0
- grim/sfx.py +276 -0
- grim/sfx_map.py +103 -0
- grim/terrain_render.py +840 -0
- grim/view.py +16 -0
crimson/__init__.py
ADDED
|
@@ -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
|
+
]
|
crimson/assets_fetch.py
ADDED
|
@@ -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)
|
crimson/atlas.py
ADDED
|
@@ -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]
|
crimson/audio_router.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
weapon = WEAPON_BY_ID.get(int(type_id))
|
|
115
|
+
ammo_class = weapon.ammo_class if weapon is not None else None
|
|
116
|
+
if ammo_class == 4:
|
|
117
|
+
return "sfx_shock_hit_01"
|
|
118
|
+
return self._rand_choice(rand, _BULLET_HIT_SFX)
|
|
119
|
+
|
|
120
|
+
def play_hit_sfx(
|
|
121
|
+
self,
|
|
122
|
+
hits: list[tuple[int, float, float, float, float, float, float]],
|
|
123
|
+
*,
|
|
124
|
+
game_mode: int,
|
|
125
|
+
rand: Callable[[], int],
|
|
126
|
+
beam_types: frozenset[int],
|
|
127
|
+
) -> None:
|
|
128
|
+
if self.audio is None or not hits:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
start_idx = 0
|
|
132
|
+
if (not self.demo_mode_active) and int(game_mode) != int(GameMode.RUSH):
|
|
133
|
+
if trigger_game_tune(self.audio, rand=rand) is not None:
|
|
134
|
+
start_idx = 1
|
|
135
|
+
|
|
136
|
+
end = min(len(hits), start_idx + _MAX_HIT_SFX_PER_FRAME)
|
|
137
|
+
for idx in range(start_idx, end):
|
|
138
|
+
type_id = int(hits[idx][0])
|
|
139
|
+
self.play_sfx(self._hit_sfx_for_type(type_id, beam_types=beam_types, rand=rand))
|
|
140
|
+
|
|
141
|
+
def play_death_sfx(self, deaths: tuple[object, ...], *, rand: Callable[[], int]) -> None:
|
|
142
|
+
if self.audio is None or not deaths:
|
|
143
|
+
return
|
|
144
|
+
for idx in range(min(len(deaths), _MAX_DEATH_SFX_PER_FRAME)):
|
|
145
|
+
death = deaths[idx]
|
|
146
|
+
type_id = getattr(death, "type_id", None)
|
|
147
|
+
if type_id is None:
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
creature_type = CreatureTypeId(int(type_id))
|
|
151
|
+
except ValueError:
|
|
152
|
+
continue
|
|
153
|
+
options = _CREATURE_DEATH_SFX.get(creature_type)
|
|
154
|
+
if options:
|
|
155
|
+
self.play_sfx(self._rand_choice(rand, options))
|
crimson/bonuses.py
ADDED
|
@@ -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
|
crimson/camera.py
ADDED
|
@@ -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
|
+
|