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
grim/music.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
import pyray as rl
|
|
9
|
+
|
|
10
|
+
from .console import ConsoleState
|
|
11
|
+
from . import paq
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MUSIC_PAK_NAME = "music.paq"
|
|
15
|
+
MUSIC_TRACKS: dict[str, tuple[str, ...]] = {
|
|
16
|
+
"intro": ("music/intro.ogg", "intro.ogg"),
|
|
17
|
+
"shortie_monk": ("music/shortie_monk.ogg", "shortie_monk.ogg"),
|
|
18
|
+
"crimson_theme": ("music/crimson_theme.ogg", "crimson_theme.ogg"),
|
|
19
|
+
"crimsonquest": ("music/crimsonquest.ogg", "crimsonquest.ogg"),
|
|
20
|
+
"gt1_ingame": ("music/gt1_ingame.ogg", "gt1_ingame.ogg"),
|
|
21
|
+
"gt2_harppen": ("music/gt2_harppen.ogg", "gt2_harppen.ogg"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class MusicState:
|
|
27
|
+
ready: bool
|
|
28
|
+
enabled: bool
|
|
29
|
+
volume: float
|
|
30
|
+
tracks: dict[str, rl.Music]
|
|
31
|
+
active_track: str | None
|
|
32
|
+
playbacks: dict[str, "TrackPlayback"] = field(default_factory=dict)
|
|
33
|
+
queue: list[str] = field(default_factory=list)
|
|
34
|
+
# Mirrors the original game's "start a random game tune on first hit" gate.
|
|
35
|
+
game_tune_started: bool = False
|
|
36
|
+
game_tune_track: str | None = None
|
|
37
|
+
track_ids: dict[str, int] = field(default_factory=dict)
|
|
38
|
+
next_track_id: int = 0
|
|
39
|
+
paq_entries: dict[str, bytes] | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def init_music_state(*, ready: bool, enabled: bool, volume: float) -> MusicState:
|
|
43
|
+
return MusicState(
|
|
44
|
+
ready=ready,
|
|
45
|
+
enabled=enabled,
|
|
46
|
+
volume=float(volume),
|
|
47
|
+
tracks={},
|
|
48
|
+
active_track=None,
|
|
49
|
+
playbacks={},
|
|
50
|
+
queue=[],
|
|
51
|
+
game_tune_started=False,
|
|
52
|
+
game_tune_track=None,
|
|
53
|
+
track_ids={},
|
|
54
|
+
next_track_id=0,
|
|
55
|
+
paq_entries=None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_music_tracks(state: MusicState, assets_dir: Path, console: ConsoleState) -> None:
|
|
60
|
+
if not state.ready or not state.enabled:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
music_dir = assets_dir / "music"
|
|
64
|
+
if music_dir.exists() and music_dir.is_dir():
|
|
65
|
+
loaded = 0
|
|
66
|
+
for track_name, candidates in MUSIC_TRACKS.items():
|
|
67
|
+
music = None
|
|
68
|
+
for candidate in candidates:
|
|
69
|
+
path = assets_dir / candidate
|
|
70
|
+
if not path.exists():
|
|
71
|
+
continue
|
|
72
|
+
music = rl.load_music_stream(str(path))
|
|
73
|
+
if music is not None:
|
|
74
|
+
break
|
|
75
|
+
if music is None:
|
|
76
|
+
raise FileNotFoundError(f"audio: missing music file for track '{track_name}' in {music_dir}")
|
|
77
|
+
rl.set_music_volume(music, state.volume)
|
|
78
|
+
state.tracks[track_name] = music
|
|
79
|
+
loaded += 1
|
|
80
|
+
state.track_ids = {name: idx for idx, name in enumerate(state.tracks.keys())}
|
|
81
|
+
state.next_track_id = len(state.track_ids)
|
|
82
|
+
state.paq_entries = None
|
|
83
|
+
console.log.log(f"audio: music tracks loaded {loaded}/{len(MUSIC_TRACKS)} from {music_dir}")
|
|
84
|
+
console.log.flush()
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
paq_path = assets_dir / MUSIC_PAK_NAME
|
|
88
|
+
if not paq_path.exists():
|
|
89
|
+
raise FileNotFoundError(f"audio: missing {MUSIC_PAK_NAME} in {assets_dir}")
|
|
90
|
+
|
|
91
|
+
entries: dict[str, bytes] = {}
|
|
92
|
+
for name, data in paq.iter_entries(paq_path):
|
|
93
|
+
entries[name.replace("\\", "/")] = data
|
|
94
|
+
|
|
95
|
+
loaded = 0
|
|
96
|
+
for track_name, candidates in MUSIC_TRACKS.items():
|
|
97
|
+
data = None
|
|
98
|
+
for candidate in candidates:
|
|
99
|
+
data = entries.get(candidate)
|
|
100
|
+
if data is not None:
|
|
101
|
+
break
|
|
102
|
+
if data is None:
|
|
103
|
+
raise FileNotFoundError(f"audio: missing music entry for track '{track_name}' in {MUSIC_PAK_NAME}")
|
|
104
|
+
music = rl.load_music_stream_from_memory(".ogg", data, len(data))
|
|
105
|
+
rl.set_music_volume(music, state.volume)
|
|
106
|
+
state.tracks[track_name] = music
|
|
107
|
+
loaded += 1
|
|
108
|
+
state.track_ids = {name: idx for idx, name in enumerate(state.tracks.keys())}
|
|
109
|
+
state.next_track_id = len(state.track_ids)
|
|
110
|
+
state.paq_entries = entries
|
|
111
|
+
|
|
112
|
+
console.log.log(f"audio: music tracks loaded {loaded}/{len(MUSIC_TRACKS)} from {paq_path}")
|
|
113
|
+
console.log.flush()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _normalize_track_key(rel_path: str) -> str:
|
|
117
|
+
name = Path(rel_path.replace("\\", "/")).name
|
|
118
|
+
if name.lower().endswith(".ogg"):
|
|
119
|
+
return name[:-4]
|
|
120
|
+
return name
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ensure_music_entries(state: MusicState, assets_dir: Path) -> dict[str, bytes] | None:
|
|
124
|
+
if state.paq_entries is not None:
|
|
125
|
+
return state.paq_entries
|
|
126
|
+
paq_path = assets_dir / MUSIC_PAK_NAME
|
|
127
|
+
if not paq_path.exists():
|
|
128
|
+
return None
|
|
129
|
+
entries: dict[str, bytes] = {}
|
|
130
|
+
for name, data in paq.iter_entries(paq_path):
|
|
131
|
+
entries[name.replace("\\", "/")] = data
|
|
132
|
+
state.paq_entries = entries
|
|
133
|
+
return entries
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_music_track(
|
|
137
|
+
state: MusicState,
|
|
138
|
+
assets_dir: Path,
|
|
139
|
+
rel_path: str,
|
|
140
|
+
*,
|
|
141
|
+
console: ConsoleState | None = None,
|
|
142
|
+
) -> tuple[str, int] | None:
|
|
143
|
+
normalized = rel_path.replace("\\", "/")
|
|
144
|
+
track_id = state.next_track_id
|
|
145
|
+
state.next_track_id += 1
|
|
146
|
+
if not state.ready or not state.enabled:
|
|
147
|
+
if console is not None:
|
|
148
|
+
console.log.log(f"SFX Tune {track_id} <- '{normalized}' FAILED")
|
|
149
|
+
return None
|
|
150
|
+
key = _normalize_track_key(normalized)
|
|
151
|
+
existing = state.tracks.get(key)
|
|
152
|
+
if existing is not None:
|
|
153
|
+
existing_id = state.track_ids.get(key)
|
|
154
|
+
if existing_id is None:
|
|
155
|
+
state.track_ids[key] = track_id
|
|
156
|
+
existing_id = track_id
|
|
157
|
+
if console is not None:
|
|
158
|
+
console.log.log(f"SFX Tune {existing_id} <- '{normalized}' ok")
|
|
159
|
+
return key, int(existing_id)
|
|
160
|
+
music_stream = None
|
|
161
|
+
file_path = assets_dir / normalized
|
|
162
|
+
if file_path.is_file():
|
|
163
|
+
music_stream = rl.load_music_stream(str(file_path))
|
|
164
|
+
else:
|
|
165
|
+
entries = _ensure_music_entries(state, assets_dir)
|
|
166
|
+
if entries is not None:
|
|
167
|
+
data = entries.get(normalized)
|
|
168
|
+
if data is None:
|
|
169
|
+
data = entries.get(Path(normalized).name)
|
|
170
|
+
if data is not None:
|
|
171
|
+
music_stream = rl.load_music_stream_from_memory(".ogg", data, len(data))
|
|
172
|
+
if music_stream is None:
|
|
173
|
+
if console is not None:
|
|
174
|
+
console.log.log(f"SFX Tune {track_id} <- '{normalized}' FAILED")
|
|
175
|
+
return None
|
|
176
|
+
rl.set_music_volume(music_stream, state.volume)
|
|
177
|
+
state.tracks[key] = music_stream
|
|
178
|
+
state.track_ids[key] = track_id
|
|
179
|
+
if console is not None:
|
|
180
|
+
console.log.log(f"SFX Tune {track_id} <- '{normalized}' ok")
|
|
181
|
+
return key, track_id
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def queue_track(state: MusicState, track_key: str) -> None:
|
|
185
|
+
if not state.ready or not state.enabled:
|
|
186
|
+
return
|
|
187
|
+
state.queue.append(track_key)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(slots=True)
|
|
191
|
+
class TrackPlayback:
|
|
192
|
+
"""Runtime playback state for a loaded music stream."""
|
|
193
|
+
|
|
194
|
+
music: rl.Music
|
|
195
|
+
volume: float
|
|
196
|
+
muted: bool
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_MUSIC_MAX_DT = 0.1
|
|
200
|
+
_MUSIC_FADE_IN_PER_SEC = 1.0
|
|
201
|
+
_MUSIC_FADE_OUT_PER_SEC = 0.5
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def play_music(state: MusicState, track_name: str) -> None:
|
|
205
|
+
if not state.ready or not state.enabled:
|
|
206
|
+
return
|
|
207
|
+
music = state.tracks.get(track_name)
|
|
208
|
+
if music is None:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Original behavior uses an "exclusive" music channel: starting a new track
|
|
212
|
+
# mutes (fades out) any currently-unmuted music.
|
|
213
|
+
for key, pb in state.playbacks.items():
|
|
214
|
+
if key != track_name:
|
|
215
|
+
pb.muted = True
|
|
216
|
+
|
|
217
|
+
pb = state.playbacks.get(track_name)
|
|
218
|
+
if pb is None:
|
|
219
|
+
pb = TrackPlayback(music=music, volume=0.0, muted=False)
|
|
220
|
+
state.playbacks[track_name] = pb
|
|
221
|
+
else:
|
|
222
|
+
pb.muted = False
|
|
223
|
+
|
|
224
|
+
playing = False
|
|
225
|
+
try:
|
|
226
|
+
playing = bool(rl.is_music_stream_playing(music))
|
|
227
|
+
except Exception:
|
|
228
|
+
playing = False
|
|
229
|
+
|
|
230
|
+
# Mirror `sfx_play_exclusive`: if the track isn't already audible, start it
|
|
231
|
+
# immediately at the target volume. Otherwise, let the fade logic bring it
|
|
232
|
+
# back up (resume behavior).
|
|
233
|
+
if (not playing) or pb.volume <= 0.0:
|
|
234
|
+
pb.volume = state.volume
|
|
235
|
+
try:
|
|
236
|
+
rl.set_music_volume(music, pb.volume)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
try:
|
|
240
|
+
rl.play_music_stream(music)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
state.active_track = track_name
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def stop_music(state: MusicState) -> None:
|
|
248
|
+
if not state.ready or not state.enabled:
|
|
249
|
+
return
|
|
250
|
+
# Mirror `sfx_mute_all`: mark everything muted and let `update_music` ramp it down.
|
|
251
|
+
for pb in state.playbacks.values():
|
|
252
|
+
pb.muted = True
|
|
253
|
+
state.active_track = None
|
|
254
|
+
state.game_tune_started = False
|
|
255
|
+
state.game_tune_track = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def trigger_game_tune(state: MusicState, *, rand: Callable[[], int] | None = None) -> str | None:
|
|
259
|
+
"""Start a random queued game tune, if it hasn't been triggered yet.
|
|
260
|
+
|
|
261
|
+
Returns the track key if playback started, otherwise None.
|
|
262
|
+
"""
|
|
263
|
+
if not state.ready or not state.enabled:
|
|
264
|
+
return None
|
|
265
|
+
if state.game_tune_started:
|
|
266
|
+
return None
|
|
267
|
+
if not state.queue:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
if rand is None:
|
|
271
|
+
track_key = random.choice(state.queue)
|
|
272
|
+
else:
|
|
273
|
+
idx = int(rand()) % len(state.queue)
|
|
274
|
+
track_key = state.queue[idx]
|
|
275
|
+
|
|
276
|
+
if track_key not in state.tracks:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
play_music(state, track_key)
|
|
280
|
+
state.game_tune_started = True
|
|
281
|
+
state.game_tune_track = track_key
|
|
282
|
+
return track_key
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def update_music(state: MusicState, dt: float) -> None:
|
|
286
|
+
if not state.ready or not state.enabled:
|
|
287
|
+
return
|
|
288
|
+
frame_dt = float(dt)
|
|
289
|
+
if frame_dt <= 0.0:
|
|
290
|
+
return
|
|
291
|
+
if frame_dt > _MUSIC_MAX_DT:
|
|
292
|
+
frame_dt = _MUSIC_MAX_DT
|
|
293
|
+
|
|
294
|
+
target_volume = float(state.volume)
|
|
295
|
+
if target_volume <= 0.0:
|
|
296
|
+
# Original behavior: global music volume at 0 stops playback immediately.
|
|
297
|
+
for track_key in list(state.playbacks.keys()):
|
|
298
|
+
pb = state.playbacks.pop(track_key, None)
|
|
299
|
+
if pb is None:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
rl.set_music_volume(pb.music, 0.0)
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
try:
|
|
306
|
+
rl.stop_music_stream(pb.music)
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
for track_key in list(state.playbacks.keys()):
|
|
312
|
+
pb = state.playbacks.get(track_key)
|
|
313
|
+
if pb is None:
|
|
314
|
+
continue
|
|
315
|
+
music = pb.music
|
|
316
|
+
|
|
317
|
+
# Keep streams serviced while they play.
|
|
318
|
+
try:
|
|
319
|
+
if rl.is_music_stream_playing(music):
|
|
320
|
+
rl.update_music_stream(music)
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
muted = pb.muted or target_volume <= 0.0
|
|
325
|
+
if muted:
|
|
326
|
+
pb.volume -= frame_dt * _MUSIC_FADE_OUT_PER_SEC
|
|
327
|
+
if pb.volume <= 0.0:
|
|
328
|
+
pb.volume = 0.0
|
|
329
|
+
try:
|
|
330
|
+
rl.set_music_volume(music, 0.0)
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
try:
|
|
334
|
+
rl.stop_music_stream(music)
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
state.playbacks.pop(track_key, None)
|
|
338
|
+
continue
|
|
339
|
+
try:
|
|
340
|
+
rl.set_music_volume(music, pb.volume)
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
# Unmuted track: ensure it stays playing and ramp toward target volume.
|
|
346
|
+
try:
|
|
347
|
+
if not rl.is_music_stream_playing(music):
|
|
348
|
+
rl.play_music_stream(music)
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
if pb.volume > target_volume:
|
|
353
|
+
pb.volume = target_volume
|
|
354
|
+
elif pb.volume < target_volume:
|
|
355
|
+
pb.volume = min(target_volume, pb.volume + frame_dt * _MUSIC_FADE_IN_PER_SEC)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
rl.set_music_volume(music, pb.volume)
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def set_music_volume(state: MusicState, volume: float) -> None:
|
|
364
|
+
volume = float(volume)
|
|
365
|
+
if volume < 0.0:
|
|
366
|
+
volume = 0.0
|
|
367
|
+
if volume > 1.0:
|
|
368
|
+
volume = 1.0
|
|
369
|
+
state.volume = volume
|
|
370
|
+
if not state.ready or not state.enabled:
|
|
371
|
+
return
|
|
372
|
+
# Mirror original: volume decreases take effect immediately; increases are ramped
|
|
373
|
+
# by `update_music`.
|
|
374
|
+
for pb in state.playbacks.values():
|
|
375
|
+
if pb.muted:
|
|
376
|
+
continue
|
|
377
|
+
if pb.volume > state.volume:
|
|
378
|
+
pb.volume = state.volume
|
|
379
|
+
try:
|
|
380
|
+
rl.set_music_volume(pb.music, pb.volume)
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def shutdown_music(state: MusicState) -> None:
|
|
386
|
+
if not state.ready:
|
|
387
|
+
return
|
|
388
|
+
for pb in list(state.playbacks.values()):
|
|
389
|
+
try:
|
|
390
|
+
rl.stop_music_stream(pb.music)
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
state.playbacks.clear()
|
|
394
|
+
for music in state.tracks.values():
|
|
395
|
+
try:
|
|
396
|
+
rl.stop_music_stream(music)
|
|
397
|
+
rl.unload_music_stream(music)
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
state.tracks.clear()
|
|
401
|
+
state.active_track = None
|
|
402
|
+
state.game_tune_started = False
|
|
403
|
+
state.game_tune_track = None
|
grim/paq.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
PAQ archive format (Crimsonland).
|
|
5
|
+
|
|
6
|
+
File layout:
|
|
7
|
+
- magic: 4 bytes, ASCII "paq\\0"
|
|
8
|
+
- entries: repeated until EOF
|
|
9
|
+
- name: NUL-terminated UTF-8 string (relative path)
|
|
10
|
+
- size: u32 little-endian payload size
|
|
11
|
+
- payload: raw file bytes of length `size`
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Iterable, Iterator
|
|
16
|
+
|
|
17
|
+
from construct import Bytes, Const, CString, GreedyRange, Int32ul, Struct
|
|
18
|
+
|
|
19
|
+
MAGIC = b"paq\x00"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PAQ_ENTRY = Struct(
|
|
23
|
+
"name" / CString("utf8"),
|
|
24
|
+
"size" / Int32ul,
|
|
25
|
+
"payload" / Bytes(lambda ctx: ctx.size),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
PAQ = Struct(
|
|
29
|
+
"magic" / Const(MAGIC),
|
|
30
|
+
"entries" / GreedyRange(PAQ_ENTRY),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def iter_entries_bytes(data: bytes) -> Iterator[tuple[str, bytes]]:
|
|
35
|
+
parsed = PAQ.parse(data)
|
|
36
|
+
for entry in parsed.entries:
|
|
37
|
+
yield entry.name, entry.payload
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def iter_entries(source: str | Path) -> Iterator[tuple[str, bytes]]:
|
|
41
|
+
data = Path(source).read_bytes()
|
|
42
|
+
yield from iter_entries_bytes(data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_paq(source: str | Path) -> list[tuple[str, bytes]]:
|
|
46
|
+
return list(iter_entries(source))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def decode_bytes(data: bytes) -> list[tuple[str, bytes]]:
|
|
50
|
+
return list(iter_entries_bytes(data))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_entries(entries: Iterable[tuple[str, bytes]]) -> bytes:
|
|
54
|
+
built_entries = []
|
|
55
|
+
for name, data in entries:
|
|
56
|
+
if isinstance(name, Path):
|
|
57
|
+
name = str(name)
|
|
58
|
+
if isinstance(data, memoryview):
|
|
59
|
+
data = data.tobytes()
|
|
60
|
+
built_entries.append(
|
|
61
|
+
{
|
|
62
|
+
"name": str(name),
|
|
63
|
+
"size": len(data),
|
|
64
|
+
"payload": bytes(data),
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
return PAQ.build({"magic": MAGIC, "entries": built_entries})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_paq(dest: str | Path, entries: Iterable[tuple[str, bytes]]) -> None:
|
|
71
|
+
data = build_entries(entries)
|
|
72
|
+
Path(dest).write_bytes(data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def encode_bytes(entries: Iterable[tuple[str, bytes]]) -> bytes:
|
|
76
|
+
return build_entries(entries)
|
grim/rand.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
CRT_RAND_MULT = 214013
|
|
6
|
+
CRT_RAND_INC = 2531011
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CrtRand:
|
|
10
|
+
"""MSVCRT-compatible `rand()` LCG used by the original game.
|
|
11
|
+
|
|
12
|
+
Matches:
|
|
13
|
+
seed = seed * 214013 + 2531011
|
|
14
|
+
return (seed >> 16) & 0x7fff
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("_state",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, seed: int | None = None) -> None:
|
|
20
|
+
if seed is None:
|
|
21
|
+
seed = int.from_bytes(os.urandom(4), "little")
|
|
22
|
+
self._state = seed & 0xFFFFFFFF
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def state(self) -> int:
|
|
26
|
+
return self._state
|
|
27
|
+
|
|
28
|
+
def srand(self, seed: int) -> None:
|
|
29
|
+
self._state = seed & 0xFFFFFFFF
|
|
30
|
+
|
|
31
|
+
def rand(self) -> int:
|
|
32
|
+
self._state = (self._state * CRT_RAND_MULT + CRT_RAND_INC) & 0xFFFFFFFF
|
|
33
|
+
return (self._state >> 16) & 0x7FFF
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Crand(CrtRand):
|
|
37
|
+
"""Backward-compatible name for the MSVCRT LCG."""
|