crimsonland 0.1.0.dev14__py3-none-any.whl → 0.1.0.dev16__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/cli.py +63 -0
- crimson/creatures/damage.py +111 -36
- crimson/creatures/runtime.py +246 -156
- crimson/creatures/spawn.py +7 -3
- crimson/debug.py +9 -0
- crimson/demo.py +38 -45
- crimson/effects.py +7 -13
- crimson/frontend/high_scores_layout.py +81 -0
- crimson/frontend/panels/base.py +4 -1
- crimson/frontend/panels/controls.py +0 -15
- crimson/frontend/panels/databases.py +291 -3
- crimson/frontend/panels/mods.py +0 -15
- crimson/frontend/panels/play_game.py +0 -16
- crimson/game.py +689 -3
- crimson/gameplay.py +921 -569
- crimson/modes/base_gameplay_mode.py +33 -12
- crimson/modes/components/__init__.py +2 -0
- crimson/modes/components/highscore_record_builder.py +58 -0
- crimson/modes/components/perk_menu_controller.py +325 -0
- crimson/modes/quest_mode.py +94 -272
- crimson/modes/rush_mode.py +12 -43
- crimson/modes/survival_mode.py +109 -330
- crimson/modes/tutorial_mode.py +46 -247
- crimson/modes/typo_mode.py +11 -38
- crimson/oracle.py +396 -0
- crimson/perks.py +5 -2
- crimson/player_damage.py +95 -36
- crimson/projectiles.py +539 -320
- crimson/render/projectile_draw_registry.py +637 -0
- crimson/render/projectile_render_registry.py +110 -0
- crimson/render/secondary_projectile_draw_registry.py +206 -0
- crimson/render/world_renderer.py +58 -707
- crimson/sim/world_state.py +118 -61
- crimson/typo/spawns.py +5 -12
- crimson/ui/demo_trial_overlay.py +3 -11
- crimson/ui/formatting.py +24 -0
- crimson/ui/game_over.py +12 -58
- crimson/ui/hud.py +72 -39
- crimson/ui/layout.py +20 -0
- crimson/ui/perk_menu.py +9 -34
- crimson/ui/quest_results.py +28 -70
- crimson/ui/text_input.py +20 -0
- crimson/views/_ui_helpers.py +27 -0
- crimson/views/aim_debug.py +15 -32
- crimson/views/animations.py +18 -28
- crimson/views/arsenal_debug.py +22 -32
- crimson/views/bonuses.py +23 -36
- crimson/views/camera_debug.py +16 -29
- crimson/views/camera_shake.py +9 -33
- crimson/views/corpse_stamp_debug.py +13 -21
- crimson/views/decals_debug.py +36 -23
- crimson/views/fonts.py +8 -25
- crimson/views/ground.py +4 -21
- crimson/views/lighting_debug.py +42 -45
- crimson/views/particles.py +33 -42
- crimson/views/perk_menu_debug.py +3 -10
- crimson/views/player.py +50 -44
- crimson/views/player_sprite_debug.py +24 -31
- crimson/views/projectile_fx.py +57 -52
- crimson/views/projectile_render_debug.py +24 -33
- crimson/views/projectiles.py +24 -37
- crimson/views/spawn_plan.py +13 -29
- crimson/views/sprites.py +14 -29
- crimson/views/terrain.py +6 -23
- crimson/views/ui.py +7 -24
- crimson/views/wicons.py +28 -33
- {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/METADATA +1 -1
- {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/RECORD +73 -62
- {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/WHEEL +1 -1
- grim/config.py +29 -1
- grim/console.py +7 -10
- grim/math.py +12 -0
- {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
crimson/oracle.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Headless oracle mode for differential testing.
|
|
2
|
+
|
|
3
|
+
Runs the game simulation without rendering, accepts inputs from a JSON file,
|
|
4
|
+
and emits game state to stdout each frame for comparison with other implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .gameplay import GameplayState, PlayerInput, PlayerState
|
|
17
|
+
from .creatures.runtime import CreaturePool
|
|
18
|
+
from .bonuses import BonusId
|
|
19
|
+
from .sim.world_state import WorldState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OutputMode:
|
|
23
|
+
"""Output modes for oracle state emission."""
|
|
24
|
+
FULL = "full" # Complete state every sample
|
|
25
|
+
SUMMARY = "summary" # Score, kills, player pos/health only
|
|
26
|
+
HASH = "hash" # SHA256 hash of full state for fast comparison
|
|
27
|
+
CHECKPOINTS = "checkpoints" # Only on significant events
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class OracleConfig:
|
|
32
|
+
"""Configuration for headless oracle mode."""
|
|
33
|
+
|
|
34
|
+
seed: int
|
|
35
|
+
input_file: Path | None
|
|
36
|
+
max_frames: int = 36000 # 10 minutes at 60fps
|
|
37
|
+
frame_rate: int = 60
|
|
38
|
+
sample_rate: int = 1 # Emit state every N frames (1 = every frame, 60 = once per second)
|
|
39
|
+
output_mode: str = OutputMode.SUMMARY
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class FrameInput:
|
|
44
|
+
"""Input for a single frame."""
|
|
45
|
+
|
|
46
|
+
frame: int
|
|
47
|
+
move_x: float = 0.0
|
|
48
|
+
move_y: float = 0.0
|
|
49
|
+
aim_x: float = 0.0
|
|
50
|
+
aim_y: float = 0.0
|
|
51
|
+
fire_down: bool = False
|
|
52
|
+
fire_pressed: bool = False
|
|
53
|
+
reload_pressed: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_inputs(path: Path) -> list[FrameInput]:
|
|
57
|
+
"""Load input sequence from JSON file.
|
|
58
|
+
|
|
59
|
+
Expected format:
|
|
60
|
+
{
|
|
61
|
+
"frames": [
|
|
62
|
+
{"frame": 0, "move_x": 1.0, "move_y": 0.0, "aim_x": 100, "aim_y": 200, "fire_down": true},
|
|
63
|
+
{"frame": 60, "move_x": 0.0, "move_y": -1.0, "fire_pressed": true},
|
|
64
|
+
...
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
data = json.loads(path.read_text())
|
|
69
|
+
inputs: list[FrameInput] = []
|
|
70
|
+
for entry in data.get("frames", []):
|
|
71
|
+
inputs.append(
|
|
72
|
+
FrameInput(
|
|
73
|
+
frame=int(entry.get("frame", 0)),
|
|
74
|
+
move_x=float(entry.get("move_x", 0.0)),
|
|
75
|
+
move_y=float(entry.get("move_y", 0.0)),
|
|
76
|
+
aim_x=float(entry.get("aim_x", 0.0)),
|
|
77
|
+
aim_y=float(entry.get("aim_y", 0.0)),
|
|
78
|
+
fire_down=bool(entry.get("fire_down", False)),
|
|
79
|
+
fire_pressed=bool(entry.get("fire_pressed", False)),
|
|
80
|
+
reload_pressed=bool(entry.get("reload_pressed", False)),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return sorted(inputs, key=lambda i: i.frame)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def export_player_state(player: PlayerState) -> dict[str, Any]:
|
|
87
|
+
"""Export player state to JSON-serializable dict."""
|
|
88
|
+
return {
|
|
89
|
+
"index": int(player.index),
|
|
90
|
+
"pos_x": round(float(player.pos_x), 4),
|
|
91
|
+
"pos_y": round(float(player.pos_y), 4),
|
|
92
|
+
"health": round(float(player.health), 4),
|
|
93
|
+
"weapon_id": int(player.weapon_id),
|
|
94
|
+
"ammo": round(float(player.ammo), 4),
|
|
95
|
+
"experience": int(player.experience),
|
|
96
|
+
"level": int(player.level),
|
|
97
|
+
"reload_active": bool(player.reload_active),
|
|
98
|
+
"heading": round(float(player.heading), 4),
|
|
99
|
+
"aim_heading": round(float(player.aim_heading), 4),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def export_creature_state(creature: Any) -> dict[str, Any]:
|
|
104
|
+
"""Export creature state to JSON-serializable dict."""
|
|
105
|
+
return {
|
|
106
|
+
"id": int(creature.id) if hasattr(creature, "id") else -1,
|
|
107
|
+
"type_id": int(creature.type_id),
|
|
108
|
+
"x": round(float(creature.x), 4),
|
|
109
|
+
"y": round(float(creature.y), 4),
|
|
110
|
+
"hp": round(float(creature.hp), 4),
|
|
111
|
+
"active": bool(creature.active),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def export_bonus_state(bonus: Any) -> dict[str, Any]:
|
|
116
|
+
"""Export bonus state to JSON-serializable dict."""
|
|
117
|
+
return {
|
|
118
|
+
"bonus_id": int(bonus.bonus_id),
|
|
119
|
+
"pos_x": round(float(bonus.pos_x), 4),
|
|
120
|
+
"pos_y": round(float(bonus.pos_y), 4),
|
|
121
|
+
"time_left": round(float(bonus.time_left), 4),
|
|
122
|
+
"picked": bool(bonus.picked),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def export_projectile_state(proj: Any) -> dict[str, Any]:
|
|
127
|
+
"""Export projectile state to JSON-serializable dict."""
|
|
128
|
+
return {
|
|
129
|
+
"type_id": int(proj.type_id),
|
|
130
|
+
"x": round(float(proj.x), 4),
|
|
131
|
+
"y": round(float(proj.y), 4),
|
|
132
|
+
"active": bool(proj.active),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def export_game_state_full(
|
|
137
|
+
frame: int,
|
|
138
|
+
world_state: WorldState,
|
|
139
|
+
players: list[PlayerState],
|
|
140
|
+
rng_state: int,
|
|
141
|
+
elapsed_ms: float,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Export complete game state for a frame."""
|
|
144
|
+
state = world_state.state
|
|
145
|
+
|
|
146
|
+
# Collect active creatures
|
|
147
|
+
creatures = []
|
|
148
|
+
for creature in world_state.creatures.entries:
|
|
149
|
+
if creature.active:
|
|
150
|
+
creatures.append(export_creature_state(creature))
|
|
151
|
+
|
|
152
|
+
# Collect active bonuses
|
|
153
|
+
bonuses = []
|
|
154
|
+
for bonus in state.bonus_pool.iter_active():
|
|
155
|
+
bonuses.append(export_bonus_state(bonus))
|
|
156
|
+
|
|
157
|
+
# Collect active projectiles
|
|
158
|
+
projectiles = []
|
|
159
|
+
for proj in state.projectiles.entries:
|
|
160
|
+
if proj.active:
|
|
161
|
+
projectiles.append(export_projectile_state(proj))
|
|
162
|
+
|
|
163
|
+
# Score is player experience, kills tracked on creatures pool
|
|
164
|
+
total_experience = sum(p.experience for p in players)
|
|
165
|
+
kill_count = world_state.creatures.kill_count
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"frame": frame,
|
|
169
|
+
"rng_state": rng_state,
|
|
170
|
+
"elapsed_ms": round(elapsed_ms, 4),
|
|
171
|
+
"score": int(total_experience),
|
|
172
|
+
"kills": int(kill_count),
|
|
173
|
+
"players": [export_player_state(p) for p in players],
|
|
174
|
+
"creatures": creatures,
|
|
175
|
+
"bonuses": bonuses,
|
|
176
|
+
"projectiles": projectiles,
|
|
177
|
+
"bonus_timers": {
|
|
178
|
+
"weapon_power_up": round(float(state.bonuses.weapon_power_up), 4),
|
|
179
|
+
"reflex_boost": round(float(state.bonuses.reflex_boost), 4),
|
|
180
|
+
"freeze": round(float(state.bonuses.freeze), 4),
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def export_game_state_summary(
|
|
186
|
+
frame: int,
|
|
187
|
+
world_state: WorldState,
|
|
188
|
+
players: list[PlayerState],
|
|
189
|
+
rng_state: int,
|
|
190
|
+
elapsed_ms: float,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""Export minimal game state for fast comparison."""
|
|
193
|
+
total_experience = sum(p.experience for p in players)
|
|
194
|
+
kill_count = world_state.creatures.kill_count
|
|
195
|
+
creature_count = sum(1 for c in world_state.creatures.entries if c.active)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"frame": frame,
|
|
199
|
+
"rng_state": rng_state,
|
|
200
|
+
"elapsed_ms": round(elapsed_ms, 4),
|
|
201
|
+
"score": int(total_experience),
|
|
202
|
+
"kills": int(kill_count),
|
|
203
|
+
"creature_count": creature_count,
|
|
204
|
+
"players": [
|
|
205
|
+
{
|
|
206
|
+
"pos_x": round(float(p.pos_x), 2),
|
|
207
|
+
"pos_y": round(float(p.pos_y), 2),
|
|
208
|
+
"health": round(float(p.health), 2),
|
|
209
|
+
"weapon_id": int(p.weapon_id),
|
|
210
|
+
"level": int(p.level),
|
|
211
|
+
}
|
|
212
|
+
for p in players
|
|
213
|
+
],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def export_game_state_hash(
|
|
218
|
+
frame: int,
|
|
219
|
+
world_state: WorldState,
|
|
220
|
+
players: list[PlayerState],
|
|
221
|
+
rng_state: int,
|
|
222
|
+
elapsed_ms: float,
|
|
223
|
+
) -> dict[str, Any]:
|
|
224
|
+
"""Export hash of game state for ultra-fast comparison."""
|
|
225
|
+
# Get full state and hash it
|
|
226
|
+
full_state = export_game_state_full(
|
|
227
|
+
frame, world_state, players, rng_state, elapsed_ms
|
|
228
|
+
)
|
|
229
|
+
# Remove frame from hash computation (it's metadata)
|
|
230
|
+
hashable = {k: v for k, v in full_state.items() if k != "frame"}
|
|
231
|
+
state_bytes = json.dumps(hashable, sort_keys=True).encode()
|
|
232
|
+
state_hash = hashlib.sha256(state_bytes).hexdigest()[:16]
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"frame": frame,
|
|
236
|
+
"hash": state_hash,
|
|
237
|
+
"score": full_state["score"],
|
|
238
|
+
"kills": full_state["kills"],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(slots=True)
|
|
243
|
+
class CheckpointTracker:
|
|
244
|
+
"""Track significant events for checkpoint-only output."""
|
|
245
|
+
last_score: int = 0
|
|
246
|
+
last_kills: int = 0
|
|
247
|
+
last_level: int = 1
|
|
248
|
+
last_health: float = 100.0
|
|
249
|
+
last_weapon_id: int = 1
|
|
250
|
+
|
|
251
|
+
def check_and_update(self, players: list[PlayerState], world_state: WorldState) -> bool:
|
|
252
|
+
"""Return True if any significant change occurred."""
|
|
253
|
+
score = sum(p.experience for p in players)
|
|
254
|
+
kills = world_state.creatures.kill_count
|
|
255
|
+
level = players[0].level if players else 1
|
|
256
|
+
health = players[0].health if players else 0.0
|
|
257
|
+
weapon_id = players[0].weapon_id if players else 1
|
|
258
|
+
|
|
259
|
+
changed = (
|
|
260
|
+
score != self.last_score or
|
|
261
|
+
kills != self.last_kills or
|
|
262
|
+
level != self.last_level or
|
|
263
|
+
int(health) != int(self.last_health) or # Only trigger on integer health change
|
|
264
|
+
weapon_id != self.last_weapon_id
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if changed:
|
|
268
|
+
self.last_score = score
|
|
269
|
+
self.last_kills = kills
|
|
270
|
+
self.last_level = level
|
|
271
|
+
self.last_health = health
|
|
272
|
+
self.last_weapon_id = weapon_id
|
|
273
|
+
|
|
274
|
+
return changed
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def run_headless(config: OracleConfig) -> None:
|
|
278
|
+
"""Run the game in headless mode, emitting state JSON each frame."""
|
|
279
|
+
from .sim.world_state import WorldState
|
|
280
|
+
from .effects import FxQueue, FxQueueRotated
|
|
281
|
+
from .game_modes import GameMode
|
|
282
|
+
|
|
283
|
+
# Build world state
|
|
284
|
+
world_state = WorldState.build(
|
|
285
|
+
world_size=1024.0,
|
|
286
|
+
demo_mode_active=False,
|
|
287
|
+
hardcore=False,
|
|
288
|
+
difficulty_level=0,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Initialize with seed
|
|
292
|
+
world_state.state.rng.srand(config.seed)
|
|
293
|
+
|
|
294
|
+
# Set up player at center
|
|
295
|
+
players = world_state.players
|
|
296
|
+
if not players:
|
|
297
|
+
from .gameplay import PlayerState, weapon_assign_player
|
|
298
|
+
|
|
299
|
+
player = PlayerState(index=0, pos_x=512.0, pos_y=512.0)
|
|
300
|
+
weapon_assign_player(player, 1)
|
|
301
|
+
players.append(player)
|
|
302
|
+
|
|
303
|
+
# Load inputs if provided
|
|
304
|
+
inputs_by_frame: dict[int, FrameInput] = {}
|
|
305
|
+
if config.input_file is not None:
|
|
306
|
+
for inp in load_inputs(config.input_file):
|
|
307
|
+
inputs_by_frame[inp.frame] = inp
|
|
308
|
+
|
|
309
|
+
dt = 1.0 / float(config.frame_rate)
|
|
310
|
+
current_input = FrameInput(frame=0)
|
|
311
|
+
elapsed_ms = 0.0
|
|
312
|
+
|
|
313
|
+
# Create dummy FX queues (not used in headless mode)
|
|
314
|
+
fx_queue = FxQueue()
|
|
315
|
+
fx_queue_rotated = FxQueueRotated()
|
|
316
|
+
|
|
317
|
+
# Checkpoint tracker for event-driven output
|
|
318
|
+
checkpoint_tracker = CheckpointTracker()
|
|
319
|
+
|
|
320
|
+
# Select export function based on output mode
|
|
321
|
+
export_fn = {
|
|
322
|
+
OutputMode.FULL: export_game_state_full,
|
|
323
|
+
OutputMode.SUMMARY: export_game_state_summary,
|
|
324
|
+
OutputMode.HASH: export_game_state_hash,
|
|
325
|
+
OutputMode.CHECKPOINTS: export_game_state_summary, # Same format, different trigger
|
|
326
|
+
}.get(config.output_mode, export_game_state_summary)
|
|
327
|
+
|
|
328
|
+
for frame in range(config.max_frames):
|
|
329
|
+
# Update current input if we have one for this frame
|
|
330
|
+
if frame in inputs_by_frame:
|
|
331
|
+
current_input = inputs_by_frame[frame]
|
|
332
|
+
|
|
333
|
+
# Convert to PlayerInput
|
|
334
|
+
player_inputs = [
|
|
335
|
+
PlayerInput(
|
|
336
|
+
move_x=current_input.move_x,
|
|
337
|
+
move_y=current_input.move_y,
|
|
338
|
+
aim_x=current_input.aim_x,
|
|
339
|
+
aim_y=current_input.aim_y,
|
|
340
|
+
fire_down=current_input.fire_down,
|
|
341
|
+
fire_pressed=current_input.fire_pressed,
|
|
342
|
+
reload_pressed=current_input.reload_pressed,
|
|
343
|
+
)
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
# Step simulation
|
|
347
|
+
world_state.step(
|
|
348
|
+
dt,
|
|
349
|
+
inputs=player_inputs,
|
|
350
|
+
world_size=1024.0,
|
|
351
|
+
damage_scale_by_type={},
|
|
352
|
+
detail_preset=5,
|
|
353
|
+
fx_queue=fx_queue,
|
|
354
|
+
fx_queue_rotated=fx_queue_rotated,
|
|
355
|
+
auto_pick_perks=True,
|
|
356
|
+
game_mode=int(GameMode.SURVIVAL),
|
|
357
|
+
perk_progression_enabled=True,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
elapsed_ms += dt * 1000.0
|
|
361
|
+
|
|
362
|
+
# Determine if we should emit state this frame
|
|
363
|
+
should_emit = False
|
|
364
|
+
if config.output_mode == OutputMode.CHECKPOINTS:
|
|
365
|
+
# Only emit on significant changes
|
|
366
|
+
should_emit = checkpoint_tracker.check_and_update(players, world_state)
|
|
367
|
+
# Always emit first and last frame
|
|
368
|
+
if frame == 0:
|
|
369
|
+
should_emit = True
|
|
370
|
+
else:
|
|
371
|
+
# Sample rate based emission
|
|
372
|
+
should_emit = (frame % config.sample_rate == 0)
|
|
373
|
+
|
|
374
|
+
if should_emit:
|
|
375
|
+
state_json = export_fn(
|
|
376
|
+
frame=frame,
|
|
377
|
+
world_state=world_state,
|
|
378
|
+
players=players,
|
|
379
|
+
rng_state=world_state.state.rng.state,
|
|
380
|
+
elapsed_ms=elapsed_ms,
|
|
381
|
+
)
|
|
382
|
+
print(json.dumps(state_json), flush=True)
|
|
383
|
+
|
|
384
|
+
# Check if all players dead
|
|
385
|
+
if all(p.health <= 0 for p in players):
|
|
386
|
+
# Always emit final state on death
|
|
387
|
+
if not should_emit:
|
|
388
|
+
state_json = export_fn(
|
|
389
|
+
frame=frame,
|
|
390
|
+
world_state=world_state,
|
|
391
|
+
players=players,
|
|
392
|
+
rng_state=world_state.state.rng.state,
|
|
393
|
+
elapsed_ms=elapsed_ms,
|
|
394
|
+
)
|
|
395
|
+
print(json.dumps(state_json), flush=True)
|
|
396
|
+
break
|
crimson/perks.py
CHANGED
|
@@ -219,7 +219,8 @@ PERK_TABLE = [
|
|
|
219
219
|
prereq=(),
|
|
220
220
|
notes=(
|
|
221
221
|
"Sets `player_plaguebearer_active` (`DAT_004908b9`). In `creature_update_all`, infected creatures "
|
|
222
|
-
"(`collision_flag != 0`) take `15` damage every `0.5` seconds via
|
|
222
|
+
"(`collision_flag != 0`, i.e. `CreatureState.plague_infected`) take `15` damage every `0.5` seconds via "
|
|
223
|
+
"`collision_timer`; on an infection kill, "
|
|
223
224
|
"increments `plaguebearer_infection_count`. While `plaguebearer_infection_count < 60`, "
|
|
224
225
|
"`FUN_00425d80` spreads infection between creatures within `45` units when the target has `<150` HP. "
|
|
225
226
|
"While `plaguebearer_infection_count < 50`, the player infects nearby creatures (`<30` units) with `<150` HP."
|
|
@@ -673,7 +674,9 @@ PERK_TABLE = [
|
|
|
673
674
|
notes=(
|
|
674
675
|
"`player_take_damage` (0x00425e50): if Death Clock is active, returns immediately (immune to damage). "
|
|
675
676
|
"`perk_apply` (0x004055e0): clears Regeneration and Greater Regeneration perk counts and sets "
|
|
676
|
-
"`player.health = 100.0` when `health > 0.0`."
|
|
677
|
+
"`player.health = 100.0` when `health > 0.0`. "
|
|
678
|
+
"`perks_update_effects` (0x00406b40): if `health <= 0.0`, sets it to `0.0`; otherwise decrements "
|
|
679
|
+
"`health -= frame_dt * 3.3333333`."
|
|
677
680
|
),
|
|
678
681
|
),
|
|
679
682
|
PerkMeta(
|
crimson/player_damage.py
CHANGED
|
@@ -6,6 +6,7 @@ This is a minimal, rewrite-focused port of `player_take_damage` (0x00425e50).
|
|
|
6
6
|
See: `docs/crimsonland-exe/player-damage.md`.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
from dataclasses import dataclass
|
|
9
10
|
from typing import Callable
|
|
10
11
|
|
|
11
12
|
from .gameplay import GameplayState, PlayerState, perk_active
|
|
@@ -14,6 +15,87 @@ from .perks import PerkId
|
|
|
14
15
|
__all__ = ["player_take_damage"]
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class _PlayerDamageCtx:
|
|
20
|
+
state: GameplayState
|
|
21
|
+
player: PlayerState
|
|
22
|
+
dmg: float
|
|
23
|
+
dt: float | None
|
|
24
|
+
rng: Callable[[], int]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_PlayerDamagePreStep = Callable[[_PlayerDamageCtx], bool]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _player_damage_gate_death_clock(ctx: _PlayerDamageCtx) -> bool:
|
|
31
|
+
return perk_active(ctx.player, PerkId.DEATH_CLOCK)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _player_damage_scale_tough_reloader(ctx: _PlayerDamageCtx) -> bool:
|
|
35
|
+
if perk_active(ctx.player, PerkId.TOUGH_RELOADER) and bool(ctx.player.reload_active):
|
|
36
|
+
ctx.dmg *= 0.5
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _player_damage_gate_shield(ctx: _PlayerDamageCtx) -> bool:
|
|
41
|
+
return float(ctx.player.shield_timer) > 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _player_damage_scale_thick_skinned(ctx: _PlayerDamageCtx) -> bool:
|
|
45
|
+
if perk_active(ctx.player, PerkId.THICK_SKINNED):
|
|
46
|
+
ctx.dmg *= 2.0 / 3.0
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _player_damage_gate_dodge(ctx: _PlayerDamageCtx) -> bool:
|
|
51
|
+
if perk_active(ctx.player, PerkId.NINJA):
|
|
52
|
+
return (ctx.rng() % 3) == 0
|
|
53
|
+
if perk_active(ctx.player, PerkId.DODGER):
|
|
54
|
+
return (ctx.rng() % 5) == 0
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_PLAYER_DAMAGE_PRE_STEPS: tuple[_PlayerDamagePreStep, ...] = (
|
|
59
|
+
_player_damage_gate_death_clock,
|
|
60
|
+
_player_damage_scale_tough_reloader,
|
|
61
|
+
_player_damage_gate_shield,
|
|
62
|
+
_player_damage_scale_thick_skinned,
|
|
63
|
+
_player_damage_gate_dodge,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _player_damage_apply_health(ctx: _PlayerDamageCtx) -> None:
|
|
68
|
+
if perk_active(ctx.player, PerkId.HIGHLANDER):
|
|
69
|
+
if (ctx.rng() % 10) == 0:
|
|
70
|
+
ctx.player.health = 0.0
|
|
71
|
+
else:
|
|
72
|
+
ctx.player.health -= ctx.dmg
|
|
73
|
+
if ctx.player.health < 0.0 and ctx.dt is not None and float(ctx.dt) > 0.0:
|
|
74
|
+
ctx.player.death_timer -= float(ctx.dt) * 28.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_PlayerDamagePostStep = Callable[[_PlayerDamageCtx], None]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _player_damage_post_hit_disruption(ctx: _PlayerDamageCtx) -> None:
|
|
81
|
+
if perk_active(ctx.player, PerkId.UNSTOPPABLE):
|
|
82
|
+
return
|
|
83
|
+
# player_take_damage @ 0x00425e50: on-hit camera/spread disruption.
|
|
84
|
+
ctx.player.heading += float((ctx.rng() % 100) - 50) * 0.04
|
|
85
|
+
ctx.player.spread_heat = min(0.48, float(ctx.player.spread_heat) + ctx.dmg * 0.01)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _player_damage_post_low_health_warning(ctx: _PlayerDamageCtx) -> None:
|
|
89
|
+
if ctx.player.health <= 20.0 and (ctx.rng() & 7) == 3:
|
|
90
|
+
ctx.player.low_health_timer = 0.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_PLAYER_DAMAGE_POST_STEPS: tuple[_PlayerDamagePostStep, ...] = (
|
|
94
|
+
_player_damage_post_hit_disruption,
|
|
95
|
+
_player_damage_post_low_health_warning,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
17
99
|
def player_take_damage(
|
|
18
100
|
state: GameplayState,
|
|
19
101
|
player: PlayerState,
|
|
@@ -32,46 +114,23 @@ def player_take_damage(
|
|
|
32
114
|
dmg = float(damage)
|
|
33
115
|
if dmg <= 0.0:
|
|
34
116
|
return 0.0
|
|
35
|
-
|
|
36
|
-
# 1) Death Clock immunity.
|
|
37
|
-
if perk_active(player, PerkId.DEATH_CLOCK):
|
|
117
|
+
if state.debug_god_mode:
|
|
38
118
|
return 0.0
|
|
39
119
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if perk_active(player, PerkId.THICK_SKINNED):
|
|
50
|
-
dmg *= 2.0 / 3.0
|
|
51
|
-
|
|
52
|
-
rng = rand or state.rng.rand
|
|
53
|
-
if perk_active(player, PerkId.NINJA):
|
|
54
|
-
if (rng() % 3) == 0:
|
|
55
|
-
return 0.0
|
|
56
|
-
elif perk_active(player, PerkId.DODGER):
|
|
57
|
-
if (rng() % 5) == 0:
|
|
120
|
+
ctx = _PlayerDamageCtx(
|
|
121
|
+
state=state,
|
|
122
|
+
player=player,
|
|
123
|
+
dmg=dmg,
|
|
124
|
+
dt=dt,
|
|
125
|
+
rng=rand or state.rng.rand,
|
|
126
|
+
)
|
|
127
|
+
for step in _PLAYER_DAMAGE_PRE_STEPS:
|
|
128
|
+
if step(ctx):
|
|
58
129
|
return 0.0
|
|
59
130
|
|
|
60
131
|
health_before = float(player.health)
|
|
61
132
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
else:
|
|
66
|
-
player.health -= dmg
|
|
67
|
-
if player.health < 0.0 and dt is not None and float(dt) > 0.0:
|
|
68
|
-
player.death_timer -= float(dt) * 28.0
|
|
69
|
-
|
|
70
|
-
if not perk_active(player, PerkId.UNSTOPPABLE):
|
|
71
|
-
# player_take_damage @ 0x00425e50: on-hit camera/spread disruption.
|
|
72
|
-
player.heading += float((rng() % 100) - 50) * 0.04
|
|
73
|
-
player.spread_heat = min(0.48, float(player.spread_heat) + dmg * 0.01)
|
|
74
|
-
|
|
75
|
-
if player.health <= 20.0 and (rng() & 7) == 3:
|
|
76
|
-
player.low_health_timer = 0.0
|
|
133
|
+
_player_damage_apply_health(ctx)
|
|
134
|
+
for step in _PLAYER_DAMAGE_POST_STEPS:
|
|
135
|
+
step(ctx)
|
|
77
136
|
return max(0.0, health_before - float(player.health))
|