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
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Creature realtime simulation glue.
|
|
4
|
+
|
|
5
|
+
This module materializes pure spawn plans (`creatures.spawn`) into a fixed-size
|
|
6
|
+
runtime pool and advances creatures each frame using the AI helpers.
|
|
7
|
+
|
|
8
|
+
It is intentionally minimal: the goal is to unblock a playable Survival loop,
|
|
9
|
+
not to perfectly match every edge case in `creature_update_all`.
|
|
10
|
+
See: `docs/creatures/update.md`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
import math
|
|
15
|
+
from typing import Callable, Sequence
|
|
16
|
+
|
|
17
|
+
from grim.rand import Crand
|
|
18
|
+
from ..effects import FxQueue, FxQueueRotated
|
|
19
|
+
from ..gameplay import GameplayState, PlayerState, award_experience, perk_active
|
|
20
|
+
from ..perks import PerkId
|
|
21
|
+
from ..player_damage import player_take_damage
|
|
22
|
+
from .ai import creature_ai7_tick_link_timer, creature_ai_update_target
|
|
23
|
+
from .spawn import (
|
|
24
|
+
CreatureFlags,
|
|
25
|
+
CreatureInit,
|
|
26
|
+
SpawnEnv,
|
|
27
|
+
SpawnPlan,
|
|
28
|
+
SpawnSlotInit,
|
|
29
|
+
build_spawn_plan,
|
|
30
|
+
resolve_tint,
|
|
31
|
+
tick_spawn_slot,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CONTACT_DAMAGE_PERIOD",
|
|
36
|
+
"CREATURE_POOL_SIZE",
|
|
37
|
+
"CreatureDeath",
|
|
38
|
+
"CreaturePool",
|
|
39
|
+
"CreatureState",
|
|
40
|
+
"CreatureUpdateResult",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
CREATURE_POOL_SIZE = 0x180
|
|
45
|
+
|
|
46
|
+
CONTACT_DAMAGE_PERIOD = 0.5
|
|
47
|
+
|
|
48
|
+
# The native uses per-type speed scaling; until we port the exact table, keep a
|
|
49
|
+
# single global factor (native multiplies `move_speed * 30.0` in creature_update_all).
|
|
50
|
+
CREATURE_SPEED_SCALE = 30.0
|
|
51
|
+
|
|
52
|
+
# Base heading turn rate multiplier (angle_approach clamps by frame_dt internally).
|
|
53
|
+
CREATURE_TURN_RATE_SCALE = 4.0 / 3.0
|
|
54
|
+
|
|
55
|
+
# Native uses hitbox_size as a lifecycle sentinel:
|
|
56
|
+
# - 16.0 means "alive" (normal AI/movement/anim update)
|
|
57
|
+
# - once HP <= 0 it ramps down quickly and drives the death slide + corpse decal timing.
|
|
58
|
+
CREATURE_HITBOX_ALIVE = 16.0
|
|
59
|
+
CREATURE_DEATH_TIMER_DECAY = 28.0
|
|
60
|
+
CREATURE_CORPSE_FADE_DECAY = 20.0
|
|
61
|
+
CREATURE_CORPSE_DESPAWN_HITBOX = -10.0
|
|
62
|
+
CREATURE_DEATH_SLIDE_SCALE = 9.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
|
66
|
+
if value < lo:
|
|
67
|
+
return lo
|
|
68
|
+
if value > hi:
|
|
69
|
+
return hi
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _wrap_angle(angle: float) -> float:
|
|
74
|
+
return (angle + math.pi) % math.tau - math.pi
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _angle_approach(current: float, target: float, rate: float, dt: float) -> float:
|
|
78
|
+
delta = _wrap_angle(target - current)
|
|
79
|
+
step_scale = min(1.0, abs(delta))
|
|
80
|
+
step = float(dt) * step_scale * float(rate)
|
|
81
|
+
if delta >= 0.0:
|
|
82
|
+
current += step
|
|
83
|
+
else:
|
|
84
|
+
current -= step
|
|
85
|
+
return _wrap_angle(current)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
|
|
89
|
+
dx = x1 - x0
|
|
90
|
+
dy = y1 - y0
|
|
91
|
+
return dx * dx + dy * dy
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _owner_id_to_player_index(owner_id: int) -> int | None:
|
|
95
|
+
# Native uses `-1/-2/-3/-4` for player indices and `-100` as a player-owned sentinel.
|
|
96
|
+
if owner_id == -100:
|
|
97
|
+
return 0
|
|
98
|
+
if owner_id < 0:
|
|
99
|
+
return -1 - owner_id
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(slots=True)
|
|
104
|
+
class CreatureState:
|
|
105
|
+
# Core identity/alive flags.
|
|
106
|
+
active: bool = False
|
|
107
|
+
type_id: int = 0
|
|
108
|
+
|
|
109
|
+
# Movement / AI.
|
|
110
|
+
x: float = 0.0
|
|
111
|
+
y: float = 0.0
|
|
112
|
+
vel_x: float = 0.0
|
|
113
|
+
vel_y: float = 0.0
|
|
114
|
+
heading: float = 0.0
|
|
115
|
+
target_heading: float = 0.0
|
|
116
|
+
force_target: int = 0
|
|
117
|
+
target_x: float = 0.0
|
|
118
|
+
target_y: float = 0.0
|
|
119
|
+
target_player: int = 0
|
|
120
|
+
ai_mode: int = 0
|
|
121
|
+
flags: CreatureFlags = CreatureFlags(0)
|
|
122
|
+
|
|
123
|
+
link_index: int = 0
|
|
124
|
+
target_offset_x: float | None = None
|
|
125
|
+
target_offset_y: float | None = None
|
|
126
|
+
orbit_angle: float = 0.0
|
|
127
|
+
orbit_radius: float = 0.0
|
|
128
|
+
phase_seed: float = 0.0
|
|
129
|
+
move_scale: float = 1.0
|
|
130
|
+
|
|
131
|
+
# Combat / timers.
|
|
132
|
+
hp: float = 0.0
|
|
133
|
+
max_hp: float = 0.0
|
|
134
|
+
move_speed: float = 1.0
|
|
135
|
+
contact_damage: float = 0.0
|
|
136
|
+
attack_cooldown: float = 0.0
|
|
137
|
+
reward_value: float = 0.0
|
|
138
|
+
|
|
139
|
+
# Contact damage gate.
|
|
140
|
+
collision_flag: int = 0
|
|
141
|
+
collision_timer: float = CONTACT_DAMAGE_PERIOD
|
|
142
|
+
hitbox_size: float = CREATURE_HITBOX_ALIVE
|
|
143
|
+
|
|
144
|
+
# Presentation.
|
|
145
|
+
size: float = 50.0
|
|
146
|
+
anim_phase: float = 0.0
|
|
147
|
+
hit_flash_timer: float = 0.0
|
|
148
|
+
last_hit_owner_id: int = -100
|
|
149
|
+
tint_r: float = 1.0
|
|
150
|
+
tint_g: float = 1.0
|
|
151
|
+
tint_b: float = 1.0
|
|
152
|
+
tint_a: float = 1.0
|
|
153
|
+
|
|
154
|
+
# Rewrite-only helpers (not in native struct, but derived from spawn plans).
|
|
155
|
+
spawn_slot_index: int | None = None
|
|
156
|
+
bonus_id: int | None = None
|
|
157
|
+
bonus_duration_override: int | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True)
|
|
161
|
+
class CreatureDeath:
|
|
162
|
+
index: int
|
|
163
|
+
x: float
|
|
164
|
+
y: float
|
|
165
|
+
type_id: int
|
|
166
|
+
reward_value: float
|
|
167
|
+
xp_awarded: int
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class CreatureUpdateResult:
|
|
172
|
+
deaths: tuple[CreatureDeath, ...] = ()
|
|
173
|
+
spawned: tuple[int, ...] = ()
|
|
174
|
+
sfx: tuple[str, ...] = ()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CreaturePool:
|
|
178
|
+
def __init__(self, *, size: int = CREATURE_POOL_SIZE, env: SpawnEnv | None = None) -> None:
|
|
179
|
+
self._entries = [CreatureState() for _ in range(int(size))]
|
|
180
|
+
self.spawn_slots: list[SpawnSlotInit] = []
|
|
181
|
+
self.env = env
|
|
182
|
+
self.kill_count = 0
|
|
183
|
+
self.spawned_count = 0
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def entries(self) -> list[CreatureState]:
|
|
187
|
+
return self._entries
|
|
188
|
+
|
|
189
|
+
def reset(self) -> None:
|
|
190
|
+
for i in range(len(self._entries)):
|
|
191
|
+
self._entries[i] = CreatureState()
|
|
192
|
+
self.spawn_slots.clear()
|
|
193
|
+
self.kill_count = 0
|
|
194
|
+
self.spawned_count = 0
|
|
195
|
+
|
|
196
|
+
def iter_active(self) -> list[CreatureState]:
|
|
197
|
+
return [entry for entry in self._entries if entry.active and entry.hp > 0.0]
|
|
198
|
+
|
|
199
|
+
def _plaguebearer_spread_infection(self, origin_index: int) -> None:
|
|
200
|
+
"""Port of `FUN_00425d80` (infects nearby creatures when Plaguebearer is active)."""
|
|
201
|
+
|
|
202
|
+
origin_index = int(origin_index)
|
|
203
|
+
if not (0 <= origin_index < len(self._entries)):
|
|
204
|
+
return
|
|
205
|
+
origin = self._entries[origin_index]
|
|
206
|
+
if not origin.active:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
for idx, creature in enumerate(self._entries):
|
|
210
|
+
if not creature.active:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if math.hypot(float(creature.x) - float(origin.x), float(creature.y) - float(origin.y)) < 45.0:
|
|
214
|
+
if creature.collision_flag != 0 and float(origin.hp) < 150.0:
|
|
215
|
+
origin.collision_flag = 1
|
|
216
|
+
if origin.collision_flag != 0 and float(creature.hp) < 150.0:
|
|
217
|
+
creature.collision_flag = 1
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
def _alloc_slot(self, *, rand: Callable[[], int] | None = None) -> int:
|
|
221
|
+
for i, entry in enumerate(self._entries):
|
|
222
|
+
if not entry.active:
|
|
223
|
+
return i
|
|
224
|
+
if not self._entries:
|
|
225
|
+
raise ValueError("Creature pool has zero entries")
|
|
226
|
+
if rand is not None:
|
|
227
|
+
return int(rand()) % len(self._entries)
|
|
228
|
+
return len(self._entries) - 1
|
|
229
|
+
|
|
230
|
+
def spawn_init(self, init: CreatureInit, *, rand: Callable[[], int] | None = None) -> int:
|
|
231
|
+
"""Materialize a single `CreatureInit` into the runtime pool."""
|
|
232
|
+
|
|
233
|
+
idx = self._alloc_slot(rand=rand)
|
|
234
|
+
entry = CreatureState()
|
|
235
|
+
self._apply_init(entry, init)
|
|
236
|
+
|
|
237
|
+
# Direct init does not have plan-local indices; preserve any raw linkage.
|
|
238
|
+
if init.ai_timer is not None:
|
|
239
|
+
entry.link_index = int(init.ai_timer)
|
|
240
|
+
elif init.ai_link_parent is not None:
|
|
241
|
+
entry.link_index = int(init.ai_link_parent)
|
|
242
|
+
if init.spawn_slot is not None:
|
|
243
|
+
# Plan-local slot ids must be remapped by `spawn_plan`; keep explicit.
|
|
244
|
+
entry.spawn_slot_index = int(init.spawn_slot)
|
|
245
|
+
entry.link_index = int(init.spawn_slot)
|
|
246
|
+
|
|
247
|
+
self._entries[idx] = entry
|
|
248
|
+
self.spawned_count += 1
|
|
249
|
+
return idx
|
|
250
|
+
|
|
251
|
+
def spawn_inits(self, inits: Sequence[CreatureInit], *, rand: Callable[[], int] | None = None) -> list[int]:
|
|
252
|
+
return [self.spawn_init(init, rand=rand) for init in inits]
|
|
253
|
+
|
|
254
|
+
def spawn_plan(
|
|
255
|
+
self,
|
|
256
|
+
plan: SpawnPlan,
|
|
257
|
+
*,
|
|
258
|
+
rand: Callable[[], int] | None = None,
|
|
259
|
+
) -> tuple[list[int], int | None]:
|
|
260
|
+
"""Materialize a pure `SpawnPlan` into the runtime pool.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
(plan_index_to_pool_index, primary_pool_index_or_none)
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
mapping: list[int] = []
|
|
267
|
+
pending_ai_links: list[int | None] = []
|
|
268
|
+
pending_ai_timers: list[int | None] = []
|
|
269
|
+
pending_spawn_slots: list[int | None] = []
|
|
270
|
+
|
|
271
|
+
# 1) Allocate pool slots for every creature.
|
|
272
|
+
for init in plan.creatures:
|
|
273
|
+
pool_idx = self._alloc_slot(rand=rand)
|
|
274
|
+
entry = CreatureState()
|
|
275
|
+
self._apply_init(entry, init)
|
|
276
|
+
self._entries[pool_idx] = entry
|
|
277
|
+
self.spawned_count += 1
|
|
278
|
+
|
|
279
|
+
mapping.append(pool_idx)
|
|
280
|
+
pending_ai_links.append(init.ai_link_parent)
|
|
281
|
+
pending_ai_timers.append(init.ai_timer)
|
|
282
|
+
pending_spawn_slots.append(init.spawn_slot)
|
|
283
|
+
|
|
284
|
+
# 2) Allocate and remap spawn slots.
|
|
285
|
+
slot_mapping: list[int] = []
|
|
286
|
+
for slot in plan.spawn_slots:
|
|
287
|
+
owner_plan = int(slot.owner_creature)
|
|
288
|
+
owner_pool = mapping[owner_plan] if 0 <= owner_plan < len(mapping) else -1
|
|
289
|
+
self.spawn_slots.append(
|
|
290
|
+
SpawnSlotInit(
|
|
291
|
+
owner_creature=int(owner_pool),
|
|
292
|
+
timer=float(slot.timer),
|
|
293
|
+
count=int(slot.count),
|
|
294
|
+
limit=int(slot.limit),
|
|
295
|
+
interval=float(slot.interval),
|
|
296
|
+
child_template_id=int(slot.child_template_id),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
slot_mapping.append(len(self.spawn_slots) - 1)
|
|
300
|
+
|
|
301
|
+
# 3) Patch link indices now that we have global indices.
|
|
302
|
+
for plan_idx, pool_idx in enumerate(mapping):
|
|
303
|
+
entry = self._entries[pool_idx]
|
|
304
|
+
|
|
305
|
+
slot_plan = pending_spawn_slots[plan_idx]
|
|
306
|
+
if slot_plan is not None:
|
|
307
|
+
global_slot = slot_mapping[int(slot_plan)]
|
|
308
|
+
entry.spawn_slot_index = int(global_slot)
|
|
309
|
+
entry.link_index = int(global_slot)
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
timer = pending_ai_timers[plan_idx]
|
|
313
|
+
if timer is not None:
|
|
314
|
+
entry.link_index = int(timer)
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
link_plan = pending_ai_links[plan_idx]
|
|
318
|
+
if link_plan is not None:
|
|
319
|
+
entry.link_index = mapping[int(link_plan)]
|
|
320
|
+
|
|
321
|
+
primary_pool = None
|
|
322
|
+
if 0 <= int(plan.primary) < len(mapping):
|
|
323
|
+
primary_pool = mapping[int(plan.primary)]
|
|
324
|
+
return mapping, primary_pool
|
|
325
|
+
|
|
326
|
+
def spawn_template(
|
|
327
|
+
self,
|
|
328
|
+
template_id: int,
|
|
329
|
+
pos: tuple[float, float],
|
|
330
|
+
heading: float,
|
|
331
|
+
rng: Crand,
|
|
332
|
+
*,
|
|
333
|
+
rand: Callable[[], int] | None = None,
|
|
334
|
+
env: SpawnEnv | None = None,
|
|
335
|
+
) -> tuple[list[int], int | None]:
|
|
336
|
+
"""Build a spawn plan and materialize it into the pool."""
|
|
337
|
+
|
|
338
|
+
spawn_env = env or self.env
|
|
339
|
+
if spawn_env is None:
|
|
340
|
+
raise ValueError("CreaturePool.spawn_template requires SpawnEnv (set CreaturePool.env or pass env=...)")
|
|
341
|
+
plan = build_spawn_plan(template_id, pos, heading, rng, spawn_env)
|
|
342
|
+
return self.spawn_plan(plan, rand=rand)
|
|
343
|
+
|
|
344
|
+
def update(
|
|
345
|
+
self,
|
|
346
|
+
dt: float,
|
|
347
|
+
*,
|
|
348
|
+
state: GameplayState,
|
|
349
|
+
players: list[PlayerState],
|
|
350
|
+
rand: Callable[[], int] | None = None,
|
|
351
|
+
detail_preset: int = 5,
|
|
352
|
+
env: SpawnEnv | None = None,
|
|
353
|
+
world_width: float = 1024.0,
|
|
354
|
+
world_height: float = 1024.0,
|
|
355
|
+
fx_queue: FxQueue | None = None,
|
|
356
|
+
fx_queue_rotated: FxQueueRotated | None = None,
|
|
357
|
+
) -> CreatureUpdateResult:
|
|
358
|
+
"""Advance the creature runtime pool by `dt` seconds.
|
|
359
|
+
|
|
360
|
+
Notes:
|
|
361
|
+
- Death side effects should be initiated by damage call sites.
|
|
362
|
+
- This is not a full port of `creature_update_all`; it targets the Survival subset.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
if rand is None:
|
|
366
|
+
rand = state.rng.rand
|
|
367
|
+
spawn_env = env or self.env
|
|
368
|
+
|
|
369
|
+
deaths: list[CreatureDeath] = []
|
|
370
|
+
spawned: list[int] = []
|
|
371
|
+
sfx: list[str] = []
|
|
372
|
+
|
|
373
|
+
evil_target = -1
|
|
374
|
+
if players and perk_active(players[0], PerkId.EVIL_EYES):
|
|
375
|
+
evil_target = int(players[0].evil_eyes_target_creature)
|
|
376
|
+
|
|
377
|
+
# Movement + AI. Dead creatures keep updating (death slide + corpse decals)
|
|
378
|
+
# even when `players` is empty so debug views remain deterministic.
|
|
379
|
+
dt_ms = int(dt * 1000.0) if dt > 0.0 else 0
|
|
380
|
+
for idx, creature in enumerate(self._entries):
|
|
381
|
+
if not creature.active:
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
if creature.hitbox_size != CREATURE_HITBOX_ALIVE or creature.hp <= 0.0:
|
|
385
|
+
if creature.hitbox_size == CREATURE_HITBOX_ALIVE:
|
|
386
|
+
creature.hitbox_size = CREATURE_HITBOX_ALIVE - 0.001
|
|
387
|
+
if dt > 0.0:
|
|
388
|
+
self._tick_dead(
|
|
389
|
+
creature,
|
|
390
|
+
dt=dt,
|
|
391
|
+
world_width=world_width,
|
|
392
|
+
world_height=world_height,
|
|
393
|
+
fx_queue_rotated=fx_queue_rotated,
|
|
394
|
+
)
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
if dt <= 0.0 or not players:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
if float(state.bonuses.freeze) > 0.0:
|
|
401
|
+
creature.move_scale = 0.0
|
|
402
|
+
creature.vel_x = 0.0
|
|
403
|
+
creature.vel_y = 0.0
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
if creature.flags & CreatureFlags.SELF_DAMAGE_TICK_STRONG:
|
|
407
|
+
creature.hp -= dt * 180.0
|
|
408
|
+
elif creature.flags & CreatureFlags.SELF_DAMAGE_TICK:
|
|
409
|
+
creature.hp -= dt * 60.0
|
|
410
|
+
if creature.hp <= 0.0:
|
|
411
|
+
deaths.append(
|
|
412
|
+
self.handle_death(
|
|
413
|
+
idx,
|
|
414
|
+
state=state,
|
|
415
|
+
players=players,
|
|
416
|
+
rand=rand,
|
|
417
|
+
detail_preset=int(detail_preset),
|
|
418
|
+
world_width=world_width,
|
|
419
|
+
world_height=world_height,
|
|
420
|
+
fx_queue=fx_queue,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
if creature.active:
|
|
424
|
+
self._tick_dead(
|
|
425
|
+
creature,
|
|
426
|
+
dt=dt,
|
|
427
|
+
world_width=world_width,
|
|
428
|
+
world_height=world_height,
|
|
429
|
+
fx_queue_rotated=fx_queue_rotated,
|
|
430
|
+
)
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
if creature.collision_flag != 0:
|
|
434
|
+
creature.collision_timer -= float(dt)
|
|
435
|
+
if creature.collision_timer < 0.0:
|
|
436
|
+
creature.collision_timer += CONTACT_DAMAGE_PERIOD
|
|
437
|
+
creature.hp -= 15.0
|
|
438
|
+
if fx_queue is not None:
|
|
439
|
+
fx_queue.add_random(pos_x=creature.x, pos_y=creature.y, rand=rand)
|
|
440
|
+
|
|
441
|
+
if creature.hp < 0.0:
|
|
442
|
+
state.plaguebearer_infection_count += 1
|
|
443
|
+
deaths.append(
|
|
444
|
+
self.handle_death(
|
|
445
|
+
idx,
|
|
446
|
+
state=state,
|
|
447
|
+
players=players,
|
|
448
|
+
rand=rand,
|
|
449
|
+
detail_preset=int(detail_preset),
|
|
450
|
+
world_width=world_width,
|
|
451
|
+
world_height=world_height,
|
|
452
|
+
fx_queue=fx_queue,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
if creature.active:
|
|
456
|
+
self._tick_dead(
|
|
457
|
+
creature,
|
|
458
|
+
dt=dt,
|
|
459
|
+
world_width=world_width,
|
|
460
|
+
world_height=world_height,
|
|
461
|
+
fx_queue_rotated=fx_queue_rotated,
|
|
462
|
+
)
|
|
463
|
+
continue
|
|
464
|
+
|
|
465
|
+
target_player = int(creature.target_player)
|
|
466
|
+
if not (0 <= target_player < len(players)):
|
|
467
|
+
target_player = 0
|
|
468
|
+
creature.target_player = 0
|
|
469
|
+
player = players[target_player]
|
|
470
|
+
|
|
471
|
+
if players and perk_active(players[0], PerkId.RADIOACTIVE):
|
|
472
|
+
radioactive_player = players[0]
|
|
473
|
+
dist = math.hypot(
|
|
474
|
+
float(creature.x) - float(radioactive_player.pos_x),
|
|
475
|
+
float(creature.y) - float(radioactive_player.pos_y),
|
|
476
|
+
)
|
|
477
|
+
if dist < 100.0:
|
|
478
|
+
creature.collision_timer -= float(dt) * 1.5
|
|
479
|
+
if creature.collision_timer < 0.0:
|
|
480
|
+
creature.collision_timer = CONTACT_DAMAGE_PERIOD
|
|
481
|
+
creature.hp -= (100.0 - dist) * 0.3
|
|
482
|
+
if fx_queue is not None:
|
|
483
|
+
fx_queue.add_random(pos_x=creature.x, pos_y=creature.y, rand=rand)
|
|
484
|
+
|
|
485
|
+
if creature.hp < 0.0:
|
|
486
|
+
if creature.type_id == 1:
|
|
487
|
+
creature.hp = 1.0
|
|
488
|
+
else:
|
|
489
|
+
radioactive_player.experience = int(
|
|
490
|
+
float(radioactive_player.experience) + float(creature.reward_value)
|
|
491
|
+
)
|
|
492
|
+
creature.hitbox_size -= float(dt)
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
frozen_by_evil_eyes = idx == evil_target
|
|
496
|
+
if frozen_by_evil_eyes:
|
|
497
|
+
creature.move_scale = 0.0
|
|
498
|
+
creature.vel_x = 0.0
|
|
499
|
+
creature.vel_y = 0.0
|
|
500
|
+
else:
|
|
501
|
+
creature_ai7_tick_link_timer(creature, dt_ms=dt_ms, rand=rand)
|
|
502
|
+
ai = creature_ai_update_target(
|
|
503
|
+
creature,
|
|
504
|
+
player_x=player.pos_x,
|
|
505
|
+
player_y=player.pos_y,
|
|
506
|
+
creatures=self._entries,
|
|
507
|
+
dt=dt,
|
|
508
|
+
)
|
|
509
|
+
creature.move_scale = float(ai.move_scale)
|
|
510
|
+
if ai.self_damage is not None and ai.self_damage > 0.0:
|
|
511
|
+
creature.hp -= float(ai.self_damage)
|
|
512
|
+
if creature.hp <= 0.0:
|
|
513
|
+
deaths.append(
|
|
514
|
+
self.handle_death(
|
|
515
|
+
idx,
|
|
516
|
+
state=state,
|
|
517
|
+
players=players,
|
|
518
|
+
rand=rand,
|
|
519
|
+
world_width=world_width,
|
|
520
|
+
world_height=world_height,
|
|
521
|
+
fx_queue=fx_queue,
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
if creature.active:
|
|
525
|
+
self._tick_dead(
|
|
526
|
+
creature,
|
|
527
|
+
dt=dt,
|
|
528
|
+
world_width=world_width,
|
|
529
|
+
world_height=world_height,
|
|
530
|
+
fx_queue_rotated=fx_queue_rotated,
|
|
531
|
+
)
|
|
532
|
+
continue
|
|
533
|
+
|
|
534
|
+
if (float(state.bonuses.energizer) > 0.0 and float(creature.max_hp) < 500.0) or creature.collision_flag != 0:
|
|
535
|
+
creature.target_heading = _wrap_angle(float(creature.target_heading) + math.pi)
|
|
536
|
+
|
|
537
|
+
turn_rate = float(creature.move_speed) * CREATURE_TURN_RATE_SCALE
|
|
538
|
+
speed = float(creature.move_speed) * CREATURE_SPEED_SCALE * creature.move_scale
|
|
539
|
+
|
|
540
|
+
if (creature.flags & CreatureFlags.ANIM_PING_PONG) == 0:
|
|
541
|
+
if creature.ai_mode == 7:
|
|
542
|
+
creature.vel_x = 0.0
|
|
543
|
+
creature.vel_y = 0.0
|
|
544
|
+
else:
|
|
545
|
+
creature.heading = _angle_approach(creature.heading, creature.target_heading, turn_rate, dt)
|
|
546
|
+
dir_x = math.cos(creature.heading - math.pi / 2.0)
|
|
547
|
+
dir_y = math.sin(creature.heading - math.pi / 2.0)
|
|
548
|
+
creature.vel_x = dir_x * speed
|
|
549
|
+
creature.vel_y = dir_y * speed
|
|
550
|
+
creature.x = _clamp(creature.x + creature.vel_x * dt, 0.0, float(world_width))
|
|
551
|
+
creature.y = _clamp(creature.y + creature.vel_y * dt, 0.0, float(world_height))
|
|
552
|
+
else:
|
|
553
|
+
# Spawner/short-strip creatures clamp to bounds using `size` as a radius; most are stationary
|
|
554
|
+
# unless ANIM_LONG_STRIP is set (see creature_update_all).
|
|
555
|
+
radius = max(0.0, float(creature.size))
|
|
556
|
+
max_x = max(radius, float(world_width) - radius)
|
|
557
|
+
max_y = max(radius, float(world_height) - radius)
|
|
558
|
+
creature.x = _clamp(creature.x, radius, max_x)
|
|
559
|
+
creature.y = _clamp(creature.y, radius, max_y)
|
|
560
|
+
if (creature.flags & CreatureFlags.ANIM_LONG_STRIP) == 0:
|
|
561
|
+
creature.vel_x = 0.0
|
|
562
|
+
creature.vel_y = 0.0
|
|
563
|
+
else:
|
|
564
|
+
creature.heading = _angle_approach(creature.heading, creature.target_heading, turn_rate, dt)
|
|
565
|
+
dir_x = math.cos(creature.heading - math.pi / 2.0)
|
|
566
|
+
dir_y = math.sin(creature.heading - math.pi / 2.0)
|
|
567
|
+
creature.vel_x = dir_x * speed
|
|
568
|
+
creature.vel_y = dir_y * speed
|
|
569
|
+
creature.x = _clamp(creature.x + creature.vel_x * dt, radius, max_x)
|
|
570
|
+
creature.y = _clamp(creature.y + creature.vel_y * dt, radius, max_y)
|
|
571
|
+
|
|
572
|
+
if (
|
|
573
|
+
players
|
|
574
|
+
and perk_active(players[0], PerkId.PLAGUEBEARER)
|
|
575
|
+
and int(state.plaguebearer_infection_count) < 0x3C
|
|
576
|
+
):
|
|
577
|
+
self._plaguebearer_spread_infection(idx)
|
|
578
|
+
|
|
579
|
+
if float(state.bonuses.energizer) > 0.0 and float(creature.max_hp) < 380.0 and float(player.health) > 0.0:
|
|
580
|
+
eat_dist_sq = _distance_sq(creature.x, creature.y, player.pos_x, player.pos_y)
|
|
581
|
+
if eat_dist_sq < 20.0 * 20.0:
|
|
582
|
+
creature.x = _clamp(creature.x - creature.vel_x * dt, 0.0, float(world_width))
|
|
583
|
+
creature.y = _clamp(creature.y - creature.vel_y * dt, 0.0, float(world_height))
|
|
584
|
+
|
|
585
|
+
state.effects.spawn_burst(
|
|
586
|
+
pos_x=float(creature.x),
|
|
587
|
+
pos_y=float(creature.y),
|
|
588
|
+
count=6,
|
|
589
|
+
rand=rand,
|
|
590
|
+
detail_preset=int(detail_preset),
|
|
591
|
+
)
|
|
592
|
+
sfx.append("sfx_ui_bonus")
|
|
593
|
+
|
|
594
|
+
prev_guard = bool(state.bonus_spawn_guard)
|
|
595
|
+
state.bonus_spawn_guard = True
|
|
596
|
+
creature.last_hit_owner_id = -1 - int(player.index)
|
|
597
|
+
deaths.append(
|
|
598
|
+
self.handle_death(
|
|
599
|
+
idx,
|
|
600
|
+
state=state,
|
|
601
|
+
players=players,
|
|
602
|
+
rand=rand,
|
|
603
|
+
detail_preset=int(detail_preset),
|
|
604
|
+
world_width=world_width,
|
|
605
|
+
world_height=world_height,
|
|
606
|
+
fx_queue=fx_queue,
|
|
607
|
+
keep_corpse=False,
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
state.bonus_spawn_guard = prev_guard
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
# Contact damage throttle. While Energizer is active, the native suppresses
|
|
614
|
+
# contact/melee interactions for most creatures (and instead allows "eat" kills).
|
|
615
|
+
if float(state.bonuses.energizer) <= 0.0:
|
|
616
|
+
dist_sq = _distance_sq(creature.x, creature.y, player.pos_x, player.pos_y)
|
|
617
|
+
contact_r = (float(creature.size) + float(player.size)) * 0.25 + 20.0
|
|
618
|
+
in_contact = dist_sq <= contact_r * contact_r
|
|
619
|
+
if in_contact:
|
|
620
|
+
creature.collision_timer -= dt
|
|
621
|
+
if creature.collision_timer < 0.0:
|
|
622
|
+
creature.collision_timer += CONTACT_DAMAGE_PERIOD
|
|
623
|
+
if perk_active(player, PerkId.MR_MELEE):
|
|
624
|
+
death_start_needed = creature.hp > 0.0 and creature.hitbox_size == CREATURE_HITBOX_ALIVE
|
|
625
|
+
|
|
626
|
+
from .damage import creature_apply_damage
|
|
627
|
+
|
|
628
|
+
killed = creature_apply_damage(
|
|
629
|
+
creature,
|
|
630
|
+
damage_amount=25.0,
|
|
631
|
+
damage_type=2,
|
|
632
|
+
impulse_x=0.0,
|
|
633
|
+
impulse_y=0.0,
|
|
634
|
+
owner_id=-1 - int(player.index),
|
|
635
|
+
dt=dt,
|
|
636
|
+
players=players,
|
|
637
|
+
rand=rand,
|
|
638
|
+
)
|
|
639
|
+
if killed and death_start_needed:
|
|
640
|
+
deaths.append(
|
|
641
|
+
self.handle_death(
|
|
642
|
+
idx,
|
|
643
|
+
state=state,
|
|
644
|
+
players=players,
|
|
645
|
+
rand=rand,
|
|
646
|
+
detail_preset=int(detail_preset),
|
|
647
|
+
world_width=world_width,
|
|
648
|
+
world_height=world_height,
|
|
649
|
+
fx_queue=fx_queue,
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
if creature.active:
|
|
653
|
+
self._tick_dead(
|
|
654
|
+
creature,
|
|
655
|
+
dt=dt,
|
|
656
|
+
world_width=world_width,
|
|
657
|
+
world_height=world_height,
|
|
658
|
+
fx_queue_rotated=fx_queue_rotated,
|
|
659
|
+
)
|
|
660
|
+
continue
|
|
661
|
+
|
|
662
|
+
if float(player.shield_timer) <= 0.0:
|
|
663
|
+
if perk_active(player, PerkId.TOXIC_AVENGER):
|
|
664
|
+
creature.flags |= (
|
|
665
|
+
CreatureFlags.SELF_DAMAGE_TICK | CreatureFlags.SELF_DAMAGE_TICK_STRONG
|
|
666
|
+
)
|
|
667
|
+
elif perk_active(player, PerkId.VEINS_OF_POISON):
|
|
668
|
+
creature.flags |= CreatureFlags.SELF_DAMAGE_TICK
|
|
669
|
+
player_take_damage(state, player, float(creature.contact_damage), dt=dt, rand=rand)
|
|
670
|
+
|
|
671
|
+
if (
|
|
672
|
+
bool(player.plaguebearer_active)
|
|
673
|
+
and float(creature.hp) < 150.0
|
|
674
|
+
and int(state.plaguebearer_infection_count) < 0x32
|
|
675
|
+
and dist_sq < 30.0 * 30.0
|
|
676
|
+
):
|
|
677
|
+
creature.collision_flag = 1
|
|
678
|
+
|
|
679
|
+
if (not frozen_by_evil_eyes) and (creature.flags & (CreatureFlags.RANGED_ATTACK_SHOCK | CreatureFlags.RANGED_ATTACK_VARIANT)):
|
|
680
|
+
# Ported from creature_update_all (see `analysis/ghidra/raw/crimsonland.exe_decompiled.c`
|
|
681
|
+
# around the 0x004276xx ranged-fire branch).
|
|
682
|
+
if creature.attack_cooldown <= 0.0:
|
|
683
|
+
creature.attack_cooldown = 0.0
|
|
684
|
+
else:
|
|
685
|
+
creature.attack_cooldown -= dt
|
|
686
|
+
|
|
687
|
+
dist = math.hypot(creature.x - player.pos_x, creature.y - player.pos_y)
|
|
688
|
+
if dist > 64.0 and creature.attack_cooldown <= 0.0:
|
|
689
|
+
if creature.flags & CreatureFlags.RANGED_ATTACK_SHOCK:
|
|
690
|
+
state.projectiles.spawn(
|
|
691
|
+
pos_x=creature.x,
|
|
692
|
+
pos_y=creature.y,
|
|
693
|
+
angle=float(creature.heading),
|
|
694
|
+
type_id=9,
|
|
695
|
+
owner_id=idx,
|
|
696
|
+
base_damage=45.0,
|
|
697
|
+
hits_players=True,
|
|
698
|
+
)
|
|
699
|
+
sfx.append("sfx_shock_fire")
|
|
700
|
+
creature.attack_cooldown += 1.0
|
|
701
|
+
|
|
702
|
+
if (creature.flags & CreatureFlags.RANGED_ATTACK_VARIANT) and creature.attack_cooldown <= 0.0:
|
|
703
|
+
projectile_type = int(creature.orbit_radius)
|
|
704
|
+
state.projectiles.spawn(
|
|
705
|
+
pos_x=creature.x,
|
|
706
|
+
pos_y=creature.y,
|
|
707
|
+
angle=float(creature.heading),
|
|
708
|
+
type_id=projectile_type,
|
|
709
|
+
owner_id=idx,
|
|
710
|
+
base_damage=45.0,
|
|
711
|
+
hits_players=True,
|
|
712
|
+
)
|
|
713
|
+
sfx.append("sfx_plasmaminigun_fire")
|
|
714
|
+
creature.attack_cooldown = (
|
|
715
|
+
float(rand() & 3) * 0.1 + float(creature.orbit_angle) + float(creature.attack_cooldown)
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Spawn-slot ticking (spawns child templates while owner stays alive).
|
|
719
|
+
if dt > 0.0 and float(state.bonuses.freeze) <= 0.0 and spawn_env is not None and self.spawn_slots:
|
|
720
|
+
for slot in self.spawn_slots:
|
|
721
|
+
owner_idx = int(slot.owner_creature)
|
|
722
|
+
if not (0 <= owner_idx < len(self._entries)):
|
|
723
|
+
continue
|
|
724
|
+
owner = self._entries[owner_idx]
|
|
725
|
+
if not (owner.active and owner.hp > 0.0):
|
|
726
|
+
continue
|
|
727
|
+
child_template_id = tick_spawn_slot(slot, dt)
|
|
728
|
+
if child_template_id is None:
|
|
729
|
+
continue
|
|
730
|
+
|
|
731
|
+
plan = build_spawn_plan(
|
|
732
|
+
int(child_template_id),
|
|
733
|
+
(owner.x, owner.y),
|
|
734
|
+
float(owner.heading),
|
|
735
|
+
state.rng,
|
|
736
|
+
spawn_env,
|
|
737
|
+
)
|
|
738
|
+
mapping, _ = self.spawn_plan(plan, rand=rand)
|
|
739
|
+
spawned.extend(mapping)
|
|
740
|
+
|
|
741
|
+
return CreatureUpdateResult(deaths=tuple(deaths), spawned=tuple(spawned), sfx=tuple(sfx))
|
|
742
|
+
|
|
743
|
+
def handle_death(
|
|
744
|
+
self,
|
|
745
|
+
idx: int,
|
|
746
|
+
*,
|
|
747
|
+
state: GameplayState,
|
|
748
|
+
players: list[PlayerState],
|
|
749
|
+
rand: Callable[[], int],
|
|
750
|
+
detail_preset: int = 5,
|
|
751
|
+
world_width: float,
|
|
752
|
+
world_height: float,
|
|
753
|
+
fx_queue: FxQueue | None,
|
|
754
|
+
keep_corpse: bool = True, # noqa: FBT001, FBT002
|
|
755
|
+
) -> CreatureDeath:
|
|
756
|
+
"""Run one-shot death side effects and return the `CreatureDeath` event."""
|
|
757
|
+
|
|
758
|
+
creature = self._entries[int(idx)]
|
|
759
|
+
death = self._start_death(
|
|
760
|
+
int(idx),
|
|
761
|
+
creature,
|
|
762
|
+
state=state,
|
|
763
|
+
players=players,
|
|
764
|
+
rand=rand,
|
|
765
|
+
detail_preset=int(detail_preset),
|
|
766
|
+
world_width=world_width,
|
|
767
|
+
world_height=world_height,
|
|
768
|
+
fx_queue=fx_queue,
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
if keep_corpse:
|
|
772
|
+
if creature.hitbox_size == CREATURE_HITBOX_ALIVE:
|
|
773
|
+
creature.hitbox_size = CREATURE_HITBOX_ALIVE - 0.001
|
|
774
|
+
else:
|
|
775
|
+
creature.active = False
|
|
776
|
+
|
|
777
|
+
if float(state.bonuses.freeze) > 0.0:
|
|
778
|
+
pos_x = float(creature.x)
|
|
779
|
+
pos_y = float(creature.y)
|
|
780
|
+
for _ in range(8):
|
|
781
|
+
angle = float(int(rand()) % 0x264) * 0.01
|
|
782
|
+
state.effects.spawn_freeze_shard(
|
|
783
|
+
pos_x=pos_x,
|
|
784
|
+
pos_y=pos_y,
|
|
785
|
+
angle=angle,
|
|
786
|
+
rand=rand,
|
|
787
|
+
detail_preset=int(detail_preset),
|
|
788
|
+
)
|
|
789
|
+
angle = float(int(rand()) % 0x264) * 0.01
|
|
790
|
+
state.effects.spawn_freeze_shatter(
|
|
791
|
+
pos_x=pos_x,
|
|
792
|
+
pos_y=pos_y,
|
|
793
|
+
angle=angle,
|
|
794
|
+
rand=rand,
|
|
795
|
+
detail_preset=int(detail_preset),
|
|
796
|
+
)
|
|
797
|
+
self.kill_count += 1
|
|
798
|
+
creature.active = False
|
|
799
|
+
|
|
800
|
+
return death
|
|
801
|
+
|
|
802
|
+
def _apply_init(self, entry: CreatureState, init: CreatureInit) -> None:
|
|
803
|
+
entry.active = True
|
|
804
|
+
entry.type_id = int(init.type_id.value) if init.type_id is not None else 0
|
|
805
|
+
entry.x = float(init.pos_x)
|
|
806
|
+
entry.y = float(init.pos_y)
|
|
807
|
+
entry.heading = float(init.heading)
|
|
808
|
+
entry.target_heading = float(init.heading)
|
|
809
|
+
entry.target_x = float(init.pos_x)
|
|
810
|
+
entry.target_y = float(init.pos_y)
|
|
811
|
+
entry.phase_seed = float(init.phase_seed)
|
|
812
|
+
|
|
813
|
+
entry.flags = init.flags or CreatureFlags(0)
|
|
814
|
+
entry.ai_mode = int(init.ai_mode)
|
|
815
|
+
|
|
816
|
+
hp = float(init.health or 0.0)
|
|
817
|
+
if hp <= 0.0:
|
|
818
|
+
hp = 1.0
|
|
819
|
+
entry.hp = hp
|
|
820
|
+
entry.max_hp = float(init.max_health or hp)
|
|
821
|
+
|
|
822
|
+
entry.move_speed = float(init.move_speed or 1.0)
|
|
823
|
+
entry.reward_value = float(init.reward_value or 0.0)
|
|
824
|
+
entry.size = float(init.size or 50.0)
|
|
825
|
+
entry.contact_damage = float(init.contact_damage or 0.0)
|
|
826
|
+
|
|
827
|
+
entry.target_offset_x = init.target_offset_x
|
|
828
|
+
entry.target_offset_y = init.target_offset_y
|
|
829
|
+
entry.orbit_angle = float(init.orbit_angle or 0.0)
|
|
830
|
+
if init.orbit_radius is not None:
|
|
831
|
+
orbit_radius = float(init.orbit_radius)
|
|
832
|
+
elif init.ranged_projectile_type is not None:
|
|
833
|
+
orbit_radius = float(init.ranged_projectile_type)
|
|
834
|
+
else:
|
|
835
|
+
orbit_radius = 0.0
|
|
836
|
+
entry.orbit_radius = orbit_radius
|
|
837
|
+
|
|
838
|
+
entry.spawn_slot_index = None
|
|
839
|
+
entry.link_index = 0
|
|
840
|
+
|
|
841
|
+
entry.bonus_id = int(init.bonus_id) if init.bonus_id is not None else None
|
|
842
|
+
entry.bonus_duration_override = int(init.bonus_duration_override) if init.bonus_duration_override is not None else None
|
|
843
|
+
|
|
844
|
+
tint = resolve_tint(init.tint)
|
|
845
|
+
entry.tint_r = float(tint[0])
|
|
846
|
+
entry.tint_g = float(tint[1])
|
|
847
|
+
entry.tint_b = float(tint[2])
|
|
848
|
+
entry.tint_a = float(tint[3])
|
|
849
|
+
|
|
850
|
+
entry.collision_flag = 0
|
|
851
|
+
entry.collision_timer = CONTACT_DAMAGE_PERIOD
|
|
852
|
+
entry.hitbox_size = CREATURE_HITBOX_ALIVE
|
|
853
|
+
|
|
854
|
+
def _disable_spawn_slot(self, slot_index: int) -> None:
|
|
855
|
+
if not (0 <= slot_index < len(self.spawn_slots)):
|
|
856
|
+
return
|
|
857
|
+
slot = self.spawn_slots[slot_index]
|
|
858
|
+
slot.owner_creature = -1
|
|
859
|
+
slot.limit = 0
|
|
860
|
+
|
|
861
|
+
def _tick_dead(
|
|
862
|
+
self,
|
|
863
|
+
creature: CreatureState,
|
|
864
|
+
*,
|
|
865
|
+
dt: float,
|
|
866
|
+
world_width: float,
|
|
867
|
+
world_height: float,
|
|
868
|
+
fx_queue_rotated: FxQueueRotated | None,
|
|
869
|
+
) -> None:
|
|
870
|
+
"""Advance the post-death hitbox_size ramp and queue corpse decals.
|
|
871
|
+
|
|
872
|
+
This matches the `hitbox_size` death staging inside `creature_update_all`:
|
|
873
|
+
- while hitbox_size > 0: decrement quickly and slide backwards
|
|
874
|
+
- once hitbox_size <= 0: queue a corpse decal and fade out until < -10, then deactivate.
|
|
875
|
+
"""
|
|
876
|
+
|
|
877
|
+
if dt <= 0.0:
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
hitbox = float(creature.hitbox_size)
|
|
881
|
+
if hitbox <= 0.0:
|
|
882
|
+
creature.hitbox_size = hitbox - float(dt) * CREATURE_CORPSE_FADE_DECAY
|
|
883
|
+
if creature.hitbox_size < CREATURE_CORPSE_DESPAWN_HITBOX:
|
|
884
|
+
creature.active = False
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
long_strip = (creature.flags & CreatureFlags.ANIM_PING_PONG) == 0 or (creature.flags & CreatureFlags.ANIM_LONG_STRIP) != 0
|
|
888
|
+
|
|
889
|
+
new_hitbox = hitbox - float(dt) * CREATURE_DEATH_TIMER_DECAY
|
|
890
|
+
creature.hitbox_size = new_hitbox
|
|
891
|
+
if new_hitbox > 0.0:
|
|
892
|
+
if long_strip:
|
|
893
|
+
dir_x = math.cos(creature.heading - math.pi / 2.0)
|
|
894
|
+
dir_y = math.sin(creature.heading - math.pi / 2.0)
|
|
895
|
+
creature.vel_x = dir_x * new_hitbox * float(dt) * CREATURE_DEATH_SLIDE_SCALE
|
|
896
|
+
creature.vel_y = dir_y * new_hitbox * float(dt) * CREATURE_DEATH_SLIDE_SCALE
|
|
897
|
+
creature.x = _clamp(creature.x - creature.vel_x, 0.0, float(world_width))
|
|
898
|
+
creature.y = _clamp(creature.y - creature.vel_y, 0.0, float(world_height))
|
|
899
|
+
else:
|
|
900
|
+
creature.vel_x = 0.0
|
|
901
|
+
creature.vel_y = 0.0
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
# hitbox_size just crossed <= 0: bake a persistent corpse decal into the ground.
|
|
905
|
+
if fx_queue_rotated is not None:
|
|
906
|
+
corpse_size = max(1.0, float(creature.size))
|
|
907
|
+
# Native uses a special fallback corpse id for ping-pong strip creatures.
|
|
908
|
+
corpse_type_id = int(creature.type_id) if long_strip else 7
|
|
909
|
+
ok = fx_queue_rotated.add(
|
|
910
|
+
top_left_x=creature.x - corpse_size * 0.5,
|
|
911
|
+
top_left_y=creature.y - corpse_size * 0.5,
|
|
912
|
+
rgba=(creature.tint_r, creature.tint_g, creature.tint_b, creature.tint_a),
|
|
913
|
+
rotation=float(creature.heading),
|
|
914
|
+
scale=corpse_size,
|
|
915
|
+
creature_type_id=corpse_type_id,
|
|
916
|
+
)
|
|
917
|
+
if not ok:
|
|
918
|
+
creature.hitbox_size = 0.001
|
|
919
|
+
return
|
|
920
|
+
|
|
921
|
+
self.kill_count += 1
|
|
922
|
+
|
|
923
|
+
def _start_death(
|
|
924
|
+
self,
|
|
925
|
+
idx: int,
|
|
926
|
+
creature: CreatureState,
|
|
927
|
+
*,
|
|
928
|
+
state: GameplayState,
|
|
929
|
+
players: list[PlayerState],
|
|
930
|
+
rand: Callable[[], int],
|
|
931
|
+
detail_preset: int = 5,
|
|
932
|
+
world_width: float,
|
|
933
|
+
world_height: float,
|
|
934
|
+
fx_queue: FxQueue | None,
|
|
935
|
+
) -> CreatureDeath:
|
|
936
|
+
creature.hp = 0.0
|
|
937
|
+
|
|
938
|
+
if creature.spawn_slot_index is not None:
|
|
939
|
+
self._disable_spawn_slot(int(creature.spawn_slot_index))
|
|
940
|
+
|
|
941
|
+
if (creature.flags & CreatureFlags.SPLIT_ON_DEATH) and float(creature.size) > 35.0:
|
|
942
|
+
for heading_offset in (-math.pi / 2.0, math.pi / 2.0):
|
|
943
|
+
child_idx = self._alloc_slot(rand=rand)
|
|
944
|
+
child = replace(creature)
|
|
945
|
+
child.phase_seed = float(int(rand()) & 0xFF)
|
|
946
|
+
child.heading = _wrap_angle(float(creature.heading) + float(heading_offset))
|
|
947
|
+
child.target_heading = float(child.heading)
|
|
948
|
+
child.hp = float(creature.max_hp) * 0.25
|
|
949
|
+
child.reward_value = float(child.reward_value) * (2.0 / 3.0)
|
|
950
|
+
child.size = float(child.size) - 8.0
|
|
951
|
+
child.move_speed = float(child.move_speed) + 0.1
|
|
952
|
+
child.contact_damage = float(child.contact_damage) * 0.7
|
|
953
|
+
child.hitbox_size = CREATURE_HITBOX_ALIVE
|
|
954
|
+
self._entries[child_idx] = child
|
|
955
|
+
self.spawned_count += 1
|
|
956
|
+
|
|
957
|
+
state.effects.spawn_burst(
|
|
958
|
+
pos_x=float(creature.x),
|
|
959
|
+
pos_y=float(creature.y),
|
|
960
|
+
count=8,
|
|
961
|
+
rand=rand,
|
|
962
|
+
detail_preset=int(detail_preset),
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
xp_base = int(creature.reward_value)
|
|
966
|
+
killer: PlayerState | None = None
|
|
967
|
+
if players:
|
|
968
|
+
player_index = _owner_id_to_player_index(int(creature.last_hit_owner_id))
|
|
969
|
+
if player_index is None or not (0 <= player_index < len(players)):
|
|
970
|
+
player_index = 0
|
|
971
|
+
killer = players[player_index]
|
|
972
|
+
|
|
973
|
+
if killer is not None and perk_active(killer, PerkId.BLOODY_MESS_QUICK_LEARNER):
|
|
974
|
+
xp_base = int(float(creature.reward_value) * 1.3)
|
|
975
|
+
|
|
976
|
+
xp_awarded = 0
|
|
977
|
+
if killer is not None:
|
|
978
|
+
xp_awarded = award_experience(state, killer, xp_base)
|
|
979
|
+
|
|
980
|
+
if players:
|
|
981
|
+
spawned_bonus = None
|
|
982
|
+
if (creature.flags & CreatureFlags.BONUS_ON_DEATH) and creature.bonus_id is not None:
|
|
983
|
+
spawned_bonus = state.bonus_pool.spawn_at(
|
|
984
|
+
creature.x,
|
|
985
|
+
creature.y,
|
|
986
|
+
int(creature.bonus_id),
|
|
987
|
+
int(creature.bonus_duration_override) if creature.bonus_duration_override is not None else -1,
|
|
988
|
+
world_width=world_width,
|
|
989
|
+
world_height=world_height,
|
|
990
|
+
)
|
|
991
|
+
else:
|
|
992
|
+
spawned_bonus = state.bonus_pool.try_spawn_on_kill(
|
|
993
|
+
creature.x,
|
|
994
|
+
creature.y,
|
|
995
|
+
state=state,
|
|
996
|
+
players=players,
|
|
997
|
+
world_width=world_width,
|
|
998
|
+
world_height=world_height,
|
|
999
|
+
)
|
|
1000
|
+
if spawned_bonus is not None:
|
|
1001
|
+
state.effects.spawn_burst(
|
|
1002
|
+
pos_x=float(spawned_bonus.pos_x),
|
|
1003
|
+
pos_y=float(spawned_bonus.pos_y),
|
|
1004
|
+
count=16,
|
|
1005
|
+
rand=rand,
|
|
1006
|
+
detail_preset=int(detail_preset),
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
if fx_queue is not None:
|
|
1010
|
+
fx_queue.add_random(pos_x=creature.x, pos_y=creature.y, rand=rand)
|
|
1011
|
+
|
|
1012
|
+
return CreatureDeath(
|
|
1013
|
+
index=int(idx),
|
|
1014
|
+
x=float(creature.x),
|
|
1015
|
+
y=float(creature.y),
|
|
1016
|
+
type_id=int(creature.type_id),
|
|
1017
|
+
reward_value=float(creature.reward_value),
|
|
1018
|
+
xp_awarded=int(xp_awarded),
|
|
1019
|
+
)
|