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.
- crimson/__init__.py +24 -0
- crimson/assets_fetch.py +60 -0
- crimson/atlas.py +92 -0
- crimson/audio_router.py +153 -0
- crimson/bonuses.py +167 -0
- crimson/camera.py +75 -0
- crimson/cli.py +377 -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 +663 -0
- crimson/gameplay.py +2450 -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 +1039 -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 +1338 -0
- crimson/sim/__init__.py +1 -0
- crimson/sim/world_defs.py +56 -0
- crimson/sim/world_state.py +421 -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 +414 -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 +433 -0
- crimson/views/player_sprite_debug.py +314 -0
- crimson/views/projectile_fx.py +608 -0
- crimson/views/projectile_render_debug.py +407 -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.dev1.dist-info/METADATA +9 -0
- crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
- crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
- crimsonland-0.1.0.dev1.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/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()
|
crimson/creatures/ai.py
ADDED
|
@@ -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)
|