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
crimson/gameplay.py
ADDED
|
@@ -0,0 +1,2467 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import math
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol
|
|
6
|
+
|
|
7
|
+
from .bonuses import BONUS_BY_ID, BonusId
|
|
8
|
+
from grim.rand import Crand
|
|
9
|
+
from .effects import EffectPool, FxQueue, ParticlePool, SpriteEffectPool
|
|
10
|
+
from .game_modes import GameMode
|
|
11
|
+
from .perks import PerkFlags, PerkId, PERK_BY_ID, PERK_TABLE
|
|
12
|
+
from .projectiles import CreatureDamageApplier, Damageable, ProjectilePool, ProjectileTypeId, SecondaryProjectilePool
|
|
13
|
+
from .weapons import (
|
|
14
|
+
WEAPON_BY_ID,
|
|
15
|
+
WEAPON_TABLE,
|
|
16
|
+
Weapon,
|
|
17
|
+
WeaponId,
|
|
18
|
+
projectile_type_id_from_weapon_id,
|
|
19
|
+
weapon_entry_for_projectile_type_id,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .persistence.save_status import GameStatus
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _HasPos(Protocol):
|
|
27
|
+
pos_x: float
|
|
28
|
+
pos_y: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _CreatureForPerks(Protocol):
|
|
32
|
+
active: bool
|
|
33
|
+
x: float
|
|
34
|
+
y: float
|
|
35
|
+
hp: float
|
|
36
|
+
flags: int
|
|
37
|
+
hitbox_size: float
|
|
38
|
+
collision_timer: float
|
|
39
|
+
reward_value: float
|
|
40
|
+
size: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class PlayerInput:
|
|
45
|
+
move_x: float = 0.0
|
|
46
|
+
move_y: float = 0.0
|
|
47
|
+
aim_x: float = 0.0
|
|
48
|
+
aim_y: float = 0.0
|
|
49
|
+
fire_down: bool = False
|
|
50
|
+
fire_pressed: bool = False
|
|
51
|
+
reload_pressed: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
PERK_COUNT_SIZE = 0x80
|
|
55
|
+
PERK_ID_MAX = max(int(meta.perk_id) for meta in PERK_TABLE)
|
|
56
|
+
WEAPON_COUNT_SIZE = max(int(entry.weapon_id) for entry in WEAPON_TABLE) + 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class PlayerState:
|
|
61
|
+
index: int
|
|
62
|
+
pos_x: float
|
|
63
|
+
pos_y: float
|
|
64
|
+
health: float = 100.0
|
|
65
|
+
size: float = 50.0
|
|
66
|
+
|
|
67
|
+
speed_multiplier: float = 2.0
|
|
68
|
+
move_speed: float = 0.0
|
|
69
|
+
move_phase: float = 0.0
|
|
70
|
+
heading: float = 0.0
|
|
71
|
+
death_timer: float = 16.0
|
|
72
|
+
low_health_timer: float = 100.0
|
|
73
|
+
|
|
74
|
+
aim_x: float = 0.0
|
|
75
|
+
aim_y: float = 0.0
|
|
76
|
+
aim_heading: float = 0.0
|
|
77
|
+
aim_dir_x: float = 1.0
|
|
78
|
+
aim_dir_y: float = 0.0
|
|
79
|
+
evil_eyes_target_creature: int = -1
|
|
80
|
+
|
|
81
|
+
bonus_aim_hover_index: int = -1
|
|
82
|
+
bonus_aim_hover_timer_ms: float = 0.0
|
|
83
|
+
|
|
84
|
+
weapon_id: int = 1
|
|
85
|
+
clip_size: int = 0
|
|
86
|
+
ammo: float = 0.0
|
|
87
|
+
reload_active: bool = False
|
|
88
|
+
reload_timer: float = 0.0
|
|
89
|
+
reload_timer_max: float = 0.0
|
|
90
|
+
shot_cooldown: float = 0.0
|
|
91
|
+
shot_seq: int = 0
|
|
92
|
+
weapon_reset_latch: int = 0
|
|
93
|
+
aux_timer: float = 0.0
|
|
94
|
+
spread_heat: float = 0.01
|
|
95
|
+
muzzle_flash_alpha: float = 0.0
|
|
96
|
+
|
|
97
|
+
alt_weapon_id: int | None = None
|
|
98
|
+
alt_clip_size: int = 0
|
|
99
|
+
alt_ammo: float = 0.0
|
|
100
|
+
alt_reload_active: bool = False
|
|
101
|
+
alt_reload_timer: float = 0.0
|
|
102
|
+
alt_reload_timer_max: float = 0.0
|
|
103
|
+
alt_shot_cooldown: float = 0.0
|
|
104
|
+
|
|
105
|
+
experience: int = 0
|
|
106
|
+
level: int = 1
|
|
107
|
+
|
|
108
|
+
perk_counts: list[int] = field(default_factory=lambda: [0] * PERK_COUNT_SIZE)
|
|
109
|
+
plaguebearer_active: bool = False
|
|
110
|
+
hot_tempered_timer: float = 0.0
|
|
111
|
+
man_bomb_timer: float = 0.0
|
|
112
|
+
living_fortress_timer: float = 0.0
|
|
113
|
+
fire_cough_timer: float = 0.0
|
|
114
|
+
|
|
115
|
+
speed_bonus_timer: float = 0.0
|
|
116
|
+
shield_timer: float = 0.0
|
|
117
|
+
fire_bullets_timer: float = 0.0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(slots=True)
|
|
121
|
+
class BonusTimers:
|
|
122
|
+
weapon_power_up: float = 0.0
|
|
123
|
+
reflex_boost: float = 0.0
|
|
124
|
+
energizer: float = 0.0
|
|
125
|
+
double_experience: float = 0.0
|
|
126
|
+
freeze: float = 0.0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True)
|
|
130
|
+
class PerkEffectIntervals:
|
|
131
|
+
"""Global thresholds used by perk timers in `player_update`.
|
|
132
|
+
|
|
133
|
+
These are global (not per-player) in crimsonland.exe: `flt_473310`,
|
|
134
|
+
`flt_473314`, and `flt_473318`.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
man_bomb: float = 4.0
|
|
138
|
+
fire_cough: float = 2.0
|
|
139
|
+
hot_tempered: float = 2.0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(slots=True)
|
|
143
|
+
class PerkSelectionState:
|
|
144
|
+
pending_count: int = 0
|
|
145
|
+
choices: list[int] = field(default_factory=list)
|
|
146
|
+
choices_dirty: bool = True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True, slots=True)
|
|
150
|
+
class _TimerRef:
|
|
151
|
+
kind: str # "global" or "player"
|
|
152
|
+
key: str
|
|
153
|
+
player_index: int | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(slots=True)
|
|
157
|
+
class BonusHudSlot:
|
|
158
|
+
active: bool = False
|
|
159
|
+
bonus_id: int = 0
|
|
160
|
+
label: str = ""
|
|
161
|
+
icon_id: int = -1
|
|
162
|
+
timer_ref: _TimerRef | None = None
|
|
163
|
+
timer_ref_alt: _TimerRef | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
BONUS_HUD_SLOT_COUNT = 16
|
|
167
|
+
|
|
168
|
+
BONUS_POOL_SIZE = 16
|
|
169
|
+
BONUS_SPAWN_MARGIN = 32.0
|
|
170
|
+
BONUS_SPAWN_MIN_DISTANCE = 32.0
|
|
171
|
+
BONUS_PICKUP_RADIUS = 26.0
|
|
172
|
+
BONUS_PICKUP_DECAY_RATE = 3.0
|
|
173
|
+
BONUS_PICKUP_LINGER = 0.5
|
|
174
|
+
BONUS_TIME_MAX = 10.0
|
|
175
|
+
BONUS_WEAPON_NEAR_RADIUS = 56.0
|
|
176
|
+
BONUS_AIM_HOVER_RADIUS = 24.0
|
|
177
|
+
BONUS_TELEKINETIC_PICKUP_MS = 650.0
|
|
178
|
+
|
|
179
|
+
WEAPON_DROP_ID_COUNT = 0x21 # weapon ids 1..33
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(slots=True)
|
|
183
|
+
class BonusHudState:
|
|
184
|
+
slots: list[BonusHudSlot] = field(default_factory=lambda: [BonusHudSlot() for _ in range(BONUS_HUD_SLOT_COUNT)])
|
|
185
|
+
|
|
186
|
+
def register(self, bonus_id: BonusId, *, label: str, icon_id: int, timer_ref: _TimerRef, timer_ref_alt: _TimerRef | None = None) -> None:
|
|
187
|
+
existing = None
|
|
188
|
+
free = None
|
|
189
|
+
for slot in self.slots:
|
|
190
|
+
if slot.active and slot.bonus_id == int(bonus_id):
|
|
191
|
+
existing = slot
|
|
192
|
+
break
|
|
193
|
+
if (not slot.active) and free is None:
|
|
194
|
+
free = slot
|
|
195
|
+
slot = existing or free
|
|
196
|
+
if slot is None:
|
|
197
|
+
slot = self.slots[-1]
|
|
198
|
+
slot.active = True
|
|
199
|
+
slot.bonus_id = int(bonus_id)
|
|
200
|
+
slot.label = label
|
|
201
|
+
slot.icon_id = int(icon_id)
|
|
202
|
+
slot.timer_ref = timer_ref
|
|
203
|
+
slot.timer_ref_alt = timer_ref_alt
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(slots=True)
|
|
207
|
+
class BonusEntry:
|
|
208
|
+
bonus_id: int = 0
|
|
209
|
+
picked: bool = False
|
|
210
|
+
time_left: float = 0.0
|
|
211
|
+
time_max: float = 0.0
|
|
212
|
+
pos_x: float = 0.0
|
|
213
|
+
pos_y: float = 0.0
|
|
214
|
+
amount: int = 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class BonusPickupEvent:
|
|
219
|
+
player_index: int
|
|
220
|
+
bonus_id: int
|
|
221
|
+
amount: int
|
|
222
|
+
pos_x: float
|
|
223
|
+
pos_y: float
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class BonusPool:
|
|
227
|
+
def __init__(self, *, size: int = BONUS_POOL_SIZE) -> None:
|
|
228
|
+
self._entries = [BonusEntry() for _ in range(int(size))]
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def entries(self) -> list[BonusEntry]:
|
|
232
|
+
return self._entries
|
|
233
|
+
|
|
234
|
+
def reset(self) -> None:
|
|
235
|
+
for entry in self._entries:
|
|
236
|
+
entry.bonus_id = 0
|
|
237
|
+
entry.picked = False
|
|
238
|
+
entry.time_left = 0.0
|
|
239
|
+
entry.time_max = 0.0
|
|
240
|
+
entry.amount = 0
|
|
241
|
+
|
|
242
|
+
def iter_active(self) -> list[BonusEntry]:
|
|
243
|
+
return [entry for entry in self._entries if entry.bonus_id != 0]
|
|
244
|
+
|
|
245
|
+
def _alloc_slot(self) -> BonusEntry | None:
|
|
246
|
+
for entry in self._entries:
|
|
247
|
+
if entry.bonus_id == 0:
|
|
248
|
+
return entry
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def _clear_entry(self, entry: BonusEntry) -> None:
|
|
252
|
+
entry.bonus_id = 0
|
|
253
|
+
entry.picked = False
|
|
254
|
+
entry.time_left = 0.0
|
|
255
|
+
entry.time_max = 0.0
|
|
256
|
+
entry.amount = 0
|
|
257
|
+
|
|
258
|
+
def spawn_at(
|
|
259
|
+
self,
|
|
260
|
+
pos_x: float,
|
|
261
|
+
pos_y: float,
|
|
262
|
+
bonus_id: int | BonusId,
|
|
263
|
+
duration_override: int = -1,
|
|
264
|
+
*,
|
|
265
|
+
world_width: float = 1024.0,
|
|
266
|
+
world_height: float = 1024.0,
|
|
267
|
+
) -> BonusEntry | None:
|
|
268
|
+
if int(bonus_id) == 0:
|
|
269
|
+
return None
|
|
270
|
+
entry = self._alloc_slot()
|
|
271
|
+
if entry is None:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
x = _clamp(float(pos_x), BONUS_SPAWN_MARGIN, float(world_width) - BONUS_SPAWN_MARGIN)
|
|
275
|
+
y = _clamp(float(pos_y), BONUS_SPAWN_MARGIN, float(world_height) - BONUS_SPAWN_MARGIN)
|
|
276
|
+
|
|
277
|
+
entry.bonus_id = int(bonus_id)
|
|
278
|
+
entry.picked = False
|
|
279
|
+
entry.pos_x = x
|
|
280
|
+
entry.pos_y = y
|
|
281
|
+
entry.time_left = BONUS_TIME_MAX
|
|
282
|
+
entry.time_max = BONUS_TIME_MAX
|
|
283
|
+
|
|
284
|
+
amount = duration_override
|
|
285
|
+
if amount == -1:
|
|
286
|
+
meta = BONUS_BY_ID.get(int(bonus_id))
|
|
287
|
+
amount = int(meta.default_amount or 0) if meta is not None else 0
|
|
288
|
+
entry.amount = int(amount)
|
|
289
|
+
return entry
|
|
290
|
+
|
|
291
|
+
def spawn_at_pos(
|
|
292
|
+
self,
|
|
293
|
+
pos_x: float,
|
|
294
|
+
pos_y: float,
|
|
295
|
+
*,
|
|
296
|
+
state: "GameplayState",
|
|
297
|
+
players: list["PlayerState"],
|
|
298
|
+
world_width: float = 1024.0,
|
|
299
|
+
world_height: float = 1024.0,
|
|
300
|
+
) -> BonusEntry | None:
|
|
301
|
+
if (
|
|
302
|
+
pos_x < BONUS_SPAWN_MARGIN
|
|
303
|
+
or pos_y < BONUS_SPAWN_MARGIN
|
|
304
|
+
or pos_x > world_width - BONUS_SPAWN_MARGIN
|
|
305
|
+
or pos_y > world_height - BONUS_SPAWN_MARGIN
|
|
306
|
+
):
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
min_dist_sq = BONUS_SPAWN_MIN_DISTANCE * BONUS_SPAWN_MIN_DISTANCE
|
|
310
|
+
for entry in self._entries:
|
|
311
|
+
if entry.bonus_id == 0:
|
|
312
|
+
continue
|
|
313
|
+
if _distance_sq(pos_x, pos_y, entry.pos_x, entry.pos_y) < min_dist_sq:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
entry = self._alloc_slot()
|
|
317
|
+
if entry is None:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
bonus_id = bonus_pick_random_type(self, state, players)
|
|
321
|
+
entry.bonus_id = int(bonus_id)
|
|
322
|
+
entry.picked = False
|
|
323
|
+
entry.pos_x = float(pos_x)
|
|
324
|
+
entry.pos_y = float(pos_y)
|
|
325
|
+
entry.time_left = BONUS_TIME_MAX
|
|
326
|
+
entry.time_max = BONUS_TIME_MAX
|
|
327
|
+
|
|
328
|
+
rng = state.rng
|
|
329
|
+
if entry.bonus_id == int(BonusId.WEAPON):
|
|
330
|
+
entry.amount = weapon_pick_random_available(state)
|
|
331
|
+
elif entry.bonus_id == int(BonusId.POINTS):
|
|
332
|
+
entry.amount = 1000 if (rng.rand() & 7) < 3 else 500
|
|
333
|
+
else:
|
|
334
|
+
meta = BONUS_BY_ID.get(entry.bonus_id)
|
|
335
|
+
entry.amount = int(meta.default_amount or 0) if meta is not None else 0
|
|
336
|
+
return entry
|
|
337
|
+
|
|
338
|
+
def try_spawn_on_kill(
|
|
339
|
+
self,
|
|
340
|
+
pos_x: float,
|
|
341
|
+
pos_y: float,
|
|
342
|
+
*,
|
|
343
|
+
state: "GameplayState",
|
|
344
|
+
players: list["PlayerState"],
|
|
345
|
+
world_width: float = 1024.0,
|
|
346
|
+
world_height: float = 1024.0,
|
|
347
|
+
) -> BonusEntry | None:
|
|
348
|
+
game_mode = int(state.game_mode)
|
|
349
|
+
if game_mode == int(GameMode.TYPO):
|
|
350
|
+
return None
|
|
351
|
+
if state.demo_mode_active:
|
|
352
|
+
return None
|
|
353
|
+
if game_mode == int(GameMode.RUSH):
|
|
354
|
+
return None
|
|
355
|
+
if game_mode == int(GameMode.TUTORIAL):
|
|
356
|
+
return None
|
|
357
|
+
if state.bonus_spawn_guard:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
rng = state.rng
|
|
361
|
+
if rng.rand() % 9 != 1:
|
|
362
|
+
if not any(perk_active(player, PerkId.BONUS_MAGNET) for player in players):
|
|
363
|
+
return None
|
|
364
|
+
if rng.rand() % 10 != 2:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
entry = self.spawn_at_pos(
|
|
368
|
+
pos_x,
|
|
369
|
+
pos_y,
|
|
370
|
+
state=state,
|
|
371
|
+
players=players,
|
|
372
|
+
world_width=world_width,
|
|
373
|
+
world_height=world_height,
|
|
374
|
+
)
|
|
375
|
+
if entry is None:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
if entry.bonus_id == int(BonusId.WEAPON):
|
|
379
|
+
near_sq = BONUS_WEAPON_NEAR_RADIUS * BONUS_WEAPON_NEAR_RADIUS
|
|
380
|
+
for player in players:
|
|
381
|
+
if _distance_sq(pos_x, pos_y, player.pos_x, player.pos_y) < near_sq:
|
|
382
|
+
entry.bonus_id = int(BonusId.POINTS)
|
|
383
|
+
entry.amount = 100
|
|
384
|
+
break
|
|
385
|
+
|
|
386
|
+
if entry.bonus_id != int(BonusId.POINTS):
|
|
387
|
+
matches = sum(1 for bonus in self._entries if bonus.bonus_id == entry.bonus_id)
|
|
388
|
+
if matches > 1:
|
|
389
|
+
self._clear_entry(entry)
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
if entry.bonus_id == int(BonusId.WEAPON):
|
|
393
|
+
for player in players:
|
|
394
|
+
if entry.amount == player.weapon_id:
|
|
395
|
+
self._clear_entry(entry)
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
return entry
|
|
399
|
+
|
|
400
|
+
def update(
|
|
401
|
+
self,
|
|
402
|
+
dt: float,
|
|
403
|
+
*,
|
|
404
|
+
state: "GameplayState",
|
|
405
|
+
players: list["PlayerState"],
|
|
406
|
+
creatures: list[Damageable] | None = None,
|
|
407
|
+
apply_creature_damage: CreatureDamageApplier | None = None,
|
|
408
|
+
detail_preset: int = 5,
|
|
409
|
+
) -> list[BonusPickupEvent]:
|
|
410
|
+
if dt <= 0.0:
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
pickups: list[BonusPickupEvent] = []
|
|
414
|
+
for entry in self._entries:
|
|
415
|
+
if entry.bonus_id == 0:
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
decay = dt * (BONUS_PICKUP_DECAY_RATE if entry.picked else 1.0)
|
|
419
|
+
entry.time_left -= decay
|
|
420
|
+
if entry.time_left < 0.0:
|
|
421
|
+
self._clear_entry(entry)
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
if entry.picked:
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
for player in players:
|
|
428
|
+
if _distance_sq(entry.pos_x, entry.pos_y, player.pos_x, player.pos_y) < BONUS_PICKUP_RADIUS * BONUS_PICKUP_RADIUS:
|
|
429
|
+
bonus_apply(
|
|
430
|
+
state,
|
|
431
|
+
player,
|
|
432
|
+
BonusId(entry.bonus_id),
|
|
433
|
+
amount=entry.amount,
|
|
434
|
+
origin=player,
|
|
435
|
+
creatures=creatures,
|
|
436
|
+
players=players,
|
|
437
|
+
apply_creature_damage=apply_creature_damage,
|
|
438
|
+
detail_preset=int(detail_preset),
|
|
439
|
+
)
|
|
440
|
+
entry.picked = True
|
|
441
|
+
entry.time_left = BONUS_PICKUP_LINGER
|
|
442
|
+
pickups.append(
|
|
443
|
+
BonusPickupEvent(
|
|
444
|
+
player_index=player.index,
|
|
445
|
+
bonus_id=entry.bonus_id,
|
|
446
|
+
amount=entry.amount,
|
|
447
|
+
pos_x=entry.pos_x,
|
|
448
|
+
pos_y=entry.pos_y,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
break
|
|
452
|
+
|
|
453
|
+
return pickups
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def bonus_find_aim_hover_entry(player: PlayerState, bonus_pool: BonusPool) -> tuple[int, BonusEntry] | None:
|
|
457
|
+
"""Return the first bonus entry within the aim hover radius, matching the exe scan order."""
|
|
458
|
+
|
|
459
|
+
aim_x = float(getattr(player, "aim_x", player.pos_x))
|
|
460
|
+
aim_y = float(getattr(player, "aim_y", player.pos_y))
|
|
461
|
+
radius_sq = BONUS_AIM_HOVER_RADIUS * BONUS_AIM_HOVER_RADIUS
|
|
462
|
+
for idx, entry in enumerate(bonus_pool.entries):
|
|
463
|
+
if entry.bonus_id == 0 or entry.picked:
|
|
464
|
+
continue
|
|
465
|
+
if _distance_sq(aim_x, aim_y, entry.pos_x, entry.pos_y) < radius_sq:
|
|
466
|
+
return idx, entry
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@dataclass(slots=True)
|
|
471
|
+
class GameplayState:
|
|
472
|
+
rng: Crand = field(default_factory=lambda: Crand(0xBEEF))
|
|
473
|
+
effects: EffectPool = field(default_factory=EffectPool)
|
|
474
|
+
particles: ParticlePool = field(init=False)
|
|
475
|
+
sprite_effects: SpriteEffectPool = field(init=False)
|
|
476
|
+
projectiles: ProjectilePool = field(default_factory=ProjectilePool)
|
|
477
|
+
secondary_projectiles: SecondaryProjectilePool = field(default_factory=SecondaryProjectilePool)
|
|
478
|
+
bonuses: BonusTimers = field(default_factory=BonusTimers)
|
|
479
|
+
perk_intervals: PerkEffectIntervals = field(default_factory=PerkEffectIntervals)
|
|
480
|
+
lean_mean_exp_timer: float = 0.25
|
|
481
|
+
jinxed_timer: float = 0.0
|
|
482
|
+
plaguebearer_infection_count: int = 0
|
|
483
|
+
perk_selection: PerkSelectionState = field(default_factory=PerkSelectionState)
|
|
484
|
+
sfx_queue: list[str] = field(default_factory=list)
|
|
485
|
+
game_mode: int = int(GameMode.SURVIVAL)
|
|
486
|
+
demo_mode_active: bool = False
|
|
487
|
+
hardcore: bool = False
|
|
488
|
+
status: GameStatus | None = None
|
|
489
|
+
quest_stage_major: int = 0
|
|
490
|
+
quest_stage_minor: int = 0
|
|
491
|
+
perk_available: list[bool] = field(default_factory=lambda: [False] * PERK_COUNT_SIZE)
|
|
492
|
+
_perk_available_unlock_index: int = -1
|
|
493
|
+
weapon_available: list[bool] = field(default_factory=lambda: [False] * WEAPON_COUNT_SIZE)
|
|
494
|
+
_weapon_available_game_mode: int = -1
|
|
495
|
+
_weapon_available_unlock_index: int = -1
|
|
496
|
+
friendly_fire_enabled: bool = False
|
|
497
|
+
bonus_spawn_guard: bool = False
|
|
498
|
+
bonus_hud: BonusHudState = field(default_factory=BonusHudState)
|
|
499
|
+
bonus_pool: BonusPool = field(default_factory=BonusPool)
|
|
500
|
+
shock_chain_links_left: int = 0
|
|
501
|
+
shock_chain_projectile_id: int = -1
|
|
502
|
+
camera_shake_offset_x: float = 0.0
|
|
503
|
+
camera_shake_offset_y: float = 0.0
|
|
504
|
+
camera_shake_timer: float = 0.0
|
|
505
|
+
camera_shake_pulses: int = 0
|
|
506
|
+
shots_fired: list[int] = field(default_factory=lambda: [0] * 4)
|
|
507
|
+
shots_hit: list[int] = field(default_factory=lambda: [0] * 4)
|
|
508
|
+
weapon_shots_fired: list[list[int]] = field(default_factory=lambda: [[0] * WEAPON_COUNT_SIZE for _ in range(4)])
|
|
509
|
+
|
|
510
|
+
def __post_init__(self) -> None:
|
|
511
|
+
rand = self.rng.rand
|
|
512
|
+
self.particles = ParticlePool(rand=rand)
|
|
513
|
+
self.sprite_effects = SpriteEffectPool(rand=rand)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def perk_count_get(player: PlayerState, perk_id: PerkId) -> int:
|
|
517
|
+
idx = int(perk_id)
|
|
518
|
+
if idx < 0:
|
|
519
|
+
return 0
|
|
520
|
+
if idx >= len(player.perk_counts):
|
|
521
|
+
return 0
|
|
522
|
+
return int(player.perk_counts[idx])
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def perk_active(player: PlayerState, perk_id: PerkId) -> bool:
|
|
526
|
+
return perk_count_get(player, perk_id) > 0
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _creature_find_in_radius(creatures: list[_CreatureForPerks], *, pos_x: float, pos_y: float, radius: float, start_index: int) -> int:
|
|
530
|
+
"""Port of `creature_find_in_radius` (0x004206a0)."""
|
|
531
|
+
|
|
532
|
+
start_index = max(0, int(start_index))
|
|
533
|
+
max_index = min(len(creatures), 0x180)
|
|
534
|
+
if start_index >= max_index:
|
|
535
|
+
return -1
|
|
536
|
+
|
|
537
|
+
pos_x = float(pos_x)
|
|
538
|
+
pos_y = float(pos_y)
|
|
539
|
+
radius = float(radius)
|
|
540
|
+
|
|
541
|
+
for idx in range(start_index, max_index):
|
|
542
|
+
creature = creatures[idx]
|
|
543
|
+
if not creature.active:
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
dist = math.hypot(float(creature.x) - pos_x, float(creature.y) - pos_y) - radius
|
|
547
|
+
threshold = float(creature.size) * 0.142857149 + 3.0
|
|
548
|
+
if threshold < dist:
|
|
549
|
+
continue
|
|
550
|
+
if float(creature.hitbox_size) < 5.0:
|
|
551
|
+
continue
|
|
552
|
+
return idx
|
|
553
|
+
return -1
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def perks_update_effects(
|
|
557
|
+
state: GameplayState,
|
|
558
|
+
players: list[PlayerState],
|
|
559
|
+
dt: float,
|
|
560
|
+
*,
|
|
561
|
+
creatures: list[_CreatureForPerks] | None = None,
|
|
562
|
+
fx_queue: FxQueue | None = None,
|
|
563
|
+
) -> None:
|
|
564
|
+
"""Port subset of `perks_update_effects` (0x00406b40)."""
|
|
565
|
+
|
|
566
|
+
dt = float(dt)
|
|
567
|
+
if dt <= 0.0:
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
if players and perk_active(players[0], PerkId.REGENERATION) and (state.rng.rand() & 1):
|
|
571
|
+
for player in players:
|
|
572
|
+
if not (0.0 < float(player.health) < 100.0):
|
|
573
|
+
continue
|
|
574
|
+
player.health = float(player.health) + dt
|
|
575
|
+
if player.health > 100.0:
|
|
576
|
+
player.health = 100.0
|
|
577
|
+
|
|
578
|
+
state.lean_mean_exp_timer -= dt
|
|
579
|
+
if state.lean_mean_exp_timer < 0.0:
|
|
580
|
+
state.lean_mean_exp_timer = 0.25
|
|
581
|
+
for player in players:
|
|
582
|
+
perk_count = perk_count_get(player, PerkId.LEAN_MEAN_EXP_MACHINE)
|
|
583
|
+
if perk_count > 0:
|
|
584
|
+
player.experience += perk_count * 10
|
|
585
|
+
|
|
586
|
+
target = -1
|
|
587
|
+
if players and creatures is not None and (
|
|
588
|
+
perk_active(players[0], PerkId.PYROKINETIC) or perk_active(players[0], PerkId.EVIL_EYES)
|
|
589
|
+
):
|
|
590
|
+
target = _creature_find_in_radius(
|
|
591
|
+
creatures,
|
|
592
|
+
pos_x=players[0].aim_x,
|
|
593
|
+
pos_y=players[0].aim_y,
|
|
594
|
+
radius=12.0,
|
|
595
|
+
start_index=0,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
if players:
|
|
599
|
+
player0 = players[0]
|
|
600
|
+
player0.evil_eyes_target_creature = target if perk_active(player0, PerkId.EVIL_EYES) else -1
|
|
601
|
+
|
|
602
|
+
if players and creatures is not None and perk_active(players[0], PerkId.PYROKINETIC) and target != -1:
|
|
603
|
+
creature = creatures[target]
|
|
604
|
+
creature.collision_timer = float(creature.collision_timer) - dt
|
|
605
|
+
if creature.collision_timer < 0.0:
|
|
606
|
+
creature.collision_timer = 0.5
|
|
607
|
+
pos_x = float(creature.x)
|
|
608
|
+
pos_y = float(creature.y)
|
|
609
|
+
for intensity in (0.8, 0.6, 0.4, 0.3, 0.2):
|
|
610
|
+
angle = float(int(state.rng.rand()) % 0x274) * 0.01
|
|
611
|
+
state.particles.spawn_particle(pos_x=pos_x, pos_y=pos_y, angle=angle, intensity=float(intensity))
|
|
612
|
+
if fx_queue is not None:
|
|
613
|
+
fx_queue.add_random(pos_x=pos_x, pos_y=pos_y, rand=state.rng.rand)
|
|
614
|
+
|
|
615
|
+
if state.jinxed_timer >= 0.0:
|
|
616
|
+
state.jinxed_timer -= dt
|
|
617
|
+
|
|
618
|
+
if state.jinxed_timer < 0.0 and players and perk_active(players[0], PerkId.JINXED):
|
|
619
|
+
player = players[0]
|
|
620
|
+
if int(state.rng.rand()) % 10 == 3:
|
|
621
|
+
player.health = float(player.health) - 5.0
|
|
622
|
+
if fx_queue is not None:
|
|
623
|
+
fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
|
|
624
|
+
fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
|
|
625
|
+
|
|
626
|
+
state.jinxed_timer = float(int(state.rng.rand()) % 0x14) * 0.1 + float(state.jinxed_timer) + 2.0
|
|
627
|
+
|
|
628
|
+
if float(state.bonuses.freeze) <= 0.0 and creatures is not None:
|
|
629
|
+
pool_mod = min(0x17F, len(creatures))
|
|
630
|
+
if pool_mod <= 0:
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
idx = int(state.rng.rand()) % pool_mod
|
|
634
|
+
attempts = 0
|
|
635
|
+
while attempts < 10 and not creatures[idx].active:
|
|
636
|
+
idx = int(state.rng.rand()) % pool_mod
|
|
637
|
+
attempts += 1
|
|
638
|
+
if not creatures[idx].active:
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
creature = creatures[idx]
|
|
642
|
+
creature.hp = -1.0
|
|
643
|
+
creature.hitbox_size = float(creature.hitbox_size) - dt * 20.0
|
|
644
|
+
player.experience = int(float(player.experience) + float(creature.reward_value))
|
|
645
|
+
state.sfx_queue.append("sfx_trooper_inpain_01")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def award_experience(state: GameplayState, player: PlayerState, amount: int) -> int:
|
|
649
|
+
"""Grant XP while honoring active bonus multipliers."""
|
|
650
|
+
|
|
651
|
+
xp = int(amount)
|
|
652
|
+
if xp <= 0:
|
|
653
|
+
return 0
|
|
654
|
+
if state.bonuses.double_experience > 0.0:
|
|
655
|
+
xp *= 2
|
|
656
|
+
player.experience += xp
|
|
657
|
+
return xp
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def survival_level_threshold(level: int) -> int:
|
|
661
|
+
"""Return the XP threshold for advancing past the given level."""
|
|
662
|
+
|
|
663
|
+
level = max(1, int(level))
|
|
664
|
+
return int(1000.0 + (math.pow(float(level), 1.8) * 1000.0))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def survival_check_level_up(player: PlayerState, perk_state: PerkSelectionState) -> int:
|
|
668
|
+
"""Advance survival levels if XP exceeds thresholds, returning number of level-ups."""
|
|
669
|
+
|
|
670
|
+
advanced = 0
|
|
671
|
+
while player.experience > survival_level_threshold(player.level):
|
|
672
|
+
player.level += 1
|
|
673
|
+
perk_state.pending_count += 1
|
|
674
|
+
perk_state.choices_dirty = True
|
|
675
|
+
advanced += 1
|
|
676
|
+
return advanced
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def perk_choice_count(player: PlayerState) -> int:
|
|
680
|
+
if perk_active(player, PerkId.PERK_MASTER):
|
|
681
|
+
return 7
|
|
682
|
+
if perk_active(player, PerkId.PERK_EXPERT):
|
|
683
|
+
return 6
|
|
684
|
+
return 5
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
_PERK_BASE_AVAILABLE_MAX_ID = int(PerkId.BONUS_MAGNET) # perks_rebuild_available @ 0x0042fc30
|
|
688
|
+
_PERK_ALWAYS_AVAILABLE: tuple[PerkId, ...] = (
|
|
689
|
+
PerkId.MAN_BOMB,
|
|
690
|
+
PerkId.LIVING_FORTRESS,
|
|
691
|
+
PerkId.FIRE_CAUGH,
|
|
692
|
+
PerkId.TOUGH_RELOADER,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
_DEATH_CLOCK_BLOCKED: frozenset[PerkId] = frozenset(
|
|
696
|
+
(
|
|
697
|
+
PerkId.JINXED,
|
|
698
|
+
PerkId.BREATHING_ROOM,
|
|
699
|
+
PerkId.GRIM_DEAL,
|
|
700
|
+
PerkId.HIGHLANDER,
|
|
701
|
+
PerkId.FATAL_LOTTERY,
|
|
702
|
+
PerkId.AMMUNITION_WITHIN,
|
|
703
|
+
PerkId.INFERNAL_CONTRACT,
|
|
704
|
+
PerkId.REGENERATION,
|
|
705
|
+
PerkId.GREATER_REGENERATION,
|
|
706
|
+
PerkId.THICK_SKINNED,
|
|
707
|
+
PerkId.BANDAGE,
|
|
708
|
+
)
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
_PERK_RARITY_GATE: frozenset[PerkId] = frozenset(
|
|
712
|
+
(
|
|
713
|
+
PerkId.JINXED,
|
|
714
|
+
PerkId.AMMUNITION_WITHIN,
|
|
715
|
+
PerkId.ANXIOUS_LOADER,
|
|
716
|
+
PerkId.MONSTER_VISION,
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def perks_rebuild_available(state: GameplayState) -> None:
|
|
722
|
+
"""Rebuild quest unlock driven `perk_meta_table[perk_id].available` flags.
|
|
723
|
+
|
|
724
|
+
Port of `perks_rebuild_available` (0x0042fc30).
|
|
725
|
+
"""
|
|
726
|
+
|
|
727
|
+
unlock_index = 0
|
|
728
|
+
if state.status is not None:
|
|
729
|
+
try:
|
|
730
|
+
unlock_index = int(state.status.quest_unlock_index)
|
|
731
|
+
except Exception:
|
|
732
|
+
unlock_index = 0
|
|
733
|
+
|
|
734
|
+
if int(state._perk_available_unlock_index) == unlock_index:
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
available = state.perk_available
|
|
738
|
+
for idx in range(len(available)):
|
|
739
|
+
available[idx] = False
|
|
740
|
+
|
|
741
|
+
for perk_id in range(1, _PERK_BASE_AVAILABLE_MAX_ID + 1):
|
|
742
|
+
if 0 <= perk_id < len(available):
|
|
743
|
+
available[perk_id] = True
|
|
744
|
+
|
|
745
|
+
for perk_id in _PERK_ALWAYS_AVAILABLE:
|
|
746
|
+
idx = int(perk_id)
|
|
747
|
+
if 0 <= idx < len(available):
|
|
748
|
+
available[idx] = True
|
|
749
|
+
|
|
750
|
+
if unlock_index > 0:
|
|
751
|
+
try:
|
|
752
|
+
from .quests import all_quests
|
|
753
|
+
|
|
754
|
+
quests = all_quests()
|
|
755
|
+
except Exception:
|
|
756
|
+
quests = []
|
|
757
|
+
|
|
758
|
+
for quest in quests[:unlock_index]:
|
|
759
|
+
perk_id = int(getattr(quest, "unlock_perk_id", 0) or 0)
|
|
760
|
+
if 0 < perk_id < len(available):
|
|
761
|
+
available[perk_id] = True
|
|
762
|
+
|
|
763
|
+
available[int(PerkId.ANTIPERK)] = False
|
|
764
|
+
state._perk_available_unlock_index = unlock_index
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def perk_can_offer(state: GameplayState, player: PlayerState, perk_id: PerkId, *, game_mode: int, player_count: int) -> bool:
|
|
768
|
+
"""Return whether `perk_id` is eligible for selection.
|
|
769
|
+
|
|
770
|
+
Used by `perk_select_random` and modeled after `perk_can_offer` (0x0042fb10).
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
if perk_id == PerkId.ANTIPERK:
|
|
774
|
+
return False
|
|
775
|
+
|
|
776
|
+
# Hardcore quest 2-10 blocks poison-related perks.
|
|
777
|
+
if (
|
|
778
|
+
int(game_mode) == int(GameMode.QUESTS)
|
|
779
|
+
and state.hardcore
|
|
780
|
+
and int(state.quest_stage_major) == 2
|
|
781
|
+
and int(state.quest_stage_minor) == 10
|
|
782
|
+
and perk_id in (PerkId.POISON_BULLETS, PerkId.VEINS_OF_POISON, PerkId.PLAGUEBEARER)
|
|
783
|
+
):
|
|
784
|
+
return False
|
|
785
|
+
|
|
786
|
+
meta = PERK_BY_ID.get(int(perk_id))
|
|
787
|
+
if meta is None:
|
|
788
|
+
return False
|
|
789
|
+
|
|
790
|
+
flags = meta.flags or PerkFlags(0)
|
|
791
|
+
if (flags & PerkFlags.MODE_3_ONLY) and int(game_mode) != int(GameMode.QUESTS):
|
|
792
|
+
return False
|
|
793
|
+
if (flags & PerkFlags.TWO_PLAYER_ONLY) and int(player_count) != 2:
|
|
794
|
+
return False
|
|
795
|
+
|
|
796
|
+
if meta.prereq and any(perk_count_get(player, req) <= 0 for req in meta.prereq):
|
|
797
|
+
return False
|
|
798
|
+
|
|
799
|
+
return True
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def perk_select_random(state: GameplayState, player: PlayerState, *, game_mode: int, player_count: int) -> PerkId:
|
|
803
|
+
"""Randomly select an eligible perk id.
|
|
804
|
+
|
|
805
|
+
Port of `perk_select_random` (0x0042fbd0).
|
|
806
|
+
"""
|
|
807
|
+
|
|
808
|
+
perks_rebuild_available(state)
|
|
809
|
+
|
|
810
|
+
for _ in range(1000):
|
|
811
|
+
perk_id = PerkId(int(state.rng.rand()) % PERK_ID_MAX + 1)
|
|
812
|
+
if not (0 <= int(perk_id) < len(state.perk_available)):
|
|
813
|
+
continue
|
|
814
|
+
if not state.perk_available[int(perk_id)]:
|
|
815
|
+
continue
|
|
816
|
+
if perk_can_offer(state, player, perk_id, game_mode=game_mode, player_count=player_count):
|
|
817
|
+
return perk_id
|
|
818
|
+
|
|
819
|
+
return PerkId.INSTANT_WINNER
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def perk_generate_choices(
|
|
823
|
+
state: GameplayState,
|
|
824
|
+
player: PlayerState,
|
|
825
|
+
*,
|
|
826
|
+
game_mode: int,
|
|
827
|
+
player_count: int,
|
|
828
|
+
count: int | None = None,
|
|
829
|
+
) -> list[PerkId]:
|
|
830
|
+
"""Generate a unique list of perk choices for the current selection."""
|
|
831
|
+
|
|
832
|
+
if count is None:
|
|
833
|
+
count = perk_choice_count(player)
|
|
834
|
+
|
|
835
|
+
# `perks_generate_choices` always fills a fixed array of 7 entries, even if the UI
|
|
836
|
+
# only shows 5/6 (Perk Expert/Master). Preserve RNG consumption by generating the
|
|
837
|
+
# full list, then slicing.
|
|
838
|
+
choices: list[PerkId] = [PerkId.ANTIPERK] * 7
|
|
839
|
+
choice_index = 0
|
|
840
|
+
|
|
841
|
+
# Quest 1-7 special-case: force Monster Vision as the first choice if not owned.
|
|
842
|
+
if (
|
|
843
|
+
int(state.quest_stage_major) == 1
|
|
844
|
+
and int(state.quest_stage_minor) == 7
|
|
845
|
+
and perk_count_get(player, PerkId.MONSTER_VISION) == 0
|
|
846
|
+
):
|
|
847
|
+
choices[0] = PerkId.MONSTER_VISION
|
|
848
|
+
choice_index = 1
|
|
849
|
+
|
|
850
|
+
while choice_index < 7:
|
|
851
|
+
attempts = 0
|
|
852
|
+
while True:
|
|
853
|
+
attempts += 1
|
|
854
|
+
perk_id = perk_select_random(state, player, game_mode=game_mode, player_count=player_count)
|
|
855
|
+
|
|
856
|
+
# Pyromaniac can only be offered if the current weapon is Flamethrower.
|
|
857
|
+
if perk_id == PerkId.PYROMANIAC and int(player.weapon_id) != int(WeaponId.FLAMETHROWER):
|
|
858
|
+
continue
|
|
859
|
+
|
|
860
|
+
if perk_count_get(player, PerkId.DEATH_CLOCK) > 0 and perk_id in _DEATH_CLOCK_BLOCKED:
|
|
861
|
+
continue
|
|
862
|
+
|
|
863
|
+
# Global rarity gate: certain perks have a 25% chance to be rejected.
|
|
864
|
+
if perk_id in _PERK_RARITY_GATE and (int(state.rng.rand()) & 3) == 1:
|
|
865
|
+
continue
|
|
866
|
+
|
|
867
|
+
meta = PERK_BY_ID.get(int(perk_id))
|
|
868
|
+
flags = meta.flags if meta is not None and meta.flags is not None else PerkFlags(0)
|
|
869
|
+
stackable = (flags & PerkFlags.STACKABLE) != 0
|
|
870
|
+
|
|
871
|
+
if attempts > 10_000 and stackable:
|
|
872
|
+
break
|
|
873
|
+
|
|
874
|
+
if perk_id in choices[:choice_index]:
|
|
875
|
+
continue
|
|
876
|
+
|
|
877
|
+
if stackable or perk_count_get(player, perk_id) < 1 or attempts > 29_999:
|
|
878
|
+
break
|
|
879
|
+
|
|
880
|
+
choices[choice_index] = perk_id
|
|
881
|
+
choice_index += 1
|
|
882
|
+
|
|
883
|
+
if int(game_mode) == int(GameMode.TUTORIAL):
|
|
884
|
+
choices = [
|
|
885
|
+
PerkId.SHARPSHOOTER,
|
|
886
|
+
PerkId.LONG_DISTANCE_RUNNER,
|
|
887
|
+
PerkId.EVIL_EYES,
|
|
888
|
+
PerkId.RADIOACTIVE,
|
|
889
|
+
PerkId.FASTSHOT,
|
|
890
|
+
PerkId.FASTSHOT,
|
|
891
|
+
PerkId.FASTSHOT,
|
|
892
|
+
]
|
|
893
|
+
|
|
894
|
+
return choices[: int(count)]
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _increment_perk_count(player: PlayerState, perk_id: PerkId, *, amount: int = 1) -> None:
|
|
898
|
+
idx = int(perk_id)
|
|
899
|
+
if 0 <= idx < len(player.perk_counts):
|
|
900
|
+
player.perk_counts[idx] += int(amount)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def perk_apply(
|
|
904
|
+
state: GameplayState,
|
|
905
|
+
players: list[PlayerState],
|
|
906
|
+
perk_id: PerkId,
|
|
907
|
+
*,
|
|
908
|
+
perk_state: PerkSelectionState | None = None,
|
|
909
|
+
dt: float | None = None,
|
|
910
|
+
creatures: list[_CreatureForPerks] | None = None,
|
|
911
|
+
) -> None:
|
|
912
|
+
"""Apply immediate perk effects and increment the perk counter."""
|
|
913
|
+
|
|
914
|
+
if not players:
|
|
915
|
+
return
|
|
916
|
+
owner = players[0]
|
|
917
|
+
try:
|
|
918
|
+
_increment_perk_count(owner, perk_id)
|
|
919
|
+
|
|
920
|
+
if perk_id == PerkId.INSTANT_WINNER:
|
|
921
|
+
owner.experience += 2500
|
|
922
|
+
return
|
|
923
|
+
|
|
924
|
+
if perk_id == PerkId.FATAL_LOTTERY:
|
|
925
|
+
if state.rng.rand() & 1:
|
|
926
|
+
owner.health = -1.0
|
|
927
|
+
else:
|
|
928
|
+
owner.experience += 10000
|
|
929
|
+
return
|
|
930
|
+
|
|
931
|
+
if perk_id == PerkId.RANDOM_WEAPON:
|
|
932
|
+
current = int(owner.weapon_id)
|
|
933
|
+
weapon_id = int(current)
|
|
934
|
+
for _ in range(100):
|
|
935
|
+
candidate = int(weapon_pick_random_available(state))
|
|
936
|
+
if candidate != 0 and candidate != current:
|
|
937
|
+
weapon_id = candidate
|
|
938
|
+
break
|
|
939
|
+
weapon_assign_player(owner, weapon_id, state=state)
|
|
940
|
+
return
|
|
941
|
+
|
|
942
|
+
if perk_id == PerkId.LIFELINE_50_50:
|
|
943
|
+
if creatures is None:
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
kill_toggle = False
|
|
947
|
+
for creature in creatures:
|
|
948
|
+
if (
|
|
949
|
+
kill_toggle
|
|
950
|
+
and creature.active
|
|
951
|
+
and float(creature.hp) <= 500.0
|
|
952
|
+
and (int(creature.flags) & 0x04) == 0
|
|
953
|
+
):
|
|
954
|
+
creature.active = False
|
|
955
|
+
state.effects.spawn_burst(
|
|
956
|
+
pos_x=float(creature.x),
|
|
957
|
+
pos_y=float(creature.y),
|
|
958
|
+
count=4,
|
|
959
|
+
rand=state.rng.rand,
|
|
960
|
+
detail_preset=5,
|
|
961
|
+
)
|
|
962
|
+
kill_toggle = not kill_toggle
|
|
963
|
+
return
|
|
964
|
+
|
|
965
|
+
if perk_id == PerkId.THICK_SKINNED:
|
|
966
|
+
for player in players:
|
|
967
|
+
if player.health > 0.0:
|
|
968
|
+
player.health = max(1.0, player.health * (2.0 / 3.0))
|
|
969
|
+
return
|
|
970
|
+
|
|
971
|
+
if perk_id == PerkId.BREATHING_ROOM:
|
|
972
|
+
for player in players:
|
|
973
|
+
player.health -= player.health * (2.0 / 3.0)
|
|
974
|
+
|
|
975
|
+
frame_dt = float(dt) if dt is not None else 0.0
|
|
976
|
+
if creatures is not None:
|
|
977
|
+
for creature in creatures:
|
|
978
|
+
if creature.active:
|
|
979
|
+
creature.hitbox_size = float(creature.hitbox_size) - frame_dt
|
|
980
|
+
|
|
981
|
+
state.bonus_spawn_guard = False
|
|
982
|
+
return
|
|
983
|
+
|
|
984
|
+
if perk_id == PerkId.INFERNAL_CONTRACT:
|
|
985
|
+
owner.level += 3
|
|
986
|
+
if perk_state is not None:
|
|
987
|
+
perk_state.pending_count += 3
|
|
988
|
+
perk_state.choices_dirty = True
|
|
989
|
+
for player in players:
|
|
990
|
+
if player.health > 0.0:
|
|
991
|
+
player.health = 0.1
|
|
992
|
+
return
|
|
993
|
+
|
|
994
|
+
if perk_id == PerkId.GRIM_DEAL:
|
|
995
|
+
owner.health = -1.0
|
|
996
|
+
owner.experience += int(owner.experience * 0.18)
|
|
997
|
+
return
|
|
998
|
+
|
|
999
|
+
if perk_id == PerkId.AMMO_MANIAC:
|
|
1000
|
+
if len(players) > 1:
|
|
1001
|
+
for player in players[1:]:
|
|
1002
|
+
player.perk_counts[:] = owner.perk_counts
|
|
1003
|
+
for player in players:
|
|
1004
|
+
weapon_assign_player(player, int(player.weapon_id), state=state)
|
|
1005
|
+
return
|
|
1006
|
+
|
|
1007
|
+
if perk_id == PerkId.DEATH_CLOCK:
|
|
1008
|
+
_increment_perk_count(owner, PerkId.REGENERATION, amount=-perk_count_get(owner, PerkId.REGENERATION))
|
|
1009
|
+
_increment_perk_count(owner, PerkId.GREATER_REGENERATION, amount=-perk_count_get(owner, PerkId.GREATER_REGENERATION))
|
|
1010
|
+
for player in players:
|
|
1011
|
+
if player.health > 0.0:
|
|
1012
|
+
player.health = 100.0
|
|
1013
|
+
return
|
|
1014
|
+
|
|
1015
|
+
if perk_id == PerkId.BANDAGE:
|
|
1016
|
+
for player in players:
|
|
1017
|
+
if player.health > 0.0:
|
|
1018
|
+
scale = float(state.rng.rand() % 50 + 1)
|
|
1019
|
+
player.health = min(100.0, player.health * scale)
|
|
1020
|
+
state.effects.spawn_burst(
|
|
1021
|
+
pos_x=float(player.pos_x),
|
|
1022
|
+
pos_y=float(player.pos_y),
|
|
1023
|
+
count=8,
|
|
1024
|
+
rand=state.rng.rand,
|
|
1025
|
+
detail_preset=5,
|
|
1026
|
+
)
|
|
1027
|
+
return
|
|
1028
|
+
|
|
1029
|
+
if perk_id == PerkId.MY_FAVOURITE_WEAPON:
|
|
1030
|
+
for player in players:
|
|
1031
|
+
player.clip_size += 2
|
|
1032
|
+
return
|
|
1033
|
+
|
|
1034
|
+
if perk_id == PerkId.PLAGUEBEARER:
|
|
1035
|
+
owner.plaguebearer_active = True
|
|
1036
|
+
finally:
|
|
1037
|
+
if len(players) > 1:
|
|
1038
|
+
for player in players[1:]:
|
|
1039
|
+
player.perk_counts[:] = owner.perk_counts
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def perk_auto_pick(
|
|
1043
|
+
state: GameplayState,
|
|
1044
|
+
players: list[PlayerState],
|
|
1045
|
+
perk_state: PerkSelectionState,
|
|
1046
|
+
*,
|
|
1047
|
+
game_mode: int,
|
|
1048
|
+
player_count: int | None = None,
|
|
1049
|
+
dt: float | None = None,
|
|
1050
|
+
creatures: list[_CreatureForPerks] | None = None,
|
|
1051
|
+
) -> list[PerkId]:
|
|
1052
|
+
"""Resolve pending perks by auto-selecting from generated choices."""
|
|
1053
|
+
|
|
1054
|
+
if not players:
|
|
1055
|
+
return []
|
|
1056
|
+
if player_count is None:
|
|
1057
|
+
player_count = len(players)
|
|
1058
|
+
picks: list[PerkId] = []
|
|
1059
|
+
while perk_state.pending_count > 0:
|
|
1060
|
+
if perk_state.choices_dirty or not perk_state.choices:
|
|
1061
|
+
perk_state.choices = [int(perk) for perk in perk_generate_choices(state, players[0], game_mode=game_mode, player_count=player_count)]
|
|
1062
|
+
perk_state.choices_dirty = False
|
|
1063
|
+
if not perk_state.choices:
|
|
1064
|
+
break
|
|
1065
|
+
idx = int(state.rng.rand() % len(perk_state.choices))
|
|
1066
|
+
perk_id = PerkId(perk_state.choices[idx])
|
|
1067
|
+
perk_apply(state, players, perk_id, perk_state=perk_state, dt=dt, creatures=creatures)
|
|
1068
|
+
picks.append(perk_id)
|
|
1069
|
+
perk_state.pending_count -= 1
|
|
1070
|
+
perk_state.choices_dirty = True
|
|
1071
|
+
return picks
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def perk_selection_current_choices(
|
|
1075
|
+
state: GameplayState,
|
|
1076
|
+
players: list[PlayerState],
|
|
1077
|
+
perk_state: PerkSelectionState,
|
|
1078
|
+
*,
|
|
1079
|
+
game_mode: int,
|
|
1080
|
+
player_count: int | None = None,
|
|
1081
|
+
) -> list[PerkId]:
|
|
1082
|
+
"""Return the current perk choices, generating them if needed.
|
|
1083
|
+
|
|
1084
|
+
Mirrors `perk_choices_dirty` + `perks_generate_choices` before entering the
|
|
1085
|
+
perk selection screen (state 6).
|
|
1086
|
+
"""
|
|
1087
|
+
|
|
1088
|
+
if not players:
|
|
1089
|
+
return []
|
|
1090
|
+
if player_count is None:
|
|
1091
|
+
player_count = len(players)
|
|
1092
|
+
if perk_state.choices_dirty or not perk_state.choices:
|
|
1093
|
+
perk_state.choices = [int(perk) for perk in perk_generate_choices(state, players[0], game_mode=game_mode, player_count=player_count)]
|
|
1094
|
+
perk_state.choices_dirty = False
|
|
1095
|
+
return [PerkId(perk_id) for perk_id in perk_state.choices]
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def perk_selection_pick(
|
|
1099
|
+
state: GameplayState,
|
|
1100
|
+
players: list[PlayerState],
|
|
1101
|
+
perk_state: PerkSelectionState,
|
|
1102
|
+
choice_index: int,
|
|
1103
|
+
*,
|
|
1104
|
+
game_mode: int,
|
|
1105
|
+
player_count: int | None = None,
|
|
1106
|
+
dt: float | None = None,
|
|
1107
|
+
creatures: list[_CreatureForPerks] | None = None,
|
|
1108
|
+
) -> PerkId | None:
|
|
1109
|
+
"""Pick a perk from the current choice list and apply it.
|
|
1110
|
+
|
|
1111
|
+
On success, decrements `pending_count` (one perk resolved) and marks the
|
|
1112
|
+
choice list dirty, matching `perk_selection_screen_update`.
|
|
1113
|
+
"""
|
|
1114
|
+
|
|
1115
|
+
if perk_state.pending_count <= 0:
|
|
1116
|
+
return None
|
|
1117
|
+
choices = perk_selection_current_choices(state, players, perk_state, game_mode=game_mode, player_count=player_count)
|
|
1118
|
+
if not choices:
|
|
1119
|
+
return None
|
|
1120
|
+
idx = int(choice_index)
|
|
1121
|
+
if idx < 0 or idx >= len(choices):
|
|
1122
|
+
return None
|
|
1123
|
+
perk_id = choices[idx]
|
|
1124
|
+
perk_apply(state, players, perk_id, perk_state=perk_state, dt=dt, creatures=creatures)
|
|
1125
|
+
perk_state.pending_count = max(0, int(perk_state.pending_count) - 1)
|
|
1126
|
+
perk_state.choices_dirty = True
|
|
1127
|
+
return perk_id
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def survival_progression_update(
|
|
1131
|
+
state: GameplayState,
|
|
1132
|
+
players: list[PlayerState],
|
|
1133
|
+
*,
|
|
1134
|
+
game_mode: int,
|
|
1135
|
+
player_count: int | None = None,
|
|
1136
|
+
auto_pick: bool = True,
|
|
1137
|
+
dt: float | None = None,
|
|
1138
|
+
creatures: list[_CreatureForPerks] | None = None,
|
|
1139
|
+
) -> list[PerkId]:
|
|
1140
|
+
"""Advance survival level/perk progression and optionally auto-pick perks."""
|
|
1141
|
+
|
|
1142
|
+
if not players:
|
|
1143
|
+
return []
|
|
1144
|
+
if player_count is None:
|
|
1145
|
+
player_count = len(players)
|
|
1146
|
+
survival_check_level_up(players[0], state.perk_selection)
|
|
1147
|
+
if auto_pick:
|
|
1148
|
+
return perk_auto_pick(
|
|
1149
|
+
state,
|
|
1150
|
+
players,
|
|
1151
|
+
state.perk_selection,
|
|
1152
|
+
game_mode=game_mode,
|
|
1153
|
+
player_count=player_count,
|
|
1154
|
+
dt=dt,
|
|
1155
|
+
creatures=creatures,
|
|
1156
|
+
)
|
|
1157
|
+
return []
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
|
1161
|
+
if value < lo:
|
|
1162
|
+
return lo
|
|
1163
|
+
if value > hi:
|
|
1164
|
+
return hi
|
|
1165
|
+
return value
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _normalize(x: float, y: float) -> tuple[float, float]:
|
|
1169
|
+
mag = math.hypot(x, y)
|
|
1170
|
+
if mag <= 1e-9:
|
|
1171
|
+
return 0.0, 0.0
|
|
1172
|
+
inv = 1.0 / mag
|
|
1173
|
+
return x * inv, y * inv
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
|
|
1177
|
+
dx = x1 - x0
|
|
1178
|
+
dy = y1 - y0
|
|
1179
|
+
return dx * dx + dy * dy
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _owner_id_for_player(player_index: int) -> int:
|
|
1183
|
+
# crimsonland.exe uses -1/-2/-3 for players (and sometimes -100 in demo paths).
|
|
1184
|
+
return -1 - int(player_index)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _weapon_entry(weapon_id: int) -> Weapon | None:
|
|
1188
|
+
return WEAPON_BY_ID.get(int(weapon_id))
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def weapon_refresh_available(state: "GameplayState") -> None:
|
|
1192
|
+
"""Rebuild `weapon_table[weapon_id].unlocked` equivalents from quest progression.
|
|
1193
|
+
|
|
1194
|
+
Port of `weapon_refresh_available` (0x00452e40).
|
|
1195
|
+
"""
|
|
1196
|
+
|
|
1197
|
+
unlock_index = 0
|
|
1198
|
+
status = state.status
|
|
1199
|
+
if status is not None:
|
|
1200
|
+
try:
|
|
1201
|
+
unlock_index = int(status.quest_unlock_index)
|
|
1202
|
+
except Exception:
|
|
1203
|
+
unlock_index = 0
|
|
1204
|
+
|
|
1205
|
+
game_mode = int(state.game_mode)
|
|
1206
|
+
if (
|
|
1207
|
+
int(state._weapon_available_game_mode) == game_mode
|
|
1208
|
+
and int(state._weapon_available_unlock_index) == unlock_index
|
|
1209
|
+
):
|
|
1210
|
+
return
|
|
1211
|
+
|
|
1212
|
+
# Clear unlocked flags.
|
|
1213
|
+
available = state.weapon_available
|
|
1214
|
+
for idx in range(len(available)):
|
|
1215
|
+
available[idx] = False
|
|
1216
|
+
|
|
1217
|
+
# Pistol is always available.
|
|
1218
|
+
pistol_id = int(WeaponId.PISTOL)
|
|
1219
|
+
if 0 <= pistol_id < len(available):
|
|
1220
|
+
available[pistol_id] = True
|
|
1221
|
+
|
|
1222
|
+
# Unlock weapons from the quest list (first `quest_unlock_index` entries).
|
|
1223
|
+
if unlock_index > 0:
|
|
1224
|
+
try:
|
|
1225
|
+
from .quests import all_quests
|
|
1226
|
+
|
|
1227
|
+
quests = all_quests()
|
|
1228
|
+
except Exception:
|
|
1229
|
+
quests = []
|
|
1230
|
+
|
|
1231
|
+
for quest in quests[:unlock_index]:
|
|
1232
|
+
weapon_id = int(getattr(quest, "unlock_weapon_id", 0) or 0)
|
|
1233
|
+
if 0 < weapon_id < len(available):
|
|
1234
|
+
available[weapon_id] = True
|
|
1235
|
+
|
|
1236
|
+
# Survival default loadout: Assault Rifle, Shotgun, Submachine Gun.
|
|
1237
|
+
if game_mode == int(GameMode.SURVIVAL):
|
|
1238
|
+
for weapon_id in (WeaponId.ASSAULT_RIFLE, WeaponId.SHOTGUN, WeaponId.SUBMACHINE_GUN):
|
|
1239
|
+
idx = int(weapon_id)
|
|
1240
|
+
if 0 <= idx < len(available):
|
|
1241
|
+
available[idx] = True
|
|
1242
|
+
|
|
1243
|
+
state._weapon_available_game_mode = game_mode
|
|
1244
|
+
state._weapon_available_unlock_index = unlock_index
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def weapon_pick_random_available(state: "GameplayState") -> int:
|
|
1248
|
+
"""Select a random available weapon id (1..33).
|
|
1249
|
+
|
|
1250
|
+
Port of `weapon_pick_random_available` (0x00452cd0).
|
|
1251
|
+
"""
|
|
1252
|
+
|
|
1253
|
+
weapon_refresh_available(state)
|
|
1254
|
+
status = state.status
|
|
1255
|
+
|
|
1256
|
+
for _ in range(1000):
|
|
1257
|
+
base_rand = int(state.rng.rand())
|
|
1258
|
+
weapon_id = base_rand % WEAPON_DROP_ID_COUNT + 1
|
|
1259
|
+
|
|
1260
|
+
# Bias: used weapons have a 50% chance to reroll once.
|
|
1261
|
+
if status is not None:
|
|
1262
|
+
try:
|
|
1263
|
+
if status.weapon_usage_count(weapon_id) != 0:
|
|
1264
|
+
if (int(state.rng.rand()) & 1) == 0:
|
|
1265
|
+
base_rand = int(state.rng.rand())
|
|
1266
|
+
weapon_id = base_rand % WEAPON_DROP_ID_COUNT + 1
|
|
1267
|
+
except Exception:
|
|
1268
|
+
pass
|
|
1269
|
+
|
|
1270
|
+
if not (0 <= weapon_id < len(state.weapon_available)):
|
|
1271
|
+
continue
|
|
1272
|
+
if not state.weapon_available[weapon_id]:
|
|
1273
|
+
continue
|
|
1274
|
+
|
|
1275
|
+
# Quest 5-10 special-case: suppress Ion Cannon.
|
|
1276
|
+
if (
|
|
1277
|
+
int(state.game_mode) == int(GameMode.QUESTS)
|
|
1278
|
+
and int(state.quest_stage_major) == 5
|
|
1279
|
+
and int(state.quest_stage_minor) == 10
|
|
1280
|
+
and weapon_id == int(WeaponId.ION_CANNON)
|
|
1281
|
+
):
|
|
1282
|
+
continue
|
|
1283
|
+
|
|
1284
|
+
return weapon_id
|
|
1285
|
+
|
|
1286
|
+
return int(WeaponId.PISTOL)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _projectile_meta_for_type_id(type_id: int) -> float:
|
|
1290
|
+
entry = weapon_entry_for_projectile_type_id(int(type_id))
|
|
1291
|
+
meta = entry.projectile_meta if entry is not None else None
|
|
1292
|
+
return float(meta if meta is not None else 45.0)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _bonus_enabled(bonus_id: int) -> bool:
|
|
1296
|
+
meta = BONUS_BY_ID.get(int(bonus_id))
|
|
1297
|
+
if meta is None:
|
|
1298
|
+
return False
|
|
1299
|
+
return meta.bonus_id != BonusId.UNUSED
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def _bonus_id_from_roll(roll: int, rng: Crand) -> int:
|
|
1303
|
+
# Mirrors `bonus_pick_random_type` (0x412470) mapping:
|
|
1304
|
+
# - roll = rand() % 162 + 1 (1..162)
|
|
1305
|
+
# - Points: roll 1..13
|
|
1306
|
+
# - Energizer: roll 14 with (rand & 0x3F) == 0, else Weapon
|
|
1307
|
+
# - Bucketed ids 3..14 via a 10-step loop; if it would exceed 14, returns 0
|
|
1308
|
+
# to force a reroll (matching the `goto LABEL_18` path leaving `v3 == 0`).
|
|
1309
|
+
if roll < 1 or roll > 162:
|
|
1310
|
+
return 0
|
|
1311
|
+
|
|
1312
|
+
if roll <= 13:
|
|
1313
|
+
return int(BonusId.POINTS)
|
|
1314
|
+
|
|
1315
|
+
if roll == 14:
|
|
1316
|
+
if (rng.rand() & 0x3F) == 0:
|
|
1317
|
+
return int(BonusId.ENERGIZER)
|
|
1318
|
+
return int(BonusId.WEAPON)
|
|
1319
|
+
|
|
1320
|
+
v5 = roll - 14
|
|
1321
|
+
v6 = int(BonusId.WEAPON)
|
|
1322
|
+
while v5 > 10:
|
|
1323
|
+
v5 -= 10
|
|
1324
|
+
v6 += 1
|
|
1325
|
+
if v6 >= 15:
|
|
1326
|
+
return 0
|
|
1327
|
+
return int(v6)
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def bonus_pick_random_type(pool: BonusPool, state: "GameplayState", players: list["PlayerState"]) -> int:
|
|
1331
|
+
has_fire_bullets_drop = any(
|
|
1332
|
+
entry.bonus_id == int(BonusId.FIRE_BULLETS) and not entry.picked
|
|
1333
|
+
for entry in pool.entries
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
for _ in range(101):
|
|
1337
|
+
roll = int(state.rng.rand()) % 162 + 1
|
|
1338
|
+
bonus_id = _bonus_id_from_roll(roll, state.rng)
|
|
1339
|
+
if bonus_id <= 0:
|
|
1340
|
+
continue
|
|
1341
|
+
if state.shock_chain_links_left > 0 and bonus_id == int(BonusId.SHOCK_CHAIN):
|
|
1342
|
+
continue
|
|
1343
|
+
if int(state.game_mode) == int(GameMode.QUESTS) and int(state.quest_stage_minor) == 10:
|
|
1344
|
+
if bonus_id == int(BonusId.NUKE) and (
|
|
1345
|
+
int(state.quest_stage_major) in (2, 4, 5) or (state.hardcore and int(state.quest_stage_major) == 3)
|
|
1346
|
+
):
|
|
1347
|
+
continue
|
|
1348
|
+
if bonus_id == int(BonusId.FREEZE) and (
|
|
1349
|
+
int(state.quest_stage_major) == 4 or (state.hardcore and int(state.quest_stage_major) == 2)
|
|
1350
|
+
):
|
|
1351
|
+
continue
|
|
1352
|
+
if bonus_id == int(BonusId.FREEZE) and state.bonuses.freeze > 0.0:
|
|
1353
|
+
continue
|
|
1354
|
+
if bonus_id == int(BonusId.SHIELD) and any(player.shield_timer > 0.0 for player in players):
|
|
1355
|
+
continue
|
|
1356
|
+
if bonus_id == int(BonusId.WEAPON) and has_fire_bullets_drop:
|
|
1357
|
+
continue
|
|
1358
|
+
if bonus_id == int(BonusId.WEAPON) and any(perk_active(player, PerkId.MY_FAVOURITE_WEAPON) for player in players):
|
|
1359
|
+
continue
|
|
1360
|
+
if bonus_id == int(BonusId.MEDIKIT) and any(perk_active(player, PerkId.DEATH_CLOCK) for player in players):
|
|
1361
|
+
continue
|
|
1362
|
+
if not _bonus_enabled(bonus_id):
|
|
1363
|
+
continue
|
|
1364
|
+
return bonus_id
|
|
1365
|
+
return int(BonusId.POINTS)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def weapon_assign_player(player: PlayerState, weapon_id: int, *, state: GameplayState | None = None) -> None:
|
|
1369
|
+
"""Assign weapon and reset per-weapon runtime state (ammo/cooldowns)."""
|
|
1370
|
+
|
|
1371
|
+
weapon_id = int(weapon_id)
|
|
1372
|
+
if state is not None and state.status is not None and not state.demo_mode_active:
|
|
1373
|
+
try:
|
|
1374
|
+
state.status.increment_weapon_usage(weapon_id)
|
|
1375
|
+
except Exception:
|
|
1376
|
+
pass
|
|
1377
|
+
|
|
1378
|
+
weapon = _weapon_entry(weapon_id)
|
|
1379
|
+
player.weapon_id = weapon_id
|
|
1380
|
+
|
|
1381
|
+
clip_size = int(weapon.clip_size) if weapon is not None and weapon.clip_size is not None else 0
|
|
1382
|
+
clip_size = max(0, clip_size)
|
|
1383
|
+
|
|
1384
|
+
# weapon_assign_player @ 0x004220B0: clip-size perks are applied on every weapon assignment.
|
|
1385
|
+
if perk_active(player, PerkId.AMMO_MANIAC):
|
|
1386
|
+
clip_size += max(1, int(float(clip_size) * 0.25))
|
|
1387
|
+
if perk_active(player, PerkId.MY_FAVOURITE_WEAPON):
|
|
1388
|
+
clip_size += 2
|
|
1389
|
+
|
|
1390
|
+
player.clip_size = max(0, int(clip_size))
|
|
1391
|
+
player.ammo = float(player.clip_size)
|
|
1392
|
+
player.weapon_reset_latch = 0
|
|
1393
|
+
player.reload_active = False
|
|
1394
|
+
player.reload_timer = 0.0
|
|
1395
|
+
player.reload_timer_max = 0.0
|
|
1396
|
+
player.shot_cooldown = 0.0
|
|
1397
|
+
player.aux_timer = 2.0
|
|
1398
|
+
|
|
1399
|
+
if state is not None and weapon is not None:
|
|
1400
|
+
from .weapon_sfx import resolve_weapon_sfx_ref
|
|
1401
|
+
|
|
1402
|
+
key = resolve_weapon_sfx_ref(weapon.reload_sound)
|
|
1403
|
+
if key is not None:
|
|
1404
|
+
state.sfx_queue.append(key)
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def most_used_weapon_id_for_player(state: GameplayState, *, player_index: int, fallback_weapon_id: int) -> int:
|
|
1408
|
+
"""Return a 1-based weapon id for the player's most-used weapon."""
|
|
1409
|
+
|
|
1410
|
+
idx = int(player_index)
|
|
1411
|
+
if 0 <= idx < len(state.weapon_shots_fired):
|
|
1412
|
+
counts = state.weapon_shots_fired[idx]
|
|
1413
|
+
if counts:
|
|
1414
|
+
start = 1 if len(counts) > 1 else 0
|
|
1415
|
+
best = max(range(start, len(counts)), key=counts.__getitem__)
|
|
1416
|
+
if int(counts[best]) > 0:
|
|
1417
|
+
return int(best)
|
|
1418
|
+
return int(fallback_weapon_id)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def player_swap_alt_weapon(player: PlayerState) -> bool:
|
|
1422
|
+
"""Swap primary and alternate weapon runtime blocks (Alternate Weapon perk)."""
|
|
1423
|
+
|
|
1424
|
+
if player.alt_weapon_id is None:
|
|
1425
|
+
return False
|
|
1426
|
+
(
|
|
1427
|
+
player.weapon_id,
|
|
1428
|
+
player.clip_size,
|
|
1429
|
+
player.reload_active,
|
|
1430
|
+
player.ammo,
|
|
1431
|
+
player.reload_timer,
|
|
1432
|
+
player.shot_cooldown,
|
|
1433
|
+
player.reload_timer_max,
|
|
1434
|
+
player.alt_weapon_id,
|
|
1435
|
+
player.alt_clip_size,
|
|
1436
|
+
player.alt_reload_active,
|
|
1437
|
+
player.alt_ammo,
|
|
1438
|
+
player.alt_reload_timer,
|
|
1439
|
+
player.alt_shot_cooldown,
|
|
1440
|
+
player.alt_reload_timer_max,
|
|
1441
|
+
) = (
|
|
1442
|
+
player.alt_weapon_id,
|
|
1443
|
+
player.alt_clip_size,
|
|
1444
|
+
player.alt_reload_active,
|
|
1445
|
+
player.alt_ammo,
|
|
1446
|
+
player.alt_reload_timer,
|
|
1447
|
+
player.alt_shot_cooldown,
|
|
1448
|
+
player.alt_reload_timer_max,
|
|
1449
|
+
player.weapon_id,
|
|
1450
|
+
player.clip_size,
|
|
1451
|
+
player.reload_active,
|
|
1452
|
+
player.ammo,
|
|
1453
|
+
player.reload_timer,
|
|
1454
|
+
player.shot_cooldown,
|
|
1455
|
+
player.reload_timer_max,
|
|
1456
|
+
)
|
|
1457
|
+
return True
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def player_start_reload(player: PlayerState, state: GameplayState) -> None:
|
|
1461
|
+
"""Start or refresh a reload timer (`player_start_reload` @ 0x00413430)."""
|
|
1462
|
+
|
|
1463
|
+
if player.reload_active and (perk_active(player, PerkId.AMMUNITION_WITHIN) or perk_active(player, PerkId.REGRESSION_BULLETS)):
|
|
1464
|
+
return
|
|
1465
|
+
|
|
1466
|
+
weapon = _weapon_entry(player.weapon_id)
|
|
1467
|
+
reload_time = float(weapon.reload_time) if weapon is not None and weapon.reload_time is not None else 0.0
|
|
1468
|
+
|
|
1469
|
+
if not player.reload_active:
|
|
1470
|
+
player.reload_active = True
|
|
1471
|
+
|
|
1472
|
+
if perk_active(player, PerkId.FASTLOADER):
|
|
1473
|
+
reload_time *= 0.69999999
|
|
1474
|
+
if state.bonuses.weapon_power_up > 0.0:
|
|
1475
|
+
reload_time *= 0.60000002
|
|
1476
|
+
|
|
1477
|
+
player.reload_timer = max(0.0, reload_time)
|
|
1478
|
+
player.reload_timer_max = player.reload_timer
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _spawn_projectile_ring(
|
|
1482
|
+
state: GameplayState,
|
|
1483
|
+
origin: _HasPos,
|
|
1484
|
+
*,
|
|
1485
|
+
count: int,
|
|
1486
|
+
angle_offset: float,
|
|
1487
|
+
type_id: int,
|
|
1488
|
+
owner_id: int,
|
|
1489
|
+
) -> None:
|
|
1490
|
+
if count <= 0:
|
|
1491
|
+
return
|
|
1492
|
+
step = math.tau / float(count)
|
|
1493
|
+
meta = _projectile_meta_for_type_id(type_id)
|
|
1494
|
+
for idx in range(count):
|
|
1495
|
+
state.projectiles.spawn(
|
|
1496
|
+
pos_x=float(origin.pos_x),
|
|
1497
|
+
pos_y=float(origin.pos_y),
|
|
1498
|
+
angle=float(idx) * step + float(angle_offset),
|
|
1499
|
+
type_id=int(type_id),
|
|
1500
|
+
owner_id=int(owner_id),
|
|
1501
|
+
base_damage=meta,
|
|
1502
|
+
)
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _perk_update_man_bomb(player: PlayerState, dt: float, state: GameplayState) -> None:
|
|
1506
|
+
player.man_bomb_timer += dt
|
|
1507
|
+
if player.man_bomb_timer <= state.perk_intervals.man_bomb:
|
|
1508
|
+
return
|
|
1509
|
+
|
|
1510
|
+
owner_id = _owner_id_for_player(player.index)
|
|
1511
|
+
state.bonus_spawn_guard = True
|
|
1512
|
+
for idx in range(8):
|
|
1513
|
+
type_id = ProjectileTypeId.ION_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.ION_RIFLE
|
|
1514
|
+
angle = (float(state.rng.rand() % 50) * 0.01) + float(idx) * (math.pi / 4.0) - 0.25
|
|
1515
|
+
state.projectiles.spawn(
|
|
1516
|
+
pos_x=player.pos_x,
|
|
1517
|
+
pos_y=player.pos_y,
|
|
1518
|
+
angle=angle,
|
|
1519
|
+
type_id=type_id,
|
|
1520
|
+
owner_id=owner_id,
|
|
1521
|
+
base_damage=_projectile_meta_for_type_id(type_id),
|
|
1522
|
+
)
|
|
1523
|
+
state.bonus_spawn_guard = False
|
|
1524
|
+
state.sfx_queue.append("sfx_explosion_small")
|
|
1525
|
+
|
|
1526
|
+
player.man_bomb_timer -= state.perk_intervals.man_bomb
|
|
1527
|
+
state.perk_intervals.man_bomb = 4.0
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _perk_update_hot_tempered(player: PlayerState, dt: float, state: GameplayState) -> None:
|
|
1531
|
+
player.hot_tempered_timer += dt
|
|
1532
|
+
if player.hot_tempered_timer <= state.perk_intervals.hot_tempered:
|
|
1533
|
+
return
|
|
1534
|
+
|
|
1535
|
+
owner_id = _owner_id_for_player(player.index)
|
|
1536
|
+
state.bonus_spawn_guard = True
|
|
1537
|
+
for idx in range(8):
|
|
1538
|
+
type_id = ProjectileTypeId.PLASMA_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.PLASMA_RIFLE
|
|
1539
|
+
angle = float(idx) * (math.pi / 4.0)
|
|
1540
|
+
state.projectiles.spawn(
|
|
1541
|
+
pos_x=player.pos_x,
|
|
1542
|
+
pos_y=player.pos_y,
|
|
1543
|
+
angle=angle,
|
|
1544
|
+
type_id=type_id,
|
|
1545
|
+
owner_id=owner_id,
|
|
1546
|
+
base_damage=_projectile_meta_for_type_id(type_id),
|
|
1547
|
+
)
|
|
1548
|
+
state.bonus_spawn_guard = False
|
|
1549
|
+
state.sfx_queue.append("sfx_explosion_small")
|
|
1550
|
+
|
|
1551
|
+
player.hot_tempered_timer -= state.perk_intervals.hot_tempered
|
|
1552
|
+
state.perk_intervals.hot_tempered = float(state.rng.rand() % 8) + 2.0
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _perk_update_fire_cough(player: PlayerState, dt: float, state: GameplayState) -> None:
|
|
1556
|
+
player.fire_cough_timer += dt
|
|
1557
|
+
if player.fire_cough_timer <= state.perk_intervals.fire_cough:
|
|
1558
|
+
return
|
|
1559
|
+
|
|
1560
|
+
owner_id = _owner_id_for_player(player.index)
|
|
1561
|
+
# Fire Cough spawns a fire projectile (and a small sprite burst) from the muzzle.
|
|
1562
|
+
theta = math.atan2(player.aim_dir_y, player.aim_dir_x)
|
|
1563
|
+
jitter = (float(state.rng.rand() % 200) - 100.0) * 0.0015
|
|
1564
|
+
angle = theta + jitter + math.pi / 2.0
|
|
1565
|
+
muzzle_x = player.pos_x + player.aim_dir_x * 16.0
|
|
1566
|
+
muzzle_y = player.pos_y + player.aim_dir_y * 16.0
|
|
1567
|
+
state.projectiles.spawn(
|
|
1568
|
+
pos_x=muzzle_x,
|
|
1569
|
+
pos_y=muzzle_y,
|
|
1570
|
+
angle=angle,
|
|
1571
|
+
type_id=ProjectileTypeId.FIRE_BULLETS,
|
|
1572
|
+
owner_id=owner_id,
|
|
1573
|
+
base_damage=_projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS),
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
player.fire_cough_timer -= state.perk_intervals.fire_cough
|
|
1577
|
+
state.perk_intervals.fire_cough = float(state.rng.rand() % 4) + 2.0
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float, state: GameplayState) -> None:
|
|
1581
|
+
dt = float(dt)
|
|
1582
|
+
|
|
1583
|
+
weapon_id = int(player.weapon_id)
|
|
1584
|
+
weapon = _weapon_entry(weapon_id)
|
|
1585
|
+
if weapon is None:
|
|
1586
|
+
return
|
|
1587
|
+
|
|
1588
|
+
if player.shot_cooldown > 0.0:
|
|
1589
|
+
return
|
|
1590
|
+
if not input_state.fire_down:
|
|
1591
|
+
return
|
|
1592
|
+
|
|
1593
|
+
firing_during_reload = False
|
|
1594
|
+
ammo_cost = 1.0
|
|
1595
|
+
is_fire_bullets = float(player.fire_bullets_timer) > 0.0
|
|
1596
|
+
if player.reload_timer > 0.0:
|
|
1597
|
+
if player.ammo <= 0 and player.experience > 0:
|
|
1598
|
+
if perk_active(player, PerkId.REGRESSION_BULLETS):
|
|
1599
|
+
firing_during_reload = True
|
|
1600
|
+
ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
|
|
1601
|
+
|
|
1602
|
+
reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
|
|
1603
|
+
factor = 4.0 if ammo_class == 1 else 200.0
|
|
1604
|
+
player.experience = int(float(player.experience) - reload_time * factor)
|
|
1605
|
+
if player.experience < 0:
|
|
1606
|
+
player.experience = 0
|
|
1607
|
+
elif perk_active(player, PerkId.AMMUNITION_WITHIN):
|
|
1608
|
+
firing_during_reload = True
|
|
1609
|
+
ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
|
|
1610
|
+
|
|
1611
|
+
from .player_damage import player_take_damage
|
|
1612
|
+
|
|
1613
|
+
cost = 0.15 if ammo_class == 1 else 1.0
|
|
1614
|
+
player_take_damage(state, player, cost, dt=dt, rand=state.rng.rand)
|
|
1615
|
+
else:
|
|
1616
|
+
return
|
|
1617
|
+
else:
|
|
1618
|
+
return
|
|
1619
|
+
|
|
1620
|
+
if player.ammo <= 0 and not firing_during_reload and not is_fire_bullets:
|
|
1621
|
+
player_start_reload(player, state)
|
|
1622
|
+
return
|
|
1623
|
+
|
|
1624
|
+
pellet_count = int(weapon.pellet_count) if weapon.pellet_count is not None else 0
|
|
1625
|
+
fire_bullets_weapon = weapon_entry_for_projectile_type_id(int(ProjectileTypeId.FIRE_BULLETS))
|
|
1626
|
+
|
|
1627
|
+
shot_cooldown = float(weapon.shot_cooldown) if weapon.shot_cooldown is not None else 0.0
|
|
1628
|
+
spread_heat_base = float(weapon.spread_heat_inc) if weapon.spread_heat_inc is not None else 0.0
|
|
1629
|
+
if is_fire_bullets and fire_bullets_weapon is not None and fire_bullets_weapon.spread_heat_inc is not None:
|
|
1630
|
+
spread_heat_base = float(fire_bullets_weapon.spread_heat_inc)
|
|
1631
|
+
|
|
1632
|
+
if is_fire_bullets and pellet_count == 1 and fire_bullets_weapon is not None:
|
|
1633
|
+
shot_cooldown = (
|
|
1634
|
+
float(fire_bullets_weapon.shot_cooldown)
|
|
1635
|
+
if fire_bullets_weapon.shot_cooldown is not None
|
|
1636
|
+
else 0.0
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
spread_inc = spread_heat_base * 1.3
|
|
1640
|
+
|
|
1641
|
+
if perk_active(player, PerkId.FASTSHOT):
|
|
1642
|
+
shot_cooldown *= 0.88
|
|
1643
|
+
if perk_active(player, PerkId.SHARPSHOOTER):
|
|
1644
|
+
shot_cooldown *= 1.05
|
|
1645
|
+
player.shot_cooldown = max(0.0, shot_cooldown)
|
|
1646
|
+
|
|
1647
|
+
aim_x = float(input_state.aim_x)
|
|
1648
|
+
aim_y = float(input_state.aim_y)
|
|
1649
|
+
dx = aim_x - float(player.pos_x)
|
|
1650
|
+
dy = aim_y - float(player.pos_y)
|
|
1651
|
+
dist = math.hypot(dx, dy)
|
|
1652
|
+
max_offset = dist * float(player.spread_heat) * 0.5
|
|
1653
|
+
dir_angle = float(int(state.rng.rand()) & 0x1FF) * (math.tau / 512.0)
|
|
1654
|
+
mag = float(int(state.rng.rand()) & 0x1FF) * (1.0 / 512.0)
|
|
1655
|
+
offset = max_offset * mag
|
|
1656
|
+
aim_jitter_x = aim_x + math.cos(dir_angle) * offset
|
|
1657
|
+
aim_jitter_y = aim_y + math.sin(dir_angle) * offset
|
|
1658
|
+
shot_angle = math.atan2(aim_jitter_y - float(player.pos_y), aim_jitter_x - float(player.pos_x)) + math.pi / 2.0
|
|
1659
|
+
particle_angle = shot_angle - math.pi / 2.0
|
|
1660
|
+
|
|
1661
|
+
muzzle_x = player.pos_x + player.aim_dir_x * 16.0
|
|
1662
|
+
muzzle_y = player.pos_y + player.aim_dir_y * 16.0
|
|
1663
|
+
|
|
1664
|
+
owner_id = _owner_id_for_player(player.index)
|
|
1665
|
+
shot_count = 1
|
|
1666
|
+
|
|
1667
|
+
# `player_fire_weapon` (crimsonland.exe) uses weapon-specific extra angular jitter for pellet
|
|
1668
|
+
# weapons. This is separate from aim-point jitter driven by `player.spread_heat`.
|
|
1669
|
+
def _pellet_jitter_step(weapon_id: int) -> float:
|
|
1670
|
+
weapon_id = int(weapon_id)
|
|
1671
|
+
if weapon_id == WeaponId.SHOTGUN:
|
|
1672
|
+
return 0.0013
|
|
1673
|
+
if weapon_id == WeaponId.SAWED_OFF_SHOTGUN:
|
|
1674
|
+
return 0.004
|
|
1675
|
+
if weapon_id == WeaponId.JACKHAMMER:
|
|
1676
|
+
return 0.0013
|
|
1677
|
+
return 0.0015
|
|
1678
|
+
|
|
1679
|
+
if is_fire_bullets:
|
|
1680
|
+
pellets = max(1, int(pellet_count))
|
|
1681
|
+
shot_count = pellets
|
|
1682
|
+
meta = _projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS)
|
|
1683
|
+
for _ in range(pellets):
|
|
1684
|
+
angle = shot_angle
|
|
1685
|
+
if pellets > 1:
|
|
1686
|
+
angle += float(int(state.rng.rand()) % 200 - 100) * 0.0015
|
|
1687
|
+
state.projectiles.spawn(
|
|
1688
|
+
pos_x=muzzle_x,
|
|
1689
|
+
pos_y=muzzle_y,
|
|
1690
|
+
angle=angle,
|
|
1691
|
+
type_id=ProjectileTypeId.FIRE_BULLETS,
|
|
1692
|
+
owner_id=owner_id,
|
|
1693
|
+
base_damage=meta,
|
|
1694
|
+
)
|
|
1695
|
+
elif weapon_id == WeaponId.ROCKET_LAUNCHER:
|
|
1696
|
+
# Rocket Launcher -> secondary type 1.
|
|
1697
|
+
state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle, type_id=1, owner_id=owner_id)
|
|
1698
|
+
elif weapon_id == WeaponId.SEEKER_ROCKETS:
|
|
1699
|
+
# Seeker Rockets -> secondary type 2.
|
|
1700
|
+
state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle, type_id=2, owner_id=owner_id)
|
|
1701
|
+
elif weapon_id == WeaponId.MINI_ROCKET_SWARMERS:
|
|
1702
|
+
# Mini-Rocket Swarmers -> secondary type 2 (fires the full clip in a spread).
|
|
1703
|
+
rocket_count = max(1, int(player.ammo))
|
|
1704
|
+
step = float(rocket_count) * (math.pi / 3.0)
|
|
1705
|
+
angle = (shot_angle - math.pi) - step * float(rocket_count) * 0.5
|
|
1706
|
+
for _ in range(rocket_count):
|
|
1707
|
+
state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=angle, type_id=2, owner_id=owner_id)
|
|
1708
|
+
angle += step
|
|
1709
|
+
ammo_cost = float(rocket_count)
|
|
1710
|
+
shot_count = rocket_count
|
|
1711
|
+
elif weapon_id == WeaponId.ROCKET_MINIGUN:
|
|
1712
|
+
# Rocket Minigun -> secondary type 4.
|
|
1713
|
+
state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle, type_id=4, owner_id=owner_id)
|
|
1714
|
+
elif weapon_id == WeaponId.FLAMETHROWER:
|
|
1715
|
+
# Flamethrower -> fast particle weapon (style 0), fractional ammo drain.
|
|
1716
|
+
state.particles.spawn_particle(pos_x=muzzle_x, pos_y=muzzle_y, angle=particle_angle, intensity=1.0, owner_id=owner_id)
|
|
1717
|
+
ammo_cost = 0.1
|
|
1718
|
+
elif weapon_id == WeaponId.BLOW_TORCH:
|
|
1719
|
+
# Blow Torch -> fast particle weapon (style 1), fractional ammo drain.
|
|
1720
|
+
particle_id = state.particles.spawn_particle(pos_x=muzzle_x, pos_y=muzzle_y, angle=particle_angle, intensity=1.0, owner_id=owner_id)
|
|
1721
|
+
state.particles.entries[particle_id].style_id = 1
|
|
1722
|
+
ammo_cost = 0.05
|
|
1723
|
+
elif weapon_id == WeaponId.HR_FLAMER:
|
|
1724
|
+
# HR Flamer -> fast particle weapon (style 2), fractional ammo drain.
|
|
1725
|
+
particle_id = state.particles.spawn_particle(pos_x=muzzle_x, pos_y=muzzle_y, angle=particle_angle, intensity=1.0, owner_id=owner_id)
|
|
1726
|
+
state.particles.entries[particle_id].style_id = 2
|
|
1727
|
+
ammo_cost = 0.1
|
|
1728
|
+
elif weapon_id == WeaponId.BUBBLEGUN:
|
|
1729
|
+
# Bubblegun -> slow particle weapon (style 8), fractional ammo drain.
|
|
1730
|
+
state.particles.spawn_particle_slow(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle - math.pi / 2.0, owner_id=owner_id)
|
|
1731
|
+
ammo_cost = 0.15
|
|
1732
|
+
elif weapon_id == WeaponId.MULTI_PLASMA:
|
|
1733
|
+
# Multi-Plasma: 5-shot fixed spread using type 0x09 and 0x0B.
|
|
1734
|
+
# (`player_update` weapon_id==0x0a in crimsonland.exe)
|
|
1735
|
+
shot_count = 5
|
|
1736
|
+
# Native literals: 0.31415927 (~ pi/10), 0.5235988 (~ pi/6).
|
|
1737
|
+
spread_small = math.pi / 10
|
|
1738
|
+
spread_large = math.pi / 6
|
|
1739
|
+
patterns: tuple[tuple[float, ProjectileTypeId], ...] = (
|
|
1740
|
+
(-spread_small, ProjectileTypeId.PLASMA_RIFLE),
|
|
1741
|
+
(-spread_large, ProjectileTypeId.PLASMA_MINIGUN),
|
|
1742
|
+
(0.0, ProjectileTypeId.PLASMA_RIFLE),
|
|
1743
|
+
(spread_large, ProjectileTypeId.PLASMA_MINIGUN),
|
|
1744
|
+
(spread_small, ProjectileTypeId.PLASMA_RIFLE),
|
|
1745
|
+
)
|
|
1746
|
+
for angle_offset, type_id in patterns:
|
|
1747
|
+
state.projectiles.spawn(
|
|
1748
|
+
pos_x=muzzle_x,
|
|
1749
|
+
pos_y=muzzle_y,
|
|
1750
|
+
angle=shot_angle + angle_offset,
|
|
1751
|
+
type_id=type_id,
|
|
1752
|
+
owner_id=owner_id,
|
|
1753
|
+
base_damage=_projectile_meta_for_type_id(type_id),
|
|
1754
|
+
)
|
|
1755
|
+
elif weapon_id == WeaponId.PLASMA_SHOTGUN:
|
|
1756
|
+
# Plasma Shotgun: 14 plasma-minigun pellets with wide jitter and random speed_scale.
|
|
1757
|
+
# (`player_update` weapon_id==0x0e in crimsonland.exe)
|
|
1758
|
+
shot_count = 14
|
|
1759
|
+
meta = _projectile_meta_for_type_id(int(ProjectileTypeId.PLASMA_MINIGUN))
|
|
1760
|
+
for _ in range(14):
|
|
1761
|
+
jitter = float((int(state.rng.rand()) & 0xFF) - 0x80) * 0.002
|
|
1762
|
+
proj_id = state.projectiles.spawn(
|
|
1763
|
+
pos_x=muzzle_x,
|
|
1764
|
+
pos_y=muzzle_y,
|
|
1765
|
+
angle=shot_angle + jitter,
|
|
1766
|
+
type_id=ProjectileTypeId.PLASMA_MINIGUN,
|
|
1767
|
+
owner_id=owner_id,
|
|
1768
|
+
base_damage=meta,
|
|
1769
|
+
)
|
|
1770
|
+
state.projectiles.entries[int(proj_id)].speed_scale = 1.0 + float(int(state.rng.rand()) % 100) * 0.01
|
|
1771
|
+
elif weapon_id == WeaponId.GAUSS_SHOTGUN:
|
|
1772
|
+
# Gauss Shotgun: 6 gauss pellets, jitter 0.002 and speed_scale 1.4..(1.4 + 0.79).
|
|
1773
|
+
# (`player_update` weapon_id==0x1e in crimsonland.exe)
|
|
1774
|
+
shot_count = 6
|
|
1775
|
+
meta = _projectile_meta_for_type_id(int(ProjectileTypeId.GAUSS_GUN))
|
|
1776
|
+
for _ in range(6):
|
|
1777
|
+
jitter = float(int(state.rng.rand()) % 200 - 100) * 0.002
|
|
1778
|
+
proj_id = state.projectiles.spawn(
|
|
1779
|
+
pos_x=muzzle_x,
|
|
1780
|
+
pos_y=muzzle_y,
|
|
1781
|
+
angle=shot_angle + jitter,
|
|
1782
|
+
type_id=ProjectileTypeId.GAUSS_GUN,
|
|
1783
|
+
owner_id=owner_id,
|
|
1784
|
+
base_damage=meta,
|
|
1785
|
+
)
|
|
1786
|
+
state.projectiles.entries[int(proj_id)].speed_scale = 1.4 + float(int(state.rng.rand()) % 0x50) * 0.01
|
|
1787
|
+
elif weapon_id == WeaponId.ION_SHOTGUN:
|
|
1788
|
+
# Ion Shotgun: 8 ion-minigun pellets, jitter 0.0026 and speed_scale 1.4..(1.4 + 0.79).
|
|
1789
|
+
# (`player_update` weapon_id==0x1f in crimsonland.exe)
|
|
1790
|
+
shot_count = 8
|
|
1791
|
+
meta = _projectile_meta_for_type_id(int(ProjectileTypeId.ION_MINIGUN))
|
|
1792
|
+
for _ in range(8):
|
|
1793
|
+
jitter = float(int(state.rng.rand()) % 200 - 100) * 0.0026
|
|
1794
|
+
proj_id = state.projectiles.spawn(
|
|
1795
|
+
pos_x=muzzle_x,
|
|
1796
|
+
pos_y=muzzle_y,
|
|
1797
|
+
angle=shot_angle + jitter,
|
|
1798
|
+
type_id=ProjectileTypeId.ION_MINIGUN,
|
|
1799
|
+
owner_id=owner_id,
|
|
1800
|
+
base_damage=meta,
|
|
1801
|
+
)
|
|
1802
|
+
state.projectiles.entries[int(proj_id)].speed_scale = 1.4 + float(int(state.rng.rand()) % 0x50) * 0.01
|
|
1803
|
+
else:
|
|
1804
|
+
pellets = max(1, int(pellet_count))
|
|
1805
|
+
shot_count = pellets
|
|
1806
|
+
type_id = projectile_type_id_from_weapon_id(weapon_id)
|
|
1807
|
+
if type_id is None:
|
|
1808
|
+
return
|
|
1809
|
+
meta = _projectile_meta_for_type_id(type_id)
|
|
1810
|
+
jitter_step = _pellet_jitter_step(weapon_id)
|
|
1811
|
+
for _ in range(pellets):
|
|
1812
|
+
angle = shot_angle
|
|
1813
|
+
if pellets > 1:
|
|
1814
|
+
angle += float(int(state.rng.rand()) % 200 - 100) * jitter_step
|
|
1815
|
+
proj_id = state.projectiles.spawn(
|
|
1816
|
+
pos_x=muzzle_x,
|
|
1817
|
+
pos_y=muzzle_y,
|
|
1818
|
+
angle=angle,
|
|
1819
|
+
type_id=type_id,
|
|
1820
|
+
owner_id=owner_id,
|
|
1821
|
+
base_damage=meta,
|
|
1822
|
+
)
|
|
1823
|
+
# Shotgun variants randomize speed_scale per pellet (rand%100 * 0.01 + 1.0).
|
|
1824
|
+
if pellets > 1 and weapon_id in (WeaponId.SHOTGUN, WeaponId.SAWED_OFF_SHOTGUN, WeaponId.JACKHAMMER):
|
|
1825
|
+
state.projectiles.entries[int(proj_id)].speed_scale = 1.0 + float(int(state.rng.rand()) % 100) * 0.01
|
|
1826
|
+
|
|
1827
|
+
if 0 <= int(player.index) < len(state.shots_fired):
|
|
1828
|
+
state.shots_fired[int(player.index)] += int(shot_count)
|
|
1829
|
+
if 0 <= weapon_id < WEAPON_COUNT_SIZE:
|
|
1830
|
+
state.weapon_shots_fired[int(player.index)][weapon_id] += int(shot_count)
|
|
1831
|
+
|
|
1832
|
+
if not perk_active(player, PerkId.SHARPSHOOTER):
|
|
1833
|
+
player.spread_heat = min(0.48, max(0.0, player.spread_heat + spread_inc))
|
|
1834
|
+
|
|
1835
|
+
muzzle_inc = float(weapon.spread_heat_inc) if weapon.spread_heat_inc is not None else 0.0
|
|
1836
|
+
if is_fire_bullets and pellet_count == 1 and fire_bullets_weapon is not None and fire_bullets_weapon.spread_heat_inc is not None:
|
|
1837
|
+
muzzle_inc = float(fire_bullets_weapon.spread_heat_inc)
|
|
1838
|
+
player.muzzle_flash_alpha = min(1.0, player.muzzle_flash_alpha)
|
|
1839
|
+
player.muzzle_flash_alpha = min(1.0, player.muzzle_flash_alpha + muzzle_inc)
|
|
1840
|
+
player.muzzle_flash_alpha = min(0.8, player.muzzle_flash_alpha)
|
|
1841
|
+
|
|
1842
|
+
player.shot_seq += 1
|
|
1843
|
+
if (not firing_during_reload) and state.bonuses.reflex_boost <= 0.0 and not is_fire_bullets:
|
|
1844
|
+
player.ammo = max(0.0, float(player.ammo) - float(ammo_cost))
|
|
1845
|
+
if (not firing_during_reload) and player.ammo <= 0.0 and player.reload_timer <= 0.0:
|
|
1846
|
+
player_start_reload(player, state)
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def player_update(player: PlayerState, input_state: PlayerInput, dt: float, state: GameplayState, *, world_size: float = 1024.0) -> None:
|
|
1850
|
+
"""Port of `player_update` (0x004136b0) for the rewrite runtime."""
|
|
1851
|
+
|
|
1852
|
+
if dt <= 0.0:
|
|
1853
|
+
return
|
|
1854
|
+
|
|
1855
|
+
prev_x = player.pos_x
|
|
1856
|
+
prev_y = player.pos_y
|
|
1857
|
+
|
|
1858
|
+
if player.health <= 0.0:
|
|
1859
|
+
player.death_timer -= dt * 20.0
|
|
1860
|
+
return
|
|
1861
|
+
|
|
1862
|
+
player.muzzle_flash_alpha = max(0.0, player.muzzle_flash_alpha - dt * 2.0)
|
|
1863
|
+
cooldown_decay = dt * (1.5 if state.bonuses.weapon_power_up > 0.0 else 1.0)
|
|
1864
|
+
player.shot_cooldown = max(0.0, player.shot_cooldown - cooldown_decay)
|
|
1865
|
+
|
|
1866
|
+
if perk_active(player, PerkId.SHARPSHOOTER):
|
|
1867
|
+
player.spread_heat = 0.02
|
|
1868
|
+
else:
|
|
1869
|
+
player.spread_heat = max(0.01, player.spread_heat - dt * 0.4)
|
|
1870
|
+
|
|
1871
|
+
player.shield_timer = max(0.0, player.shield_timer - dt)
|
|
1872
|
+
player.fire_bullets_timer = max(0.0, player.fire_bullets_timer - dt)
|
|
1873
|
+
player.speed_bonus_timer = max(0.0, player.speed_bonus_timer - dt)
|
|
1874
|
+
if player.aux_timer > 0.0:
|
|
1875
|
+
aux_decay = 1.4 if player.aux_timer >= 1.0 else 0.5
|
|
1876
|
+
player.aux_timer = max(0.0, player.aux_timer - dt * aux_decay)
|
|
1877
|
+
|
|
1878
|
+
# Aim: compute direction from (player -> aim point).
|
|
1879
|
+
player.aim_x = float(input_state.aim_x)
|
|
1880
|
+
player.aim_y = float(input_state.aim_y)
|
|
1881
|
+
aim_dx = player.aim_x - player.pos_x
|
|
1882
|
+
aim_dy = player.aim_y - player.pos_y
|
|
1883
|
+
aim_dir_x, aim_dir_y = _normalize(aim_dx, aim_dy)
|
|
1884
|
+
if aim_dir_x != 0.0 or aim_dir_y != 0.0:
|
|
1885
|
+
player.aim_dir_x = aim_dir_x
|
|
1886
|
+
player.aim_dir_y = aim_dir_y
|
|
1887
|
+
player.aim_heading = math.atan2(aim_dir_y, aim_dir_x) + math.pi / 2.0
|
|
1888
|
+
|
|
1889
|
+
# Movement.
|
|
1890
|
+
raw_move_x = float(input_state.move_x)
|
|
1891
|
+
raw_move_y = float(input_state.move_y)
|
|
1892
|
+
raw_mag = math.hypot(raw_move_x, raw_move_y)
|
|
1893
|
+
moving_input = raw_mag > 0.2
|
|
1894
|
+
|
|
1895
|
+
if moving_input:
|
|
1896
|
+
inv = 1.0 / raw_mag if raw_mag > 1e-9 else 0.0
|
|
1897
|
+
move_x = raw_move_x * inv
|
|
1898
|
+
move_y = raw_move_y * inv
|
|
1899
|
+
player.heading = math.atan2(move_y, move_x) + math.pi / 2.0
|
|
1900
|
+
if perk_active(player, PerkId.LONG_DISTANCE_RUNNER):
|
|
1901
|
+
if player.move_speed < 2.0:
|
|
1902
|
+
player.move_speed = float(player.move_speed + dt * 4.0)
|
|
1903
|
+
player.move_speed = float(player.move_speed + dt)
|
|
1904
|
+
if player.move_speed > 2.8:
|
|
1905
|
+
player.move_speed = 2.8
|
|
1906
|
+
else:
|
|
1907
|
+
player.move_speed = float(player.move_speed + dt * 5.0)
|
|
1908
|
+
if player.move_speed > 2.0:
|
|
1909
|
+
player.move_speed = 2.0
|
|
1910
|
+
else:
|
|
1911
|
+
player.move_speed = float(player.move_speed - dt * 15.0)
|
|
1912
|
+
if player.move_speed < 0.0:
|
|
1913
|
+
player.move_speed = 0.0
|
|
1914
|
+
move_x = math.cos(player.heading - math.pi / 2.0)
|
|
1915
|
+
move_y = math.sin(player.heading - math.pi / 2.0)
|
|
1916
|
+
|
|
1917
|
+
if player.weapon_id == WeaponId.MEAN_MINIGUN and player.move_speed > 0.8:
|
|
1918
|
+
player.move_speed = 0.8
|
|
1919
|
+
|
|
1920
|
+
speed_multiplier = float(player.speed_multiplier)
|
|
1921
|
+
if player.speed_bonus_timer > 0.0:
|
|
1922
|
+
speed_multiplier += 1.0
|
|
1923
|
+
|
|
1924
|
+
speed = player.move_speed * speed_multiplier * 25.0
|
|
1925
|
+
if moving_input:
|
|
1926
|
+
speed *= min(1.0, raw_mag)
|
|
1927
|
+
if perk_active(player, PerkId.ALTERNATE_WEAPON):
|
|
1928
|
+
speed *= 0.8
|
|
1929
|
+
|
|
1930
|
+
player.pos_x = _clamp(player.pos_x + move_x * speed * dt, 0.0, float(world_size))
|
|
1931
|
+
player.pos_y = _clamp(player.pos_y + move_y * speed * dt, 0.0, float(world_size))
|
|
1932
|
+
|
|
1933
|
+
player.move_phase += dt * player.move_speed * 19.0
|
|
1934
|
+
|
|
1935
|
+
stationary = abs(player.pos_x - prev_x) <= 1e-9 and abs(player.pos_y - prev_y) <= 1e-9
|
|
1936
|
+
reload_scale = 1.0
|
|
1937
|
+
if stationary and perk_active(player, PerkId.STATIONARY_RELOADER):
|
|
1938
|
+
reload_scale = 3.0
|
|
1939
|
+
|
|
1940
|
+
if stationary and perk_active(player, PerkId.MAN_BOMB):
|
|
1941
|
+
_perk_update_man_bomb(player, dt, state)
|
|
1942
|
+
else:
|
|
1943
|
+
player.man_bomb_timer = 0.0
|
|
1944
|
+
|
|
1945
|
+
if stationary and perk_active(player, PerkId.LIVING_FORTRESS):
|
|
1946
|
+
player.living_fortress_timer = min(30.0, player.living_fortress_timer + dt)
|
|
1947
|
+
else:
|
|
1948
|
+
player.living_fortress_timer = 0.0
|
|
1949
|
+
|
|
1950
|
+
if perk_active(player, PerkId.FIRE_CAUGH):
|
|
1951
|
+
_perk_update_fire_cough(player, dt, state)
|
|
1952
|
+
else:
|
|
1953
|
+
player.fire_cough_timer = 0.0
|
|
1954
|
+
|
|
1955
|
+
if perk_active(player, PerkId.HOT_TEMPERED):
|
|
1956
|
+
_perk_update_hot_tempered(player, dt, state)
|
|
1957
|
+
else:
|
|
1958
|
+
player.hot_tempered_timer = 0.0
|
|
1959
|
+
|
|
1960
|
+
# Reload + reload perks.
|
|
1961
|
+
if perk_active(player, PerkId.ANXIOUS_LOADER) and input_state.fire_pressed and player.reload_timer > 0.0:
|
|
1962
|
+
player.reload_timer = max(0.0, player.reload_timer - 0.05)
|
|
1963
|
+
|
|
1964
|
+
if player.reload_timer > 0.0:
|
|
1965
|
+
if (
|
|
1966
|
+
perk_active(player, PerkId.ANGRY_RELOADER)
|
|
1967
|
+
and player.reload_timer_max > 0.5
|
|
1968
|
+
and (player.reload_timer_max * 0.5) < player.reload_timer
|
|
1969
|
+
):
|
|
1970
|
+
half = player.reload_timer_max * 0.5
|
|
1971
|
+
next_timer = player.reload_timer - reload_scale * dt
|
|
1972
|
+
player.reload_timer = next_timer
|
|
1973
|
+
if next_timer <= half:
|
|
1974
|
+
count = 7 + int(player.reload_timer_max * 4.0)
|
|
1975
|
+
state.bonus_spawn_guard = True
|
|
1976
|
+
_spawn_projectile_ring(
|
|
1977
|
+
state,
|
|
1978
|
+
player,
|
|
1979
|
+
count=count,
|
|
1980
|
+
angle_offset=0.1,
|
|
1981
|
+
type_id=ProjectileTypeId.PLASMA_MINIGUN,
|
|
1982
|
+
owner_id=_owner_id_for_player(player.index),
|
|
1983
|
+
)
|
|
1984
|
+
state.bonus_spawn_guard = False
|
|
1985
|
+
state.sfx_queue.append("sfx_explosion_small")
|
|
1986
|
+
else:
|
|
1987
|
+
player.reload_timer -= reload_scale * dt
|
|
1988
|
+
|
|
1989
|
+
if player.reload_timer < 0.0:
|
|
1990
|
+
player.reload_timer = 0.0
|
|
1991
|
+
|
|
1992
|
+
if player.reload_active and player.reload_timer <= 0.0 and player.reload_timer_max > 0.0:
|
|
1993
|
+
player.ammo = float(player.clip_size)
|
|
1994
|
+
player.reload_active = False
|
|
1995
|
+
player.reload_timer_max = 0.0
|
|
1996
|
+
|
|
1997
|
+
if input_state.reload_pressed:
|
|
1998
|
+
if perk_active(player, PerkId.ALTERNATE_WEAPON) and player_swap_alt_weapon(player):
|
|
1999
|
+
weapon = _weapon_entry(player.weapon_id)
|
|
2000
|
+
if weapon is not None and weapon.reload_sound is not None:
|
|
2001
|
+
from .weapon_sfx import resolve_weapon_sfx_ref
|
|
2002
|
+
|
|
2003
|
+
key = resolve_weapon_sfx_ref(weapon.reload_sound)
|
|
2004
|
+
if key is not None:
|
|
2005
|
+
state.sfx_queue.append(key)
|
|
2006
|
+
player.shot_cooldown = float(player.shot_cooldown) + 0.1
|
|
2007
|
+
elif player.reload_timer == 0.0:
|
|
2008
|
+
player_start_reload(player, state)
|
|
2009
|
+
|
|
2010
|
+
player_fire_weapon(player, input_state, dt, state)
|
|
2011
|
+
|
|
2012
|
+
while player.move_phase > 14.0:
|
|
2013
|
+
player.move_phase -= 14.0
|
|
2014
|
+
while player.move_phase < 0.0:
|
|
2015
|
+
player.move_phase += 14.0
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def bonus_apply(
|
|
2019
|
+
state: GameplayState,
|
|
2020
|
+
player: PlayerState,
|
|
2021
|
+
bonus_id: BonusId,
|
|
2022
|
+
*,
|
|
2023
|
+
amount: int | None = None,
|
|
2024
|
+
origin: _HasPos | None = None,
|
|
2025
|
+
creatures: list[Damageable] | None = None,
|
|
2026
|
+
players: list[PlayerState] | None = None,
|
|
2027
|
+
apply_creature_damage: CreatureDamageApplier | None = None,
|
|
2028
|
+
detail_preset: int = 5,
|
|
2029
|
+
) -> None:
|
|
2030
|
+
"""Apply a bonus to player + global timers (subset of `bonus_apply`)."""
|
|
2031
|
+
|
|
2032
|
+
meta = BONUS_BY_ID.get(int(bonus_id))
|
|
2033
|
+
if meta is None:
|
|
2034
|
+
return
|
|
2035
|
+
if amount is None:
|
|
2036
|
+
amount = int(meta.default_amount or 0)
|
|
2037
|
+
|
|
2038
|
+
if bonus_id == BonusId.POINTS:
|
|
2039
|
+
award_experience(state, player, int(amount))
|
|
2040
|
+
return
|
|
2041
|
+
|
|
2042
|
+
economist_multiplier = 1.0 + 0.5 * float(perk_count_get(player, PerkId.BONUS_ECONOMIST))
|
|
2043
|
+
|
|
2044
|
+
icon_id = int(meta.icon_id) if meta.icon_id is not None else -1
|
|
2045
|
+
label = meta.name
|
|
2046
|
+
|
|
2047
|
+
def _register_global(timer_key: str) -> None:
|
|
2048
|
+
state.bonus_hud.register(
|
|
2049
|
+
bonus_id,
|
|
2050
|
+
label=label,
|
|
2051
|
+
icon_id=icon_id,
|
|
2052
|
+
timer_ref=_TimerRef("global", timer_key),
|
|
2053
|
+
)
|
|
2054
|
+
|
|
2055
|
+
def _register_player(timer_key: str) -> None:
|
|
2056
|
+
if players is not None and len(players) > 1:
|
|
2057
|
+
state.bonus_hud.register(
|
|
2058
|
+
bonus_id,
|
|
2059
|
+
label=label,
|
|
2060
|
+
icon_id=icon_id,
|
|
2061
|
+
timer_ref=_TimerRef("player", timer_key, player_index=0),
|
|
2062
|
+
timer_ref_alt=_TimerRef("player", timer_key, player_index=1),
|
|
2063
|
+
)
|
|
2064
|
+
else:
|
|
2065
|
+
state.bonus_hud.register(
|
|
2066
|
+
bonus_id,
|
|
2067
|
+
label=label,
|
|
2068
|
+
icon_id=icon_id,
|
|
2069
|
+
timer_ref=_TimerRef("player", timer_key, player_index=int(player.index)),
|
|
2070
|
+
)
|
|
2071
|
+
|
|
2072
|
+
if bonus_id == BonusId.ENERGIZER:
|
|
2073
|
+
old = float(state.bonuses.energizer)
|
|
2074
|
+
if old <= 0.0:
|
|
2075
|
+
_register_global("energizer")
|
|
2076
|
+
state.bonuses.energizer = float(old + float(amount) * economist_multiplier)
|
|
2077
|
+
return
|
|
2078
|
+
|
|
2079
|
+
if bonus_id == BonusId.WEAPON_POWER_UP:
|
|
2080
|
+
old = float(state.bonuses.weapon_power_up)
|
|
2081
|
+
if old <= 0.0:
|
|
2082
|
+
_register_global("weapon_power_up")
|
|
2083
|
+
state.bonuses.weapon_power_up = float(old + float(amount) * economist_multiplier)
|
|
2084
|
+
player.weapon_reset_latch = 0
|
|
2085
|
+
player.shot_cooldown = 0.0
|
|
2086
|
+
player.reload_active = False
|
|
2087
|
+
player.reload_timer = 0.0
|
|
2088
|
+
player.reload_timer_max = 0.0
|
|
2089
|
+
player.ammo = float(player.clip_size)
|
|
2090
|
+
return
|
|
2091
|
+
|
|
2092
|
+
if bonus_id == BonusId.DOUBLE_EXPERIENCE:
|
|
2093
|
+
old = float(state.bonuses.double_experience)
|
|
2094
|
+
if old <= 0.0:
|
|
2095
|
+
_register_global("double_experience")
|
|
2096
|
+
state.bonuses.double_experience = float(old + float(amount) * economist_multiplier)
|
|
2097
|
+
return
|
|
2098
|
+
|
|
2099
|
+
if bonus_id == BonusId.REFLEX_BOOST:
|
|
2100
|
+
old = float(state.bonuses.reflex_boost)
|
|
2101
|
+
if old <= 0.0:
|
|
2102
|
+
_register_global("reflex_boost")
|
|
2103
|
+
state.bonuses.reflex_boost = float(old + float(amount) * economist_multiplier)
|
|
2104
|
+
|
|
2105
|
+
targets = players if players is not None else [player]
|
|
2106
|
+
for target in targets:
|
|
2107
|
+
target.ammo = float(target.clip_size)
|
|
2108
|
+
target.reload_active = False
|
|
2109
|
+
target.reload_timer = 0.0
|
|
2110
|
+
target.reload_timer_max = 0.0
|
|
2111
|
+
return
|
|
2112
|
+
|
|
2113
|
+
if bonus_id == BonusId.FREEZE:
|
|
2114
|
+
old = float(state.bonuses.freeze)
|
|
2115
|
+
if old <= 0.0:
|
|
2116
|
+
_register_global("freeze")
|
|
2117
|
+
state.bonuses.freeze = float(old + float(amount) * economist_multiplier)
|
|
2118
|
+
if creatures:
|
|
2119
|
+
rand = state.rng.rand
|
|
2120
|
+
for creature in creatures:
|
|
2121
|
+
active = getattr(creature, "active", True)
|
|
2122
|
+
if not bool(active):
|
|
2123
|
+
continue
|
|
2124
|
+
if float(getattr(creature, "hp", 0.0)) > 0.0:
|
|
2125
|
+
continue
|
|
2126
|
+
pos_x = float(getattr(creature, "x", 0.0))
|
|
2127
|
+
pos_y = float(getattr(creature, "y", 0.0))
|
|
2128
|
+
for _ in range(8):
|
|
2129
|
+
angle = float(int(rand()) % 0x264) * 0.01
|
|
2130
|
+
state.effects.spawn_freeze_shard(
|
|
2131
|
+
pos_x=pos_x,
|
|
2132
|
+
pos_y=pos_y,
|
|
2133
|
+
angle=angle,
|
|
2134
|
+
rand=rand,
|
|
2135
|
+
detail_preset=int(detail_preset),
|
|
2136
|
+
)
|
|
2137
|
+
angle = float(int(rand()) % 0x264) * 0.01
|
|
2138
|
+
state.effects.spawn_freeze_shatter(
|
|
2139
|
+
pos_x=pos_x,
|
|
2140
|
+
pos_y=pos_y,
|
|
2141
|
+
angle=angle,
|
|
2142
|
+
rand=rand,
|
|
2143
|
+
detail_preset=int(detail_preset),
|
|
2144
|
+
)
|
|
2145
|
+
if hasattr(creature, "active"):
|
|
2146
|
+
setattr(creature, "active", False)
|
|
2147
|
+
state.sfx_queue.append("sfx_shockwave")
|
|
2148
|
+
return
|
|
2149
|
+
|
|
2150
|
+
if bonus_id == BonusId.SHIELD:
|
|
2151
|
+
should_register = float(player.shield_timer) <= 0.0
|
|
2152
|
+
if players is not None and len(players) > 1:
|
|
2153
|
+
should_register = float(players[0].shield_timer) <= 0.0 and float(players[1].shield_timer) <= 0.0
|
|
2154
|
+
if should_register:
|
|
2155
|
+
_register_player("shield_timer")
|
|
2156
|
+
player.shield_timer = float(player.shield_timer + float(amount) * economist_multiplier)
|
|
2157
|
+
return
|
|
2158
|
+
|
|
2159
|
+
if bonus_id == BonusId.SPEED:
|
|
2160
|
+
should_register = float(player.speed_bonus_timer) <= 0.0
|
|
2161
|
+
if players is not None and len(players) > 1:
|
|
2162
|
+
should_register = float(players[0].speed_bonus_timer) <= 0.0 and float(players[1].speed_bonus_timer) <= 0.0
|
|
2163
|
+
if should_register:
|
|
2164
|
+
_register_player("speed_bonus_timer")
|
|
2165
|
+
player.speed_bonus_timer = float(player.speed_bonus_timer + float(amount) * economist_multiplier)
|
|
2166
|
+
return
|
|
2167
|
+
|
|
2168
|
+
if bonus_id == BonusId.FIRE_BULLETS:
|
|
2169
|
+
should_register = float(player.fire_bullets_timer) <= 0.0
|
|
2170
|
+
if players is not None and len(players) > 1:
|
|
2171
|
+
should_register = float(players[0].fire_bullets_timer) <= 0.0 and float(players[1].fire_bullets_timer) <= 0.0
|
|
2172
|
+
if should_register:
|
|
2173
|
+
_register_player("fire_bullets_timer")
|
|
2174
|
+
player.fire_bullets_timer = float(player.fire_bullets_timer + float(amount) * economist_multiplier)
|
|
2175
|
+
player.weapon_reset_latch = 0
|
|
2176
|
+
player.shot_cooldown = 0.0
|
|
2177
|
+
player.reload_active = False
|
|
2178
|
+
player.reload_timer = 0.0
|
|
2179
|
+
player.reload_timer_max = 0.0
|
|
2180
|
+
player.ammo = float(player.clip_size)
|
|
2181
|
+
return
|
|
2182
|
+
|
|
2183
|
+
if bonus_id == BonusId.SHOCK_CHAIN:
|
|
2184
|
+
if creatures:
|
|
2185
|
+
origin_pos = origin or player
|
|
2186
|
+
best_idx: int | None = None
|
|
2187
|
+
best_dist = 0.0
|
|
2188
|
+
for idx, creature in enumerate(creatures):
|
|
2189
|
+
if creature.hp <= 0.0:
|
|
2190
|
+
continue
|
|
2191
|
+
d = _distance_sq(float(origin_pos.pos_x), float(origin_pos.pos_y), creature.x, creature.y)
|
|
2192
|
+
if best_idx is None or d < best_dist:
|
|
2193
|
+
best_idx = idx
|
|
2194
|
+
best_dist = d
|
|
2195
|
+
if best_idx is not None:
|
|
2196
|
+
target = creatures[best_idx]
|
|
2197
|
+
dx = target.x - float(origin_pos.pos_x)
|
|
2198
|
+
dy = target.y - float(origin_pos.pos_y)
|
|
2199
|
+
angle = math.atan2(dy, dx) + math.pi / 2.0
|
|
2200
|
+
owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
|
|
2201
|
+
|
|
2202
|
+
state.bonus_spawn_guard = True
|
|
2203
|
+
state.shock_chain_links_left = 0x20
|
|
2204
|
+
state.shock_chain_projectile_id = state.projectiles.spawn(
|
|
2205
|
+
pos_x=float(origin_pos.pos_x),
|
|
2206
|
+
pos_y=float(origin_pos.pos_y),
|
|
2207
|
+
angle=angle,
|
|
2208
|
+
type_id=int(ProjectileTypeId.ION_RIFLE),
|
|
2209
|
+
owner_id=int(owner_id),
|
|
2210
|
+
base_damage=_projectile_meta_for_type_id(int(ProjectileTypeId.ION_RIFLE)),
|
|
2211
|
+
)
|
|
2212
|
+
state.bonus_spawn_guard = False
|
|
2213
|
+
return
|
|
2214
|
+
|
|
2215
|
+
if bonus_id == BonusId.WEAPON:
|
|
2216
|
+
weapon_id = int(amount)
|
|
2217
|
+
if perk_active(player, PerkId.ALTERNATE_WEAPON) and player.alt_weapon_id is None:
|
|
2218
|
+
player.alt_weapon_id = int(player.weapon_id)
|
|
2219
|
+
player.alt_clip_size = int(player.clip_size)
|
|
2220
|
+
player.alt_ammo = float(player.ammo)
|
|
2221
|
+
player.alt_reload_active = bool(player.reload_active)
|
|
2222
|
+
player.alt_reload_timer = float(player.reload_timer)
|
|
2223
|
+
player.alt_shot_cooldown = float(player.shot_cooldown)
|
|
2224
|
+
player.alt_reload_timer_max = float(player.reload_timer_max)
|
|
2225
|
+
weapon_assign_player(player, weapon_id, state=state)
|
|
2226
|
+
return
|
|
2227
|
+
|
|
2228
|
+
if bonus_id == BonusId.FIREBLAST:
|
|
2229
|
+
origin_pos = origin or player
|
|
2230
|
+
owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
|
|
2231
|
+
state.bonus_spawn_guard = True
|
|
2232
|
+
_spawn_projectile_ring(
|
|
2233
|
+
state,
|
|
2234
|
+
origin_pos,
|
|
2235
|
+
count=16,
|
|
2236
|
+
angle_offset=0.0,
|
|
2237
|
+
type_id=ProjectileTypeId.PLASMA_RIFLE,
|
|
2238
|
+
owner_id=int(owner_id),
|
|
2239
|
+
)
|
|
2240
|
+
state.bonus_spawn_guard = False
|
|
2241
|
+
state.sfx_queue.append("sfx_explosion_medium")
|
|
2242
|
+
return
|
|
2243
|
+
|
|
2244
|
+
if bonus_id == BonusId.NUKE:
|
|
2245
|
+
# `bonus_apply` (crimsonland.exe @ 0x00409890) starts screen shake via:
|
|
2246
|
+
# camera_shake_pulses = 0x14;
|
|
2247
|
+
# camera_shake_timer = 0.2f;
|
|
2248
|
+
state.camera_shake_pulses = 0x14
|
|
2249
|
+
state.camera_shake_timer = 0.2
|
|
2250
|
+
|
|
2251
|
+
origin_pos = origin or player
|
|
2252
|
+
ox = float(origin_pos.pos_x)
|
|
2253
|
+
oy = float(origin_pos.pos_y)
|
|
2254
|
+
rand = state.rng.rand
|
|
2255
|
+
|
|
2256
|
+
bullet_count = int(rand()) & 3
|
|
2257
|
+
bullet_count += 4
|
|
2258
|
+
assault_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.ASSAULT_RIFLE))
|
|
2259
|
+
for _ in range(bullet_count):
|
|
2260
|
+
angle = float(int(rand()) % 0x274) * 0.01
|
|
2261
|
+
proj_id = state.projectiles.spawn(
|
|
2262
|
+
pos_x=ox,
|
|
2263
|
+
pos_y=oy,
|
|
2264
|
+
angle=float(angle),
|
|
2265
|
+
type_id=int(ProjectileTypeId.ASSAULT_RIFLE),
|
|
2266
|
+
owner_id=-100,
|
|
2267
|
+
base_damage=assault_meta,
|
|
2268
|
+
)
|
|
2269
|
+
if proj_id != -1:
|
|
2270
|
+
speed_scale = float(int(rand()) % 0x32) * 0.01 + 0.5
|
|
2271
|
+
state.projectiles.entries[proj_id].speed_scale *= float(speed_scale)
|
|
2272
|
+
|
|
2273
|
+
minigun_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.MEAN_MINIGUN))
|
|
2274
|
+
for _ in range(2):
|
|
2275
|
+
angle = float(int(rand()) % 0x274) * 0.01
|
|
2276
|
+
state.projectiles.spawn(
|
|
2277
|
+
pos_x=ox,
|
|
2278
|
+
pos_y=oy,
|
|
2279
|
+
angle=float(angle),
|
|
2280
|
+
type_id=int(ProjectileTypeId.MEAN_MINIGUN),
|
|
2281
|
+
owner_id=-100,
|
|
2282
|
+
base_damage=minigun_meta,
|
|
2283
|
+
)
|
|
2284
|
+
|
|
2285
|
+
state.effects.spawn_explosion_burst(
|
|
2286
|
+
pos_x=ox,
|
|
2287
|
+
pos_y=oy,
|
|
2288
|
+
scale=1.0,
|
|
2289
|
+
rand=rand,
|
|
2290
|
+
detail_preset=int(detail_preset),
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
if creatures:
|
|
2294
|
+
prev_guard = bool(state.bonus_spawn_guard)
|
|
2295
|
+
state.bonus_spawn_guard = True
|
|
2296
|
+
for idx, creature in enumerate(creatures):
|
|
2297
|
+
if creature.hp <= 0.0:
|
|
2298
|
+
continue
|
|
2299
|
+
dx = float(creature.x) - ox
|
|
2300
|
+
dy = float(creature.y) - oy
|
|
2301
|
+
if abs(dx) > 256.0 or abs(dy) > 256.0:
|
|
2302
|
+
continue
|
|
2303
|
+
dist = math.hypot(dx, dy)
|
|
2304
|
+
if dist < 256.0:
|
|
2305
|
+
damage = (256.0 - dist) * 5.0
|
|
2306
|
+
if apply_creature_damage is not None:
|
|
2307
|
+
apply_creature_damage(
|
|
2308
|
+
int(idx),
|
|
2309
|
+
float(damage),
|
|
2310
|
+
3,
|
|
2311
|
+
0.0,
|
|
2312
|
+
0.0,
|
|
2313
|
+
_owner_id_for_player(player.index),
|
|
2314
|
+
)
|
|
2315
|
+
else:
|
|
2316
|
+
creature.hp -= float(damage)
|
|
2317
|
+
state.bonus_spawn_guard = prev_guard
|
|
2318
|
+
state.sfx_queue.append("sfx_explosion_large")
|
|
2319
|
+
state.sfx_queue.append("sfx_shockwave")
|
|
2320
|
+
return
|
|
2321
|
+
|
|
2322
|
+
# Bonus types not modeled yet.
|
|
2323
|
+
return
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
def bonus_hud_update(state: GameplayState, players: list[PlayerState]) -> None:
|
|
2327
|
+
"""Refresh HUD slots based on current timer values."""
|
|
2328
|
+
|
|
2329
|
+
def _timer_value(ref: _TimerRef | None) -> float:
|
|
2330
|
+
if ref is None:
|
|
2331
|
+
return 0.0
|
|
2332
|
+
if ref.kind == "global":
|
|
2333
|
+
return float(getattr(state.bonuses, ref.key, 0.0) or 0.0)
|
|
2334
|
+
if ref.kind == "player":
|
|
2335
|
+
idx = ref.player_index
|
|
2336
|
+
if idx is None or not (0 <= idx < len(players)):
|
|
2337
|
+
return 0.0
|
|
2338
|
+
return float(getattr(players[idx], ref.key, 0.0) or 0.0)
|
|
2339
|
+
return 0.0
|
|
2340
|
+
|
|
2341
|
+
for slot in state.bonus_hud.slots:
|
|
2342
|
+
if not slot.active:
|
|
2343
|
+
continue
|
|
2344
|
+
timer = _timer_value(slot.timer_ref)
|
|
2345
|
+
if slot.timer_ref_alt is not None:
|
|
2346
|
+
timer = max(timer, _timer_value(slot.timer_ref_alt))
|
|
2347
|
+
if timer <= 0.0:
|
|
2348
|
+
slot.active = False
|
|
2349
|
+
slot.timer_ref = None
|
|
2350
|
+
slot.timer_ref_alt = None
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
def bonus_telekinetic_update(
|
|
2354
|
+
state: GameplayState,
|
|
2355
|
+
players: list[PlayerState],
|
|
2356
|
+
dt: float,
|
|
2357
|
+
*,
|
|
2358
|
+
creatures: list[Damageable] | None = None,
|
|
2359
|
+
apply_creature_damage: CreatureDamageApplier | None = None,
|
|
2360
|
+
detail_preset: int = 5,
|
|
2361
|
+
) -> list[BonusPickupEvent]:
|
|
2362
|
+
"""Allow Telekinetic perk owners to pick up bonuses by aiming at them."""
|
|
2363
|
+
|
|
2364
|
+
if dt <= 0.0:
|
|
2365
|
+
return []
|
|
2366
|
+
|
|
2367
|
+
pickups: list[BonusPickupEvent] = []
|
|
2368
|
+
dt_ms = float(dt) * 1000.0
|
|
2369
|
+
|
|
2370
|
+
for player in players:
|
|
2371
|
+
if player.health <= 0.0:
|
|
2372
|
+
player.bonus_aim_hover_index = -1
|
|
2373
|
+
player.bonus_aim_hover_timer_ms = 0.0
|
|
2374
|
+
continue
|
|
2375
|
+
|
|
2376
|
+
hovered = bonus_find_aim_hover_entry(player, state.bonus_pool)
|
|
2377
|
+
if hovered is None:
|
|
2378
|
+
player.bonus_aim_hover_index = -1
|
|
2379
|
+
player.bonus_aim_hover_timer_ms = 0.0
|
|
2380
|
+
continue
|
|
2381
|
+
|
|
2382
|
+
idx, entry = hovered
|
|
2383
|
+
if idx != int(player.bonus_aim_hover_index):
|
|
2384
|
+
player.bonus_aim_hover_index = int(idx)
|
|
2385
|
+
player.bonus_aim_hover_timer_ms = dt_ms
|
|
2386
|
+
else:
|
|
2387
|
+
player.bonus_aim_hover_timer_ms += dt_ms
|
|
2388
|
+
|
|
2389
|
+
if player.bonus_aim_hover_timer_ms <= BONUS_TELEKINETIC_PICKUP_MS:
|
|
2390
|
+
continue
|
|
2391
|
+
if not perk_active(player, PerkId.TELEKINETIC):
|
|
2392
|
+
continue
|
|
2393
|
+
if entry.picked or entry.bonus_id == 0:
|
|
2394
|
+
continue
|
|
2395
|
+
|
|
2396
|
+
bonus_apply(
|
|
2397
|
+
state,
|
|
2398
|
+
player,
|
|
2399
|
+
BonusId(int(entry.bonus_id)),
|
|
2400
|
+
amount=int(entry.amount),
|
|
2401
|
+
origin=player,
|
|
2402
|
+
creatures=creatures,
|
|
2403
|
+
players=players,
|
|
2404
|
+
apply_creature_damage=apply_creature_damage,
|
|
2405
|
+
detail_preset=int(detail_preset),
|
|
2406
|
+
)
|
|
2407
|
+
entry.picked = True
|
|
2408
|
+
entry.time_left = BONUS_PICKUP_LINGER
|
|
2409
|
+
pickups.append(
|
|
2410
|
+
BonusPickupEvent(
|
|
2411
|
+
player_index=int(player.index),
|
|
2412
|
+
bonus_id=int(entry.bonus_id),
|
|
2413
|
+
amount=int(entry.amount),
|
|
2414
|
+
pos_x=float(entry.pos_x),
|
|
2415
|
+
pos_y=float(entry.pos_y),
|
|
2416
|
+
)
|
|
2417
|
+
)
|
|
2418
|
+
|
|
2419
|
+
# Match the exe: after a telekinetic pickup, reset the hover accumulator.
|
|
2420
|
+
player.bonus_aim_hover_index = -1
|
|
2421
|
+
player.bonus_aim_hover_timer_ms = 0.0
|
|
2422
|
+
|
|
2423
|
+
return pickups
|
|
2424
|
+
|
|
2425
|
+
|
|
2426
|
+
def bonus_update(
|
|
2427
|
+
state: GameplayState,
|
|
2428
|
+
players: list[PlayerState],
|
|
2429
|
+
dt: float,
|
|
2430
|
+
*,
|
|
2431
|
+
creatures: list[Damageable] | None = None,
|
|
2432
|
+
update_hud: bool = True,
|
|
2433
|
+
apply_creature_damage: CreatureDamageApplier | None = None,
|
|
2434
|
+
detail_preset: int = 5,
|
|
2435
|
+
) -> list[BonusPickupEvent]:
|
|
2436
|
+
"""Advance world bonuses and global timers (subset of `bonus_update`)."""
|
|
2437
|
+
|
|
2438
|
+
pickups = bonus_telekinetic_update(
|
|
2439
|
+
state,
|
|
2440
|
+
players,
|
|
2441
|
+
dt,
|
|
2442
|
+
creatures=creatures,
|
|
2443
|
+
apply_creature_damage=apply_creature_damage,
|
|
2444
|
+
detail_preset=int(detail_preset),
|
|
2445
|
+
)
|
|
2446
|
+
pickups.extend(
|
|
2447
|
+
state.bonus_pool.update(
|
|
2448
|
+
dt,
|
|
2449
|
+
state=state,
|
|
2450
|
+
players=players,
|
|
2451
|
+
creatures=creatures,
|
|
2452
|
+
apply_creature_damage=apply_creature_damage,
|
|
2453
|
+
detail_preset=int(detail_preset),
|
|
2454
|
+
)
|
|
2455
|
+
)
|
|
2456
|
+
|
|
2457
|
+
if dt > 0.0:
|
|
2458
|
+
state.bonuses.weapon_power_up = max(0.0, state.bonuses.weapon_power_up - dt)
|
|
2459
|
+
state.bonuses.reflex_boost = max(0.0, state.bonuses.reflex_boost - dt)
|
|
2460
|
+
state.bonuses.energizer = max(0.0, state.bonuses.energizer - dt)
|
|
2461
|
+
state.bonuses.double_experience = max(0.0, state.bonuses.double_experience - dt)
|
|
2462
|
+
state.bonuses.freeze = max(0.0, state.bonuses.freeze - dt)
|
|
2463
|
+
|
|
2464
|
+
if update_hud:
|
|
2465
|
+
bonus_hud_update(state, players)
|
|
2466
|
+
|
|
2467
|
+
return pickups
|