crimsonland 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +153 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +377 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +663 -0
  34. crimson/gameplay.py +2450 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1039 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1338 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +56 -0
  67. crimson/sim/world_state.py +421 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +414 -0
  86. crimson/views/bonuses.py +201 -0
  87. crimson/views/camera_debug.py +359 -0
  88. crimson/views/camera_shake.py +229 -0
  89. crimson/views/corpse_stamp_debug.py +324 -0
  90. crimson/views/decals_debug.py +739 -0
  91. crimson/views/empty.py +19 -0
  92. crimson/views/fonts.py +114 -0
  93. crimson/views/game_over.py +117 -0
  94. crimson/views/ground.py +259 -0
  95. crimson/views/lighting_debug.py +1166 -0
  96. crimson/views/particles.py +293 -0
  97. crimson/views/perk_menu_debug.py +430 -0
  98. crimson/views/perks.py +398 -0
  99. crimson/views/player.py +433 -0
  100. crimson/views/player_sprite_debug.py +314 -0
  101. crimson/views/projectile_fx.py +608 -0
  102. crimson/views/projectile_render_debug.py +407 -0
  103. crimson/views/projectiles.py +221 -0
  104. crimson/views/quest_title_overlay.py +108 -0
  105. crimson/views/registry.py +34 -0
  106. crimson/views/rush.py +16 -0
  107. crimson/views/small_font_debug.py +204 -0
  108. crimson/views/spawn_plan.py +363 -0
  109. crimson/views/sprites.py +214 -0
  110. crimson/views/survival.py +15 -0
  111. crimson/views/terrain.py +132 -0
  112. crimson/views/ui.py +123 -0
  113. crimson/views/wicons.py +166 -0
  114. crimson/weapon_sfx.py +63 -0
  115. crimson/weapons.py +860 -0
  116. crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
  117. crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
  118. crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
  119. crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
  120. grim/__init__.py +20 -0
  121. grim/app.py +92 -0
  122. grim/assets.py +231 -0
  123. grim/audio.py +106 -0
  124. grim/config.py +294 -0
  125. grim/console.py +737 -0
  126. grim/fonts/__init__.py +7 -0
  127. grim/fonts/grim_mono.py +111 -0
  128. grim/fonts/small.py +120 -0
  129. grim/input.py +44 -0
  130. grim/jaz.py +103 -0
  131. grim/math.py +17 -0
  132. grim/music.py +403 -0
  133. grim/paq.py +76 -0
  134. grim/rand.py +37 -0
  135. grim/sfx.py +276 -0
  136. grim/sfx_map.py +103 -0
  137. grim/terrain_render.py +840 -0
  138. grim/view.py +16 -0
crimson/cli.py ADDED
@@ -0,0 +1,377 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import inspect
5
+ import json
6
+ import random
7
+ import re
8
+ from pathlib import Path
9
+ from dataclasses import fields
10
+
11
+ import typer
12
+ from PIL import Image
13
+
14
+ from grim import jaz, paq
15
+ from grim.rand import Crand
16
+ from .paths import default_runtime_dir
17
+ from .creatures.spawn import SpawnEnv, build_spawn_plan, spawn_id_label
18
+ from .quests import all_quests
19
+ from .quests.types import QuestContext, QuestDefinition, SpawnEntry
20
+
21
+
22
+ app = typer.Typer(add_completion=False)
23
+
24
+ _QUEST_DEFS: dict[str, QuestDefinition] = {quest.level: quest for quest in all_quests()}
25
+ _QUEST_BUILDERS = {level: quest.builder for level, quest in _QUEST_DEFS.items()}
26
+ _QUEST_TITLES = {level: quest.title for level, quest in _QUEST_DEFS.items()}
27
+
28
+ _SEP_RE = re.compile(r"[\\/]+")
29
+
30
+
31
+ def _safe_relpath(name: str) -> Path:
32
+ parts = [p for p in _SEP_RE.split(name) if p]
33
+ if not parts:
34
+ raise ValueError("empty entry name")
35
+ for part in parts:
36
+ if part in (".", ".."):
37
+ raise ValueError(f"unsafe path part: {part!r}")
38
+ return Path(*parts)
39
+
40
+
41
+ def _extract_one(paq_path: Path, assets_root: Path) -> int:
42
+ out_root = assets_root / paq_path.stem
43
+ out_root.mkdir(parents=True, exist_ok=True)
44
+ count = 0
45
+ for name, data in paq.iter_entries(paq_path):
46
+ rel = _safe_relpath(name)
47
+ dest = out_root / rel
48
+ dest.parent.mkdir(parents=True, exist_ok=True)
49
+ suffix = dest.suffix.lower()
50
+ if suffix == ".jaz":
51
+ jaz_image = jaz.decode_jaz_bytes(data)
52
+ base = dest.with_suffix("")
53
+ jaz_image.composite_image().save(base.with_suffix(".png"))
54
+ else:
55
+ if suffix == ".tga":
56
+ img = Image.open(io.BytesIO(data))
57
+ img.save(dest.with_suffix(".png"))
58
+ else:
59
+ dest.write_bytes(data)
60
+ count += 1
61
+ return count
62
+
63
+
64
+ @app.command("extract")
65
+ def cmd_extract(game_dir: Path, assets_dir: Path) -> None:
66
+ """Extract all .paq files into a flat asset directory."""
67
+ if not game_dir.is_dir():
68
+ typer.echo(f"game dir not found: {game_dir}", err=True)
69
+ raise typer.Exit(code=1)
70
+ assets_dir.mkdir(parents=True, exist_ok=True)
71
+ paqs = sorted(game_dir.rglob("*.paq"))
72
+ if not paqs:
73
+ typer.echo(f"no .paq files under {game_dir}", err=True)
74
+ raise typer.Exit(code=1)
75
+ total = 0
76
+ for paq_path in paqs:
77
+ total += _extract_one(paq_path, assets_dir)
78
+ typer.echo(f"extracted {total} files")
79
+
80
+
81
+ def _call_builder(builder, ctx: QuestContext, rng: random.Random | None) -> list[SpawnEntry]:
82
+ params = inspect.signature(builder).parameters
83
+ if "rng" in params:
84
+ return builder(ctx, rng=rng)
85
+ return builder(ctx)
86
+
87
+
88
+ def _format_entry(idx: int, entry: SpawnEntry, *, plan_info: tuple[int, int] | None) -> str:
89
+ creature = spawn_id_label(entry.spawn_id)
90
+ plan_text = ""
91
+ if plan_info is not None:
92
+ creatures_per_spawn, spawn_slots_per_spawn = plan_info
93
+ alloc = entry.count * creatures_per_spawn
94
+ plan_text = f" alloc={alloc:3d} (x{creatures_per_spawn:2d}) slots={spawn_slots_per_spawn}"
95
+ return (
96
+ f"{idx:02d} t={entry.trigger_ms:5d} "
97
+ f"id=0x{entry.spawn_id:02x} ({entry.spawn_id:2d}) "
98
+ f"creature={creature:10s} "
99
+ f"count={entry.count:2d} "
100
+ f"x={entry.x:7.1f} y={entry.y:7.1f} heading={entry.heading:7.3f}{plan_text}"
101
+ )
102
+
103
+
104
+ def _format_id(value: int | None) -> str:
105
+ if value is None:
106
+ return "none"
107
+ return f"0x{value:02x} ({value})"
108
+
109
+
110
+ def _format_id_list(values: tuple[int, ...] | None) -> str:
111
+ if not values:
112
+ return "none"
113
+ return "[" + ", ".join(_format_id(value) for value in values) + "]"
114
+
115
+
116
+ def _format_meta(quest: QuestDefinition) -> list[str]:
117
+ builder_addr = f"0x{quest.builder_address:08x}" if quest.builder_address is not None else "unknown"
118
+ terrain_ids = _format_id_list(quest.terrain_ids)
119
+ return [
120
+ f"time_limit_ms={quest.time_limit_ms}",
121
+ f"start_weapon_id={quest.start_weapon_id}",
122
+ f"unlock_perk_id={_format_id(quest.unlock_perk_id)}",
123
+ f"unlock_weapon_id={_format_id(quest.unlock_weapon_id)}",
124
+ f"builder_address={builder_addr}",
125
+ f"terrain_ids={terrain_ids}",
126
+ ]
127
+
128
+
129
+ @app.command("quests")
130
+ def cmd_quests(
131
+ level: str = typer.Argument(..., help="quest level, e.g. 1.1"),
132
+ width: int = typer.Option(1024, help="terrain width"),
133
+ height: int = typer.Option(1024, help="terrain height"),
134
+ player_count: int = typer.Option(1, help="player count"),
135
+ seed: int | None = typer.Option(None, help="seed for randomized quests"),
136
+ sort: bool = typer.Option(False, help="sort output by trigger time"),
137
+ show_plan: bool = typer.Option(False, help="include spawn-plan allocation summary"),
138
+ ) -> None:
139
+ """Print quest spawn scripts for a given level."""
140
+ quest = _QUEST_DEFS.get(level)
141
+ if quest is None:
142
+ available = ", ".join(sorted(_QUEST_BUILDERS))
143
+ typer.echo(f"unknown level {level!r}. Available: {available}", err=True)
144
+ raise typer.Exit(code=1)
145
+ builder = quest.builder
146
+ title = quest.title
147
+ ctx = QuestContext(width=width, height=height, player_count=player_count)
148
+ rng = random.Random(seed) if seed is not None else random.Random()
149
+ entries = _call_builder(builder, ctx, rng)
150
+ if sort:
151
+ entries = sorted(entries, key=lambda e: (e.trigger_ms, e.spawn_id, e.x, e.y))
152
+ typer.echo(f"Quest {level} {title} ({len(entries)} entries)")
153
+ typer.echo("Meta: " + "; ".join(_format_meta(quest)))
154
+
155
+ plan_cache: dict[int, tuple[int, int]] = {}
156
+ if show_plan:
157
+ env = SpawnEnv(
158
+ terrain_width=float(width),
159
+ terrain_height=float(height),
160
+ demo_mode_active=True,
161
+ hardcore=False,
162
+ difficulty_level=0,
163
+ )
164
+ for entry in entries:
165
+ if entry.spawn_id in plan_cache:
166
+ continue
167
+ plan = build_spawn_plan(entry.spawn_id, (512.0, 512.0), 0.0, Crand(0), env)
168
+ plan_cache[entry.spawn_id] = (len(plan.creatures), len(plan.spawn_slots))
169
+ total_alloc = sum(entry.count * plan_cache[entry.spawn_id][0] for entry in entries)
170
+ total_slots = sum(entry.count * plan_cache[entry.spawn_id][1] for entry in entries)
171
+ typer.echo(f"Plan: total_alloc={total_alloc} total_spawn_slots={total_slots}")
172
+
173
+ for idx, entry in enumerate(entries, start=1):
174
+ typer.echo(_format_entry(idx, entry, plan_info=plan_cache.get(entry.spawn_id)))
175
+
176
+
177
+ @app.command("view")
178
+ def cmd_view(
179
+ name: str = typer.Argument(..., help="view name (e.g. empty)"),
180
+ width: int = typer.Option(1024, help="window width"),
181
+ height: int = typer.Option(768, help="window height"),
182
+ fps: int = typer.Option(60, help="target fps"),
183
+ assets_dir: Path = typer.Option(Path("artifacts") / "assets", help="assets root (default: ./artifacts/assets)"),
184
+ ) -> None:
185
+ """Launch a Raylib debug view."""
186
+ from grim.app import run_view
187
+ from grim.view import ViewContext
188
+ from .views import all_views, view_by_name
189
+
190
+ view_def = view_by_name(name)
191
+ if view_def is None:
192
+ available = ", ".join(view.name for view in all_views())
193
+ typer.echo(f"unknown view {name!r}. Available: {available}", err=True)
194
+ raise typer.Exit(code=1)
195
+ ctx = ViewContext(assets_dir=assets_dir)
196
+ params = inspect.signature(view_def.factory).parameters
197
+ if "ctx" in params:
198
+ view = view_def.factory(ctx=ctx)
199
+ else:
200
+ view = view_def.factory()
201
+ title = f"{view_def.title} — Crimsonland"
202
+ run_view(view, width=width, height=height, title=title, fps=fps)
203
+
204
+
205
+ @app.command("game")
206
+ def cmd_game(
207
+ width: int | None = typer.Option(None, help="window width (default: use crimson.cfg)"),
208
+ height: int | None = typer.Option(None, help="window height (default: use crimson.cfg)"),
209
+ fps: int = typer.Option(60, help="target fps"),
210
+ seed: int | None = typer.Option(None, help="rng seed"),
211
+ demo: bool = typer.Option(False, "--demo", help="enable shareware demo mode"),
212
+ no_intro: bool = typer.Option(False, "--no-intro", help="skip company splashes and intro music"),
213
+ base_dir: Path = typer.Option(
214
+ default_runtime_dir(),
215
+ "--base-dir",
216
+ "--runtime-dir",
217
+ help="base path for runtime files (default: per-user OS data dir; override with CRIMSON_RUNTIME_DIR)",
218
+ ),
219
+ assets_dir: Path | None = typer.Option(
220
+ None,
221
+ help="assets root (default: base-dir; missing .paq files are downloaded)",
222
+ ),
223
+ ) -> None:
224
+ """Run the reimplementation game flow."""
225
+ from .game import GameConfig, run_game
226
+
227
+ config = GameConfig(
228
+ base_dir=base_dir,
229
+ assets_dir=assets_dir,
230
+ width=width,
231
+ height=height,
232
+ fps=fps,
233
+ seed=seed,
234
+ demo_enabled=demo,
235
+ no_intro=no_intro,
236
+ )
237
+ run_game(config)
238
+
239
+
240
+ @app.command("config")
241
+ def cmd_config(
242
+ path: Path | None = typer.Option(None, help="path to crimson.cfg (default: base-dir/crimson.cfg)"),
243
+ base_dir: Path = typer.Option(
244
+ default_runtime_dir(),
245
+ "--base-dir",
246
+ "--runtime-dir",
247
+ help="base path for runtime files (default: per-user OS data dir; override with CRIMSON_RUNTIME_DIR)",
248
+ ),
249
+ ) -> None:
250
+ """Inspect crimson.cfg configuration values."""
251
+ from grim.config import CRIMSON_CFG_NAME, CRIMSON_CFG_STRUCT, load_crimson_cfg
252
+
253
+ cfg_path = path if path is not None else base_dir / CRIMSON_CFG_NAME
254
+ config = load_crimson_cfg(cfg_path)
255
+ typer.echo(f"path: {config.path}")
256
+ typer.echo(f"screen: {config.screen_width}x{config.screen_height}")
257
+ typer.echo(f"windowed: {config.windowed_flag}")
258
+ typer.echo(f"bpp: {config.screen_bpp}")
259
+ typer.echo(f"texture_scale: {config.texture_scale}")
260
+ typer.echo("fields:")
261
+ for sub in CRIMSON_CFG_STRUCT.subcons:
262
+ name = sub.name
263
+ if not name:
264
+ continue
265
+ value = config.data[name]
266
+ typer.echo(f"{name}: {_format_cfg_value(value)}")
267
+
268
+
269
+ def _format_cfg_value(value: object) -> str:
270
+ if isinstance(value, (bytes, bytearray)):
271
+ length = len(value)
272
+ prefix = value.split(b"\x00", 1)[0]
273
+ if prefix and all(32 <= b < 127 for b in prefix):
274
+ text = prefix.decode("ascii", errors="replace")
275
+ return f"{text!r} (len={length})"
276
+ return f"0x{bytes(value).hex()} (len={length})"
277
+ return str(value)
278
+
279
+
280
+ def _parse_int_auto(text: str) -> int:
281
+ try:
282
+ return int(text, 0)
283
+ except ValueError as exc:
284
+ raise typer.BadParameter(f"invalid integer: {text!r}") from exc
285
+
286
+
287
+ def _dc_to_dict(obj: object) -> dict[str, object]:
288
+ return {f.name: getattr(obj, f.name) for f in fields(obj)}
289
+
290
+
291
+ @app.command("spawn-plan")
292
+ def cmd_spawn_plan(
293
+ template: str = typer.Argument(..., help="spawn id (e.g. 0x12)"),
294
+ seed: str = typer.Option("0xBEEF", help="MSVCRT rand() seed (e.g. 0xBEEF)"),
295
+ x: float = typer.Option(512.0, help="spawn x"),
296
+ y: float = typer.Option(512.0, help="spawn y"),
297
+ heading: float = typer.Option(0.0, help="heading (radians)"),
298
+ terrain_w: float = typer.Option(1024.0, help="terrain width"),
299
+ terrain_h: float = typer.Option(1024.0, help="terrain height"),
300
+ demo_mode_active: bool = typer.Option(True, help="when true, burst effect is skipped"),
301
+ hardcore: bool = typer.Option(False, help="hardcore mode"),
302
+ difficulty: int = typer.Option(0, help="difficulty level"),
303
+ as_json: bool = typer.Option(False, "--json", help="print JSON"),
304
+ ) -> None:
305
+ """Build and print a spawn plan for a single template id."""
306
+ template_id = _parse_int_auto(template)
307
+ rng = Crand(_parse_int_auto(seed))
308
+ env = SpawnEnv(
309
+ terrain_width=terrain_w,
310
+ terrain_height=terrain_h,
311
+ demo_mode_active=demo_mode_active,
312
+ hardcore=hardcore,
313
+ difficulty_level=difficulty,
314
+ )
315
+ plan = build_spawn_plan(template_id, (x, y), heading, rng, env)
316
+ if as_json:
317
+ payload: dict[str, object] = {
318
+ "template_id": template_id,
319
+ "pos": [x, y],
320
+ "heading": heading,
321
+ "seed": _parse_int_auto(seed),
322
+ "env": {
323
+ "terrain_width": terrain_w,
324
+ "terrain_height": terrain_h,
325
+ "demo_mode_active": demo_mode_active,
326
+ "hardcore": hardcore,
327
+ "difficulty_level": difficulty,
328
+ },
329
+ "primary": plan.primary,
330
+ "creatures": [_dc_to_dict(c) for c in plan.creatures],
331
+ "spawn_slots": [_dc_to_dict(s) for s in plan.spawn_slots],
332
+ "effects": [_dc_to_dict(e) for e in plan.effects],
333
+ "rng_state": rng.state,
334
+ }
335
+ typer.echo(json.dumps(payload, indent=2, sort_keys=True))
336
+ return
337
+
338
+ typer.echo(f"template_id=0x{template_id:02x} ({template_id}) creature={spawn_id_label(template_id)}")
339
+ typer.echo(f"pos=({x:.1f},{y:.1f}) heading={heading:.6f} seed=0x{_parse_int_auto(seed):08x} rng_state=0x{rng.state:08x}")
340
+ typer.echo(
341
+ "env="
342
+ f"demo_mode_active={demo_mode_active} "
343
+ f"hardcore={hardcore} "
344
+ f"difficulty={difficulty} "
345
+ f"terrain={terrain_w:.0f}x{terrain_h:.0f}"
346
+ )
347
+ typer.echo(f"primary={plan.primary} creatures={len(plan.creatures)} slots={len(plan.spawn_slots)} effects={len(plan.effects)}")
348
+ typer.echo("")
349
+ typer.echo("creatures:")
350
+ for idx, c in enumerate(plan.creatures):
351
+ primary = "*" if idx == plan.primary else " "
352
+ typer.echo(
353
+ f"{primary}{idx:02d} type={c.type_id!s:14s} ai={c.ai_mode:2d} flags=0x{int(c.flags):03x} "
354
+ f"pos=({c.pos_x:7.1f},{c.pos_y:7.1f}) health={c.health!s:>6s} size={c.size!s:>6s} link={c.ai_link_parent!s:>3s} "
355
+ f"slot={c.spawn_slot!s:>3s}"
356
+ )
357
+ if plan.spawn_slots:
358
+ typer.echo("")
359
+ typer.echo("spawn_slots:")
360
+ for idx, slot in enumerate(plan.spawn_slots):
361
+ typer.echo(
362
+ f"{idx:02d} owner={slot.owner_creature:02d} timer={slot.timer:.2f} count={slot.count:3d} "
363
+ f"limit={slot.limit:3d} interval={slot.interval:.3f} child=0x{slot.child_template_id:02x}"
364
+ )
365
+ if plan.effects:
366
+ typer.echo("")
367
+ typer.echo("effects:")
368
+ for fx in plan.effects:
369
+ typer.echo(f"burst x={fx.x:.1f} y={fx.y:.1f} count={fx.count}")
370
+
371
+
372
+ def main(argv: list[str] | None = None) -> None:
373
+ app(prog_name="crimson", args=argv)
374
+
375
+
376
+ if __name__ == "__main__":
377
+ main()
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ """Creature simulation helpers (structs, spawning, animation, AI).
4
+
5
+ This package will grow as we port `creature_*` logic from the original binary.
6
+ """
7
+
8
+ __all__ = ["ai", "anim", "spawn", "runtime"]
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ """Creature AI helpers.
4
+
5
+ Ported from `creature_update_all` (`FUN_00426220`).
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ import math
10
+ from typing import Callable, Protocol, Sequence
11
+
12
+ from .spawn import CreatureFlags
13
+
14
+ __all__ = [
15
+ "CreatureAIUpdate",
16
+ "creature_ai7_tick_link_timer",
17
+ "creature_ai_update_target",
18
+ ]
19
+
20
+
21
+ class PositionLike(Protocol):
22
+ x: float
23
+ y: float
24
+
25
+
26
+ class CreatureLinkLike(PositionLike, Protocol):
27
+ hp: float
28
+
29
+
30
+ class CreatureAIStateLike(CreatureLinkLike, Protocol):
31
+ flags: CreatureFlags
32
+ ai_mode: int
33
+ link_index: int
34
+ target_offset_x: float | None
35
+ target_offset_y: float | None
36
+ phase_seed: float
37
+ orbit_angle: float
38
+ orbit_radius: float
39
+ heading: float
40
+
41
+ target_x: float
42
+ target_y: float
43
+ target_heading: float
44
+ force_target: int
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class CreatureAIUpdate:
49
+ move_scale: float
50
+ self_damage: float | None = None
51
+
52
+
53
+ def creature_ai7_tick_link_timer(creature: CreatureAIStateLike, *, dt_ms: int, rand: Callable[[], int]) -> None:
54
+ """Update AI7's link-index timer behavior (flag 0x80).
55
+
56
+ In the original, this runs regardless of the current ai_mode; when the timer
57
+ flips from negative to non-negative, ai_mode is forced to 7 for a short hold.
58
+ """
59
+
60
+ if not (creature.flags & CreatureFlags.AI7_LINK_TIMER):
61
+ return
62
+
63
+ if creature.link_index < 0:
64
+ creature.link_index += dt_ms
65
+ if creature.link_index >= 0:
66
+ creature.ai_mode = 7
67
+ creature.link_index = (rand() & 0x1FF) + 500
68
+ return
69
+
70
+ creature.link_index -= dt_ms
71
+ if creature.link_index < 1:
72
+ creature.link_index = -700 - (rand() & 0x3FF)
73
+
74
+
75
+ def resolve_live_link(creatures: Sequence[CreatureLinkLike], link_index: int) -> CreatureLinkLike | None:
76
+ if 0 <= link_index < len(creatures) and creatures[link_index].hp > 0.0:
77
+ return creatures[link_index]
78
+ return None
79
+
80
+
81
+ def creature_ai_update_target(
82
+ creature: CreatureAIStateLike,
83
+ *,
84
+ player_x: float,
85
+ player_y: float,
86
+ creatures: Sequence[CreatureLinkLike],
87
+ dt: float,
88
+ ) -> CreatureAIUpdate:
89
+ """Compute the target position + heading for one creature.
90
+
91
+ Updates:
92
+ - `target_x/target_y`
93
+ - `target_heading`
94
+ - `force_target`
95
+ - `ai_mode` (may reset to 0 in some modes)
96
+ - `orbit_radius` (AI7 non-link timer uses it as a countdown)
97
+ """
98
+
99
+ dx = player_x - creature.x
100
+ dy = player_y - creature.y
101
+ dist_to_player = math.hypot(dx, dy)
102
+
103
+ orbit_phase = float(int(creature.phase_seed)) * 3.7 * math.pi
104
+ move_scale = 1.0
105
+ self_damage: float | None = None
106
+
107
+ creature.force_target = 0
108
+
109
+ ai_mode = creature.ai_mode
110
+ if ai_mode == 0:
111
+ if dist_to_player > 800.0:
112
+ creature.target_x = player_x
113
+ creature.target_y = player_y
114
+ else:
115
+ creature.target_x = player_x + math.cos(orbit_phase) * dist_to_player * 0.85
116
+ creature.target_y = player_y + math.sin(orbit_phase) * dist_to_player * 0.85
117
+ elif ai_mode == 8:
118
+ creature.target_x = player_x + math.cos(orbit_phase) * dist_to_player * 0.9
119
+ creature.target_y = player_y + math.sin(orbit_phase) * dist_to_player * 0.9
120
+ elif ai_mode == 1:
121
+ if dist_to_player > 800.0:
122
+ creature.target_x = player_x
123
+ creature.target_y = player_y
124
+ else:
125
+ creature.target_x = player_x + math.cos(orbit_phase) * dist_to_player * 0.55
126
+ creature.target_y = player_y + math.sin(orbit_phase) * dist_to_player * 0.55
127
+ elif ai_mode == 3:
128
+ link = resolve_live_link(creatures, creature.link_index)
129
+ if link is not None:
130
+ creature.target_x = link.x + float(creature.target_offset_x or 0.0)
131
+ creature.target_y = link.y + float(creature.target_offset_y or 0.0)
132
+ else:
133
+ creature.ai_mode = 0
134
+ elif ai_mode == 5:
135
+ link = resolve_live_link(creatures, creature.link_index)
136
+ if link is not None:
137
+ creature.target_x = link.x + float(creature.target_offset_x or 0.0)
138
+ creature.target_y = link.y + float(creature.target_offset_y or 0.0)
139
+ dist_to_target = math.hypot(creature.target_x - creature.x, creature.target_y - creature.y)
140
+ if dist_to_target <= 64.0:
141
+ move_scale = dist_to_target * 0.015625
142
+ else:
143
+ creature.ai_mode = 0
144
+ self_damage = 1000.0
145
+
146
+ ai_mode = creature.ai_mode
147
+ if ai_mode == 4:
148
+ link = resolve_live_link(creatures, creature.link_index)
149
+ if link is None:
150
+ creature.ai_mode = 0
151
+ self_damage = 1000.0
152
+ elif dist_to_player > 800.0:
153
+ creature.target_x = player_x
154
+ creature.target_y = player_y
155
+ else:
156
+ creature.target_x = player_x + math.cos(orbit_phase) * dist_to_player * 0.85
157
+ creature.target_y = player_y + math.sin(orbit_phase) * dist_to_player * 0.85
158
+ elif ai_mode == 7:
159
+ if (creature.flags & CreatureFlags.AI7_LINK_TIMER) and creature.link_index > 0:
160
+ creature.target_x = creature.x
161
+ creature.target_y = creature.y
162
+ elif not (creature.flags & CreatureFlags.AI7_LINK_TIMER) and creature.orbit_radius > 0.0:
163
+ creature.target_x = creature.x
164
+ creature.target_y = creature.y
165
+ creature.orbit_radius -= dt
166
+ else:
167
+ creature.ai_mode = 0
168
+ elif ai_mode == 6:
169
+ link = resolve_live_link(creatures, creature.link_index)
170
+ if link is None:
171
+ creature.ai_mode = 0
172
+ else:
173
+ angle = float(creature.orbit_angle) + float(creature.heading)
174
+ creature.target_x = link.x + math.cos(angle) * float(creature.orbit_radius)
175
+ creature.target_y = link.y + math.sin(angle) * float(creature.orbit_radius)
176
+
177
+ dist_to_target = math.hypot(creature.target_x - creature.x, creature.target_y - creature.y)
178
+ if dist_to_target < 40.0 or dist_to_target > 400.0:
179
+ creature.force_target = 1
180
+
181
+ if creature.force_target or creature.ai_mode == 2:
182
+ creature.target_x = player_x
183
+ creature.target_y = player_y
184
+
185
+ creature.target_heading = math.atan2(creature.target_y - creature.y, creature.target_x - creature.x) + math.pi / 2.0
186
+ return CreatureAIUpdate(move_scale=move_scale, self_damage=self_damage)