crimsonland 0.1.0.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crimson/__init__.py +24 -0
- crimson/assets_fetch.py +60 -0
- crimson/atlas.py +92 -0
- crimson/audio_router.py +153 -0
- crimson/bonuses.py +167 -0
- crimson/camera.py +75 -0
- crimson/cli.py +377 -0
- crimson/creatures/__init__.py +8 -0
- crimson/creatures/ai.py +186 -0
- crimson/creatures/anim.py +173 -0
- crimson/creatures/damage.py +103 -0
- crimson/creatures/runtime.py +1019 -0
- crimson/creatures/spawn.py +2871 -0
- crimson/debug.py +7 -0
- crimson/demo.py +1360 -0
- crimson/demo_trial.py +140 -0
- crimson/effects.py +1086 -0
- crimson/effects_atlas.py +73 -0
- crimson/frontend/__init__.py +1 -0
- crimson/frontend/assets.py +43 -0
- crimson/frontend/boot.py +424 -0
- crimson/frontend/menu.py +700 -0
- crimson/frontend/panels/__init__.py +1 -0
- crimson/frontend/panels/base.py +410 -0
- crimson/frontend/panels/controls.py +132 -0
- crimson/frontend/panels/mods.py +128 -0
- crimson/frontend/panels/options.py +409 -0
- crimson/frontend/panels/play_game.py +627 -0
- crimson/frontend/panels/stats.py +351 -0
- crimson/frontend/transitions.py +31 -0
- crimson/game.py +2533 -0
- crimson/game_modes.py +15 -0
- crimson/game_world.py +663 -0
- crimson/gameplay.py +2450 -0
- crimson/input_codes.py +176 -0
- crimson/modes/__init__.py +1 -0
- crimson/modes/base_gameplay_mode.py +219 -0
- crimson/modes/quest_mode.py +502 -0
- crimson/modes/rush_mode.py +300 -0
- crimson/modes/survival_mode.py +792 -0
- crimson/modes/tutorial_mode.py +648 -0
- crimson/modes/typo_mode.py +472 -0
- crimson/paths.py +23 -0
- crimson/perks.py +828 -0
- crimson/persistence/__init__.py +1 -0
- crimson/persistence/highscores.py +385 -0
- crimson/persistence/save_status.py +245 -0
- crimson/player_damage.py +77 -0
- crimson/projectiles.py +1039 -0
- crimson/quests/__init__.py +18 -0
- crimson/quests/helpers.py +147 -0
- crimson/quests/registry.py +49 -0
- crimson/quests/results.py +164 -0
- crimson/quests/runtime.py +91 -0
- crimson/quests/tier1.py +620 -0
- crimson/quests/tier2.py +652 -0
- crimson/quests/tier3.py +579 -0
- crimson/quests/tier4.py +721 -0
- crimson/quests/tier5.py +886 -0
- crimson/quests/timeline.py +115 -0
- crimson/quests/types.py +70 -0
- crimson/render/__init__.py +1 -0
- crimson/render/terrain_fx.py +88 -0
- crimson/render/world_renderer.py +1338 -0
- crimson/sim/__init__.py +1 -0
- crimson/sim/world_defs.py +56 -0
- crimson/sim/world_state.py +421 -0
- crimson/terrain_assets.py +19 -0
- crimson/tutorial/__init__.py +12 -0
- crimson/tutorial/timeline.py +291 -0
- crimson/typo/__init__.py +2 -0
- crimson/typo/names.py +233 -0
- crimson/typo/player.py +43 -0
- crimson/typo/spawns.py +73 -0
- crimson/typo/typing.py +52 -0
- crimson/ui/__init__.py +3 -0
- crimson/ui/cursor.py +95 -0
- crimson/ui/demo_trial_overlay.py +235 -0
- crimson/ui/game_over.py +660 -0
- crimson/ui/hud.py +601 -0
- crimson/ui/perk_menu.py +388 -0
- crimson/views/__init__.py +40 -0
- crimson/views/aim_debug.py +276 -0
- crimson/views/animations.py +274 -0
- crimson/views/arsenal_debug.py +414 -0
- crimson/views/bonuses.py +201 -0
- crimson/views/camera_debug.py +359 -0
- crimson/views/camera_shake.py +229 -0
- crimson/views/corpse_stamp_debug.py +324 -0
- crimson/views/decals_debug.py +739 -0
- crimson/views/empty.py +19 -0
- crimson/views/fonts.py +114 -0
- crimson/views/game_over.py +117 -0
- crimson/views/ground.py +259 -0
- crimson/views/lighting_debug.py +1166 -0
- crimson/views/particles.py +293 -0
- crimson/views/perk_menu_debug.py +430 -0
- crimson/views/perks.py +398 -0
- crimson/views/player.py +433 -0
- crimson/views/player_sprite_debug.py +314 -0
- crimson/views/projectile_fx.py +608 -0
- crimson/views/projectile_render_debug.py +407 -0
- crimson/views/projectiles.py +221 -0
- crimson/views/quest_title_overlay.py +108 -0
- crimson/views/registry.py +34 -0
- crimson/views/rush.py +16 -0
- crimson/views/small_font_debug.py +204 -0
- crimson/views/spawn_plan.py +363 -0
- crimson/views/sprites.py +214 -0
- crimson/views/survival.py +15 -0
- crimson/views/terrain.py +132 -0
- crimson/views/ui.py +123 -0
- crimson/views/wicons.py +166 -0
- crimson/weapon_sfx.py +63 -0
- crimson/weapons.py +860 -0
- crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
- crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
- crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
- crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
- grim/__init__.py +20 -0
- grim/app.py +92 -0
- grim/assets.py +231 -0
- grim/audio.py +106 -0
- grim/config.py +294 -0
- grim/console.py +737 -0
- grim/fonts/__init__.py +7 -0
- grim/fonts/grim_mono.py +111 -0
- grim/fonts/small.py +120 -0
- grim/input.py +44 -0
- grim/jaz.py +103 -0
- grim/math.py +17 -0
- grim/music.py +403 -0
- grim/paq.py +76 -0
- grim/rand.py +37 -0
- grim/sfx.py +276 -0
- grim/sfx_map.py +103 -0
- grim/terrain_render.py +840 -0
- grim/view.py +16 -0
crimson/demo.py
ADDED
|
@@ -0,0 +1,1360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import random
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
import pyray as rl
|
|
9
|
+
|
|
10
|
+
from grim.audio import AudioState, update_audio
|
|
11
|
+
from grim.assets import PaqTextureCache, load_paq_entries
|
|
12
|
+
from grim.config import CrimsonConfig
|
|
13
|
+
from grim.fonts.grim_mono import GrimMonoFont, draw_grim_mono_text, load_grim_mono_font
|
|
14
|
+
from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
|
|
15
|
+
|
|
16
|
+
from grim.rand import Crand
|
|
17
|
+
from .game_world import GameWorld
|
|
18
|
+
from .gameplay import PlayerInput, PlayerState, weapon_assign_player
|
|
19
|
+
from .ui.cursor import draw_menu_cursor
|
|
20
|
+
from .weapons import WEAPON_TABLE, WeaponId, projectile_type_id_from_weapon_id, weapon_entry_for_projectile_type_id
|
|
21
|
+
|
|
22
|
+
WORLD_SIZE = 1024.0
|
|
23
|
+
DEMO_VARIANT_COUNT = 6
|
|
24
|
+
|
|
25
|
+
_DEMO_UPSELL_MESSAGES: tuple[str, ...] = (
|
|
26
|
+
"Want more Levels?",
|
|
27
|
+
"Want more Weapons?",
|
|
28
|
+
"Want more Perks?",
|
|
29
|
+
"Want unlimited Play time?",
|
|
30
|
+
"Want to post your high scores?",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DEMO_PURCHASE_URL = "http://buy.crimsonland.com"
|
|
34
|
+
DEMO_PURCHASE_SCREEN_LIMIT_MS = 16_000
|
|
35
|
+
DEMO_PURCHASE_INTERSTITIAL_LIMIT_MS = 10_000
|
|
36
|
+
|
|
37
|
+
_DEMO_PURCHASE_TITLE = "Upgrade to the full version of Crimsonland Today!"
|
|
38
|
+
_DEMO_PURCHASE_FEATURES_TITLE = "Full version features:"
|
|
39
|
+
_DEMO_PURCHASE_FEATURE_LINES: tuple[tuple[str, float], ...] = (
|
|
40
|
+
("-Unlimited Play Time in three thrilling Game Modes!", 22.0),
|
|
41
|
+
("-The varied weapon arsenal consisting of over 20 unique", 17.0),
|
|
42
|
+
(" weapons that allow you to deal death with plasma, lead,", 17.0),
|
|
43
|
+
(" fire and electricity!", 22.0),
|
|
44
|
+
("-Over 40 game altering Perks!", 22.0),
|
|
45
|
+
("-40 insane Levels that give you", 18.0),
|
|
46
|
+
(" hours of intense and fun gameplay!", 22.0),
|
|
47
|
+
("-The ability to post your high scores online!", 44.0),
|
|
48
|
+
)
|
|
49
|
+
_DEMO_PURCHASE_FOOTER = "Purchasing the game is very easy and secure."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DemoState(Protocol):
|
|
53
|
+
assets_dir: Path
|
|
54
|
+
rng: random.Random
|
|
55
|
+
config: CrimsonConfig
|
|
56
|
+
texture_cache: PaqTextureCache | None
|
|
57
|
+
audio: AudioState | None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _weapon_name(weapon_id: int) -> str:
|
|
61
|
+
for weapon in WEAPON_TABLE:
|
|
62
|
+
if weapon.weapon_id == weapon_id:
|
|
63
|
+
return weapon.name or f"weapon_{weapon_id}"
|
|
64
|
+
return f"weapon_{weapon_id}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clamp(value: float, lo: float, hi: float) -> float:
|
|
68
|
+
if value < lo:
|
|
69
|
+
return lo
|
|
70
|
+
if value > hi:
|
|
71
|
+
return hi
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
|
|
76
|
+
dx = x1 - x0
|
|
77
|
+
dy = y1 - y0
|
|
78
|
+
return dx * dx + dy * dy
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalize(dx: float, dy: float) -> tuple[float, float, float]:
|
|
82
|
+
d = math.hypot(dx, dy)
|
|
83
|
+
if d <= 1e-6:
|
|
84
|
+
return 0.0, 0.0, 0.0
|
|
85
|
+
inv = 1.0 / d
|
|
86
|
+
return dx * inv, dy * inv, d
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DemoView:
|
|
90
|
+
"""Attract-mode demo scaffold.
|
|
91
|
+
|
|
92
|
+
Modeled after the classic demo helpers in crimsonland.exe:
|
|
93
|
+
- demo_setup_variant_0 @ 0x00402ED0
|
|
94
|
+
- demo_setup_variant_1 @ 0x004030F0
|
|
95
|
+
- demo_setup_variant_2 @ 0x00402FE0
|
|
96
|
+
- demo_setup_variant_3 @ 0x00403250
|
|
97
|
+
- demo_mode_start @ 0x00403390
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, state: DemoState) -> None:
|
|
101
|
+
self._state = state
|
|
102
|
+
self._world = GameWorld(
|
|
103
|
+
assets_dir=state.assets_dir,
|
|
104
|
+
world_size=WORLD_SIZE,
|
|
105
|
+
demo_mode_active=True,
|
|
106
|
+
hardcore=bool(int(state.config.data.get("hardcore_flag", 0) or 0)),
|
|
107
|
+
difficulty_level=0,
|
|
108
|
+
texture_cache=state.texture_cache,
|
|
109
|
+
config=state.config,
|
|
110
|
+
audio=state.audio,
|
|
111
|
+
audio_rng=state.rng,
|
|
112
|
+
)
|
|
113
|
+
self._crand = Crand(0)
|
|
114
|
+
self._demo_targets: list[int | None] = []
|
|
115
|
+
self._variant_index = 0
|
|
116
|
+
self._demo_variant_index = 0
|
|
117
|
+
self._quest_spawn_timeline_ms = 0
|
|
118
|
+
self._demo_time_limit_ms = 0
|
|
119
|
+
self._finished = False
|
|
120
|
+
self._upsell_message_index = 0
|
|
121
|
+
self._upsell_pulse_ms = 0
|
|
122
|
+
self._upsell_font: GrimMonoFont | None = None
|
|
123
|
+
self._small_font: SmallFontData | None = None
|
|
124
|
+
self._purchase_active = False
|
|
125
|
+
self._purchase_url_opened = False
|
|
126
|
+
self._spawn_rng = Crand(0)
|
|
127
|
+
|
|
128
|
+
def open(self) -> None:
|
|
129
|
+
self._finished = False
|
|
130
|
+
self._upsell_message_index = 0
|
|
131
|
+
self._upsell_pulse_ms = 0
|
|
132
|
+
self._purchase_active = False
|
|
133
|
+
self._purchase_url_opened = False
|
|
134
|
+
self._variant_index = 0
|
|
135
|
+
self._demo_variant_index = 0
|
|
136
|
+
self._quest_spawn_timeline_ms = 0
|
|
137
|
+
self._demo_time_limit_ms = 0
|
|
138
|
+
self._crand.srand(self._state.rng.getrandbits(32))
|
|
139
|
+
self._world.open()
|
|
140
|
+
self._demo_mode_start()
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
self._world.close()
|
|
144
|
+
if self._upsell_font is not None:
|
|
145
|
+
rl.unload_texture(self._upsell_font.texture)
|
|
146
|
+
self._upsell_font = None
|
|
147
|
+
if self._small_font is not None:
|
|
148
|
+
rl.unload_texture(self._small_font.texture)
|
|
149
|
+
self._small_font = None
|
|
150
|
+
|
|
151
|
+
def is_finished(self) -> bool:
|
|
152
|
+
return self._finished
|
|
153
|
+
|
|
154
|
+
def update(self, dt: float) -> None:
|
|
155
|
+
if self._state.audio is not None:
|
|
156
|
+
update_audio(self._state.audio, dt)
|
|
157
|
+
if self._finished:
|
|
158
|
+
return
|
|
159
|
+
frame_dt = min(dt, 0.1)
|
|
160
|
+
frame_dt_ms = int(frame_dt * 1000.0)
|
|
161
|
+
if frame_dt_ms <= 0:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
if (not self._purchase_active) and getattr(self._state, "demo_enabled", False) and self._purchase_screen_triggered():
|
|
165
|
+
self._begin_purchase_screen(DEMO_PURCHASE_SCREEN_LIMIT_MS, reset_timeline=False)
|
|
166
|
+
|
|
167
|
+
if self._purchase_active:
|
|
168
|
+
self._upsell_pulse_ms += frame_dt_ms
|
|
169
|
+
self._update_purchase_screen()
|
|
170
|
+
self._quest_spawn_timeline_ms += frame_dt_ms
|
|
171
|
+
if self._quest_spawn_timeline_ms > self._demo_time_limit_ms:
|
|
172
|
+
# demo_purchase_screen_update restarts the demo once the purchase screen
|
|
173
|
+
# timer exceeds demo_time_limit_ms.
|
|
174
|
+
self._demo_mode_start()
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if self._skip_triggered():
|
|
178
|
+
self._finished = True
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
self._quest_spawn_timeline_ms += frame_dt_ms
|
|
182
|
+
self._update_world(frame_dt)
|
|
183
|
+
if self._quest_spawn_timeline_ms > self._demo_time_limit_ms:
|
|
184
|
+
self._demo_mode_start()
|
|
185
|
+
|
|
186
|
+
def draw(self) -> None:
|
|
187
|
+
if self._purchase_active:
|
|
188
|
+
self._draw_purchase_screen()
|
|
189
|
+
return
|
|
190
|
+
self._world.draw()
|
|
191
|
+
self._draw_overlay()
|
|
192
|
+
|
|
193
|
+
def _skip_triggered(self) -> bool:
|
|
194
|
+
if rl.get_key_pressed() != 0:
|
|
195
|
+
return True
|
|
196
|
+
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
|
197
|
+
return True
|
|
198
|
+
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_RIGHT):
|
|
199
|
+
return True
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def _purchase_screen_triggered(self) -> bool:
|
|
203
|
+
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
|
204
|
+
return True
|
|
205
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
206
|
+
return True
|
|
207
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
|
|
208
|
+
return True
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
def _begin_purchase_screen(self, limit_ms: int, *, reset_timeline: bool) -> None:
|
|
212
|
+
self._purchase_active = True
|
|
213
|
+
if reset_timeline:
|
|
214
|
+
self._quest_spawn_timeline_ms = 0
|
|
215
|
+
self._demo_time_limit_ms = max(0, int(limit_ms))
|
|
216
|
+
self._purchase_url_opened = False
|
|
217
|
+
|
|
218
|
+
def _ensure_small_font(self) -> SmallFontData:
|
|
219
|
+
if self._small_font is not None:
|
|
220
|
+
return self._small_font
|
|
221
|
+
missing_assets: list[str] = []
|
|
222
|
+
self._small_font = load_small_font(self._state.assets_dir, missing_assets)
|
|
223
|
+
return self._small_font
|
|
224
|
+
|
|
225
|
+
def _purchase_var_28_2(self) -> float:
|
|
226
|
+
screen_w = int(self._state.config.screen_width)
|
|
227
|
+
if screen_w == 0x320: # 800
|
|
228
|
+
return 64.0
|
|
229
|
+
if screen_w == 0x400: # 1024
|
|
230
|
+
return 128.0
|
|
231
|
+
return 0.0
|
|
232
|
+
|
|
233
|
+
def _update_purchase_screen(self) -> None:
|
|
234
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
235
|
+
self._purchase_active = False
|
|
236
|
+
self._finished = True
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
small = self._ensure_small_font()
|
|
240
|
+
# ui_button_update uses the medium (145px wide) button sprite here (the per-button
|
|
241
|
+
# "small" flag at +0x14 is 0 for both purchase/maybe-later globals).
|
|
242
|
+
button_tex = self._ensure_cache().get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
|
|
243
|
+
|
|
244
|
+
if button_tex is None:
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
w = float(self._state.config.screen_width)
|
|
248
|
+
h = float(self._state.config.screen_height)
|
|
249
|
+
wide_shift = self._purchase_var_28_2()
|
|
250
|
+
button_x = w / 2.0 + 128.0
|
|
251
|
+
button_base_y = h / 2.0 + 102.0 + wide_shift * 0.3
|
|
252
|
+
purchase_y = button_base_y + 50.0
|
|
253
|
+
maybe_y = button_base_y + 90.0
|
|
254
|
+
|
|
255
|
+
purchase_rect = rl.Rectangle(button_x, purchase_y, float(button_tex.width), float(button_tex.height))
|
|
256
|
+
maybe_rect = rl.Rectangle(button_x, maybe_y, float(button_tex.width), float(button_tex.height))
|
|
257
|
+
|
|
258
|
+
mouse = rl.get_mouse_position()
|
|
259
|
+
if (
|
|
260
|
+
purchase_rect.x <= mouse.x <= purchase_rect.x + purchase_rect.width
|
|
261
|
+
and purchase_rect.y <= mouse.y <= purchase_rect.y + purchase_rect.height
|
|
262
|
+
and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
|
|
263
|
+
):
|
|
264
|
+
if not self._purchase_url_opened:
|
|
265
|
+
self._purchase_url_opened = True
|
|
266
|
+
try:
|
|
267
|
+
import webbrowser
|
|
268
|
+
|
|
269
|
+
webbrowser.open(DEMO_PURCHASE_URL)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
if hasattr(self._state, "quit_requested"):
|
|
273
|
+
self._state.quit_requested = True
|
|
274
|
+
|
|
275
|
+
if (
|
|
276
|
+
maybe_rect.x <= mouse.x <= maybe_rect.x + maybe_rect.width
|
|
277
|
+
and maybe_rect.y <= mouse.y <= maybe_rect.y + maybe_rect.height
|
|
278
|
+
and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
|
|
279
|
+
):
|
|
280
|
+
self._purchase_active = False
|
|
281
|
+
self._finished = True
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Keyboard activation for convenience; original uses UI mouse.
|
|
285
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
|
|
286
|
+
if not self._purchase_url_opened:
|
|
287
|
+
self._purchase_url_opened = True
|
|
288
|
+
try:
|
|
289
|
+
import webbrowser
|
|
290
|
+
|
|
291
|
+
webbrowser.open(DEMO_PURCHASE_URL)
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
if hasattr(self._state, "quit_requested"):
|
|
295
|
+
self._state.quit_requested = True
|
|
296
|
+
|
|
297
|
+
# Keep small referenced to avoid unused warnings if this method grows.
|
|
298
|
+
_ = small
|
|
299
|
+
|
|
300
|
+
def _draw_purchase_screen(self) -> None:
|
|
301
|
+
rl.clear_background(rl.BLACK)
|
|
302
|
+
|
|
303
|
+
logos = getattr(self._state, "logos", None)
|
|
304
|
+
if logos is None or logos.backplasma.texture is None:
|
|
305
|
+
return
|
|
306
|
+
backplasma = logos.backplasma.texture
|
|
307
|
+
|
|
308
|
+
pulse_phase = float(self._upsell_pulse_ms % 1000)
|
|
309
|
+
pulse = math.sin(pulse_phase * 6.2831855)
|
|
310
|
+
pulse = pulse * pulse
|
|
311
|
+
|
|
312
|
+
screen_w = float(self._state.config.screen_width)
|
|
313
|
+
screen_h = float(self._state.config.screen_height)
|
|
314
|
+
|
|
315
|
+
# demo_purchase_screen_update @ 0x0040b985:
|
|
316
|
+
# - full-screen quad
|
|
317
|
+
# - UV: 0..0.5 (top-left quarter of the backplasma atlas)
|
|
318
|
+
# - per-corner color slots, with a sin^2 pulse at bottom-right
|
|
319
|
+
|
|
320
|
+
def _to_u8(value: float) -> int:
|
|
321
|
+
return int(_clamp(value, 0.0, 1.0) * 255.0 + 0.5)
|
|
322
|
+
|
|
323
|
+
c0 = rl.Color(_to_u8(0.0), _to_u8(0.0), _to_u8(0.0), _to_u8(1.0))
|
|
324
|
+
c1 = rl.Color(_to_u8(0.0), _to_u8(0.0), _to_u8(0.3), _to_u8(1.0))
|
|
325
|
+
c2 = rl.Color(
|
|
326
|
+
_to_u8(0.0),
|
|
327
|
+
_to_u8(0.4),
|
|
328
|
+
_to_u8(pulse * 0.55),
|
|
329
|
+
_to_u8(pulse),
|
|
330
|
+
)
|
|
331
|
+
c3 = rl.Color(_to_u8(0.0), _to_u8(0.4), _to_u8(0.4), _to_u8(1.0))
|
|
332
|
+
|
|
333
|
+
rl.begin_blend_mode(rl.BLEND_ALPHA)
|
|
334
|
+
rl.rl_set_texture(backplasma.id)
|
|
335
|
+
rl.rl_begin(rl.RL_QUADS)
|
|
336
|
+
# TL
|
|
337
|
+
rl.rl_color4ub(c0.r, c0.g, c0.b, c0.a)
|
|
338
|
+
rl.rl_tex_coord2f(0.0, 0.0)
|
|
339
|
+
rl.rl_vertex2f(0.0, 0.0)
|
|
340
|
+
# TR
|
|
341
|
+
rl.rl_color4ub(c1.r, c1.g, c1.b, c1.a)
|
|
342
|
+
rl.rl_tex_coord2f(0.5, 0.0)
|
|
343
|
+
rl.rl_vertex2f(screen_w, 0.0)
|
|
344
|
+
# BR
|
|
345
|
+
rl.rl_color4ub(c2.r, c2.g, c2.b, c2.a)
|
|
346
|
+
rl.rl_tex_coord2f(0.5, 0.5)
|
|
347
|
+
rl.rl_vertex2f(screen_w, screen_h)
|
|
348
|
+
# BL
|
|
349
|
+
rl.rl_color4ub(c3.r, c3.g, c3.b, c3.a)
|
|
350
|
+
rl.rl_tex_coord2f(0.0, 0.5)
|
|
351
|
+
rl.rl_vertex2f(0.0, screen_h)
|
|
352
|
+
rl.rl_end()
|
|
353
|
+
rl.rl_set_texture(0)
|
|
354
|
+
rl.end_blend_mode()
|
|
355
|
+
|
|
356
|
+
wide_shift = self._purchase_var_28_2()
|
|
357
|
+
|
|
358
|
+
# Mockup and logo textures.
|
|
359
|
+
if logos.mockup.texture is not None:
|
|
360
|
+
mockup = logos.mockup.texture
|
|
361
|
+
x = screen_w / 2.0 - 128.0 + wide_shift
|
|
362
|
+
y = screen_h / 2.0 - 140.0
|
|
363
|
+
dst = rl.Rectangle(x, y, 512.0, 256.0)
|
|
364
|
+
src = rl.Rectangle(0.0, 0.0, float(mockup.width), float(mockup.height))
|
|
365
|
+
rl.draw_texture_pro(mockup, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
|
|
366
|
+
|
|
367
|
+
if logos.cl_logo.texture is not None:
|
|
368
|
+
cl_logo = logos.cl_logo.texture
|
|
369
|
+
x = screen_w / 2.0 - 256.0
|
|
370
|
+
y = screen_h / 2.0 - 200.0 - wide_shift * 0.4
|
|
371
|
+
dst = rl.Rectangle(x, y, 512.0, 64.0)
|
|
372
|
+
src = rl.Rectangle(0.0, 0.0, float(cl_logo.width), float(cl_logo.height))
|
|
373
|
+
rl.draw_texture_pro(cl_logo, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
|
|
374
|
+
|
|
375
|
+
small = self._ensure_small_font()
|
|
376
|
+
text_scale = 1.2
|
|
377
|
+
x_text = screen_w / 2.0 - 296.0 - wide_shift * 0.8
|
|
378
|
+
y = screen_h / 2.0 - 104.0
|
|
379
|
+
color = rl.Color(255, 255, 255, 255)
|
|
380
|
+
draw_small_text(small, _DEMO_PURCHASE_TITLE, x_text, y, text_scale, color)
|
|
381
|
+
y += 28.0
|
|
382
|
+
draw_small_text(small, _DEMO_PURCHASE_FEATURES_TITLE, x_text, y, text_scale, color)
|
|
383
|
+
|
|
384
|
+
underline_w = measure_small_text_width(small, _DEMO_PURCHASE_FEATURES_TITLE, text_scale)
|
|
385
|
+
rl.draw_rectangle_rec(rl.Rectangle(x_text, y + 15.0, underline_w, 2.0), rl.Color(255, 255, 255, 160))
|
|
386
|
+
|
|
387
|
+
y += 22.0
|
|
388
|
+
x_list = x_text + 8.0
|
|
389
|
+
for line, delta_y in _DEMO_PURCHASE_FEATURE_LINES:
|
|
390
|
+
draw_small_text(small, line, x_list, y, text_scale, color)
|
|
391
|
+
y += delta_y
|
|
392
|
+
draw_small_text(small, _DEMO_PURCHASE_FOOTER, x_text, y, text_scale, color)
|
|
393
|
+
|
|
394
|
+
# Buttons on the right.
|
|
395
|
+
cache = self._ensure_cache()
|
|
396
|
+
button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
|
|
397
|
+
if button_tex is None:
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
button_x = screen_w / 2.0 + 128.0
|
|
401
|
+
button_base_y = screen_h / 2.0 + 102.0 + wide_shift * 0.3
|
|
402
|
+
purchase_y = button_base_y + 50.0
|
|
403
|
+
maybe_y = button_base_y + 90.0
|
|
404
|
+
mouse = rl.get_mouse_position()
|
|
405
|
+
|
|
406
|
+
def draw_button(texture: rl.Texture2D, label: str, x: float, y0: float) -> None:
|
|
407
|
+
hovered = x <= mouse.x <= x + texture.width and y0 <= mouse.y <= y0 + texture.height
|
|
408
|
+
tint = rl.Color(255, 255, 255, 255) if hovered else rl.Color(220, 220, 220, 255)
|
|
409
|
+
rl.draw_texture(texture, int(x), int(y0), tint)
|
|
410
|
+
label_scale = 1.0
|
|
411
|
+
text_w = measure_small_text_width(small, label, label_scale)
|
|
412
|
+
text_x = x + float(texture.width) * 0.5 - text_w * 0.5 + 1.0
|
|
413
|
+
text_y = y0 + 10.0
|
|
414
|
+
alpha = 1.0 if hovered else 0.7
|
|
415
|
+
draw_small_text(small, label, text_x, text_y, label_scale, rl.Color(255, 255, 255, int(255 * alpha)))
|
|
416
|
+
|
|
417
|
+
draw_button(button_tex, "Purchase", button_x, purchase_y)
|
|
418
|
+
draw_button(button_tex, "Maybe later", button_x, maybe_y)
|
|
419
|
+
|
|
420
|
+
# Demo purchase screen uses menu-style cursor; draw it explicitly since the OS cursor is hidden.
|
|
421
|
+
particles = cache.get_or_load("particles", "game/particles.jaz").texture
|
|
422
|
+
cursor_tex = cache.get_or_load("ui_cursor", "ui/ui_cursor.jaz").texture
|
|
423
|
+
pulse_time = float(self._upsell_pulse_ms) * 0.001
|
|
424
|
+
draw_menu_cursor(particles, cursor_tex, x=float(mouse.x), y=float(mouse.y), pulse_time=pulse_time)
|
|
425
|
+
|
|
426
|
+
def _ensure_cache(self) -> PaqTextureCache:
|
|
427
|
+
cache = self._state.texture_cache
|
|
428
|
+
if cache is not None:
|
|
429
|
+
return cache
|
|
430
|
+
entries = load_paq_entries(self._state.assets_dir)
|
|
431
|
+
cache = PaqTextureCache(entries=entries, textures={})
|
|
432
|
+
self._state.texture_cache = cache
|
|
433
|
+
return cache
|
|
434
|
+
|
|
435
|
+
def _demo_mode_start(self) -> None:
|
|
436
|
+
index = self._demo_variant_index
|
|
437
|
+
self._demo_variant_index = (index + 1) % DEMO_VARIANT_COUNT
|
|
438
|
+
self._variant_index = index
|
|
439
|
+
self._quest_spawn_timeline_ms = 0
|
|
440
|
+
self._demo_time_limit_ms = 0
|
|
441
|
+
self._purchase_active = False
|
|
442
|
+
self._purchase_url_opened = False
|
|
443
|
+
self._spawn_rng.srand(self._state.rng.randrange(0, 0x1_0000_0000))
|
|
444
|
+
self._world.state.bonuses.weapon_power_up = 0.0
|
|
445
|
+
if index == 0:
|
|
446
|
+
self._apply_variant_ground(0)
|
|
447
|
+
self._setup_variant_0()
|
|
448
|
+
elif index == 1:
|
|
449
|
+
self._apply_variant_ground(1)
|
|
450
|
+
self._setup_variant_1()
|
|
451
|
+
elif index == 2:
|
|
452
|
+
self._apply_variant_ground(2)
|
|
453
|
+
self._setup_variant_2()
|
|
454
|
+
elif index == 3:
|
|
455
|
+
self._apply_variant_ground(3)
|
|
456
|
+
self._setup_variant_3()
|
|
457
|
+
elif index == 4:
|
|
458
|
+
self._apply_variant_ground(4)
|
|
459
|
+
self._setup_variant_0()
|
|
460
|
+
else:
|
|
461
|
+
# demo_purchase_interstitial_begin
|
|
462
|
+
self._begin_purchase_screen(DEMO_PURCHASE_INTERSTITIAL_LIMIT_MS, reset_timeline=True)
|
|
463
|
+
|
|
464
|
+
# demo_purchase_screen_update increments demo_upsell_message_index when the
|
|
465
|
+
# timeline resets (quest_spawn_timeline == 0) and the purchase screen is inactive.
|
|
466
|
+
if (not self._purchase_active) and _DEMO_UPSELL_MESSAGES:
|
|
467
|
+
self._upsell_message_index = (self._upsell_message_index + 1) % len(_DEMO_UPSELL_MESSAGES)
|
|
468
|
+
|
|
469
|
+
def _setup_world_players(self, specs: list[tuple[float, float, int]]) -> None:
|
|
470
|
+
seed = int(self._state.rng.getrandbits(32))
|
|
471
|
+
self._world.reset(seed=seed, player_count=len(specs))
|
|
472
|
+
for idx, (x, y, weapon_id) in enumerate(specs):
|
|
473
|
+
if idx >= len(self._world.players):
|
|
474
|
+
continue
|
|
475
|
+
player = self._world.players[idx]
|
|
476
|
+
player.pos_x = float(x)
|
|
477
|
+
player.pos_y = float(y)
|
|
478
|
+
weapon_assign_player(player, int(weapon_id))
|
|
479
|
+
self._demo_targets = [None] * len(self._world.players)
|
|
480
|
+
|
|
481
|
+
def _apply_variant_ground(self, index: int) -> None:
|
|
482
|
+
if index == 5:
|
|
483
|
+
return
|
|
484
|
+
terrain = {
|
|
485
|
+
0: (
|
|
486
|
+
"ter_q1_base",
|
|
487
|
+
"ter_q1_tex1",
|
|
488
|
+
"ter/ter_q1_base.jaz",
|
|
489
|
+
"ter/ter_q1_tex1.jaz",
|
|
490
|
+
),
|
|
491
|
+
1: (
|
|
492
|
+
"ter_q2_base",
|
|
493
|
+
"ter_q2_tex1",
|
|
494
|
+
"ter/ter_q2_base.jaz",
|
|
495
|
+
"ter/ter_q2_tex1.jaz",
|
|
496
|
+
),
|
|
497
|
+
2: (
|
|
498
|
+
"ter_q3_base",
|
|
499
|
+
"ter_q3_tex1",
|
|
500
|
+
"ter/ter_q3_base.jaz",
|
|
501
|
+
"ter/ter_q3_tex1.jaz",
|
|
502
|
+
),
|
|
503
|
+
3: (
|
|
504
|
+
"ter_q4_base",
|
|
505
|
+
"ter_q4_tex1",
|
|
506
|
+
"ter/ter_q4_base.jaz",
|
|
507
|
+
"ter/ter_q4_tex1.jaz",
|
|
508
|
+
),
|
|
509
|
+
4: (
|
|
510
|
+
"ter_q1_base",
|
|
511
|
+
"ter_q1_tex1",
|
|
512
|
+
"ter/ter_q1_base.jaz",
|
|
513
|
+
"ter/ter_q1_tex1.jaz",
|
|
514
|
+
),
|
|
515
|
+
}.get(
|
|
516
|
+
index,
|
|
517
|
+
(
|
|
518
|
+
"ter_q1_base",
|
|
519
|
+
"ter_q1_tex1",
|
|
520
|
+
"ter/ter_q1_base.jaz",
|
|
521
|
+
"ter/ter_q1_tex1.jaz",
|
|
522
|
+
),
|
|
523
|
+
)
|
|
524
|
+
base_key, overlay_key, base_path, overlay_path = terrain
|
|
525
|
+
self._world.set_terrain(
|
|
526
|
+
base_key=base_key,
|
|
527
|
+
overlay_key=overlay_key,
|
|
528
|
+
base_path=base_path,
|
|
529
|
+
overlay_path=overlay_path,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def _wrap_pos(self, x: float, y: float) -> tuple[float, float]:
|
|
533
|
+
return (x % WORLD_SIZE, y % WORLD_SIZE)
|
|
534
|
+
|
|
535
|
+
def _crand_mod(self, mod: int) -> int:
|
|
536
|
+
if mod <= 0:
|
|
537
|
+
return 0
|
|
538
|
+
return int(self._crand.rand() % mod)
|
|
539
|
+
|
|
540
|
+
def _spawn(self, spawn_id: int, x: float, y: float, *, heading: float = 0.0) -> None:
|
|
541
|
+
x, y = self._wrap_pos(x, y)
|
|
542
|
+
self._world.creatures.spawn_template(
|
|
543
|
+
int(spawn_id),
|
|
544
|
+
(x, y),
|
|
545
|
+
float(heading),
|
|
546
|
+
self._spawn_rng,
|
|
547
|
+
rand=self._spawn_rng.rand,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def _setup_variant_0(self) -> None:
|
|
551
|
+
self._demo_time_limit_ms = 4000
|
|
552
|
+
weapon_id = 12
|
|
553
|
+
self._setup_world_players(
|
|
554
|
+
[
|
|
555
|
+
(448.0, 384.0, weapon_id),
|
|
556
|
+
(546.0, 654.0, weapon_id),
|
|
557
|
+
]
|
|
558
|
+
)
|
|
559
|
+
y = 256
|
|
560
|
+
i = 0
|
|
561
|
+
while y < 1696:
|
|
562
|
+
col = i % 2
|
|
563
|
+
self._spawn(0x38, float((col + 2) * 64), float(y), heading=-100.0)
|
|
564
|
+
self._spawn(0x38, float(col * 64 + 798), float(y), heading=-100.0)
|
|
565
|
+
y += 80
|
|
566
|
+
i += 1
|
|
567
|
+
|
|
568
|
+
def _setup_variant_1(self) -> None:
|
|
569
|
+
self._demo_time_limit_ms = 5000
|
|
570
|
+
weapon_id = 6
|
|
571
|
+
self._setup_world_players(
|
|
572
|
+
[
|
|
573
|
+
(490.0, 448.0, weapon_id),
|
|
574
|
+
(480.0, 576.0, weapon_id),
|
|
575
|
+
]
|
|
576
|
+
)
|
|
577
|
+
self._world.state.bonuses.weapon_power_up = 15.0
|
|
578
|
+
for idx in range(20):
|
|
579
|
+
x = float(self._crand_mod(200) + 32)
|
|
580
|
+
y = float(self._crand_mod(899) + 64)
|
|
581
|
+
self._spawn(0x34, x, y, heading=-100.0)
|
|
582
|
+
if idx % 3 != 0:
|
|
583
|
+
x2 = float(self._crand_mod(30) + 32)
|
|
584
|
+
y2 = float(self._crand_mod(899) + 64)
|
|
585
|
+
self._spawn(0x35, x2, y2, heading=-100.0)
|
|
586
|
+
|
|
587
|
+
def _setup_variant_2(self) -> None:
|
|
588
|
+
self._demo_time_limit_ms = 5000
|
|
589
|
+
weapon_id = 22
|
|
590
|
+
self._setup_world_players([(512.0, 512.0, weapon_id)])
|
|
591
|
+
y = 128
|
|
592
|
+
i = 0
|
|
593
|
+
while y < 848:
|
|
594
|
+
col = i % 2
|
|
595
|
+
self._spawn(0x41, float(col * 64 + 32), float(y), heading=-100.0)
|
|
596
|
+
self._spawn(0x41, float((col + 2) * 64), float(y), heading=-100.0)
|
|
597
|
+
self._spawn(0x41, float(col * 64 - 64), float(y), heading=-100.0)
|
|
598
|
+
self._spawn(0x41, float((col + 12) * 64), float(y), heading=-100.0)
|
|
599
|
+
y += 60
|
|
600
|
+
i += 1
|
|
601
|
+
|
|
602
|
+
def _setup_variant_3(self) -> None:
|
|
603
|
+
self._demo_time_limit_ms = 4000
|
|
604
|
+
weapon_id = 19
|
|
605
|
+
self._setup_world_players([(512.0, 512.0, weapon_id)])
|
|
606
|
+
for idx in range(20):
|
|
607
|
+
x = float(self._crand_mod(200) + 32)
|
|
608
|
+
y = float(self._crand_mod(899) + 64)
|
|
609
|
+
self._spawn(0x24, x, y, heading=0.0)
|
|
610
|
+
if idx % 3 != 0:
|
|
611
|
+
x2 = float(self._crand_mod(30) + 32)
|
|
612
|
+
y2 = float(self._crand_mod(899) + 64)
|
|
613
|
+
self._spawn(0x25, x2, y2, heading=0.0)
|
|
614
|
+
|
|
615
|
+
def _world_params(self) -> tuple[float, float, float, float]:
|
|
616
|
+
out_w = float(rl.get_screen_width())
|
|
617
|
+
out_h = float(rl.get_screen_height())
|
|
618
|
+
screen_w = float(self._state.config.screen_width)
|
|
619
|
+
screen_h = float(self._state.config.screen_height)
|
|
620
|
+
if screen_w > WORLD_SIZE:
|
|
621
|
+
screen_w = WORLD_SIZE
|
|
622
|
+
if screen_h > WORLD_SIZE:
|
|
623
|
+
screen_h = WORLD_SIZE
|
|
624
|
+
|
|
625
|
+
cam_x = self._camera_x
|
|
626
|
+
cam_y = self._camera_y
|
|
627
|
+
min_x = screen_w - WORLD_SIZE
|
|
628
|
+
min_y = screen_h - WORLD_SIZE
|
|
629
|
+
if cam_x > -1.0:
|
|
630
|
+
cam_x = -1.0
|
|
631
|
+
if cam_y > -1.0:
|
|
632
|
+
cam_y = -1.0
|
|
633
|
+
if cam_x < min_x:
|
|
634
|
+
cam_x = min_x
|
|
635
|
+
if cam_y < min_y:
|
|
636
|
+
cam_y = min_y
|
|
637
|
+
|
|
638
|
+
scale_x = out_w / screen_w if screen_w > 0 else 1.0
|
|
639
|
+
scale_y = out_h / screen_h if screen_h > 0 else 1.0
|
|
640
|
+
return cam_x, cam_y, scale_x, scale_y
|
|
641
|
+
|
|
642
|
+
def _world_to_screen(self, x: float, y: float) -> tuple[float, float]:
|
|
643
|
+
cam_x, cam_y, scale_x, scale_y = self._world_params()
|
|
644
|
+
return (x + cam_x) * scale_x, (y + cam_y) * scale_y
|
|
645
|
+
|
|
646
|
+
def _select_frame(self, spawn_id: int, phase: float) -> tuple[int, bool]:
|
|
647
|
+
template = SPAWN_ID_TO_TEMPLATE.get(spawn_id)
|
|
648
|
+
if template is None or template.type_id is None:
|
|
649
|
+
return 0, False
|
|
650
|
+
info = _TYPE_ANIM.get(template.type_id)
|
|
651
|
+
if info is None:
|
|
652
|
+
return 0, False
|
|
653
|
+
flags = template.flags or CreatureFlags(0)
|
|
654
|
+
frame, mirror_applied, _ = creature_anim_select_frame(
|
|
655
|
+
phase,
|
|
656
|
+
base_frame=info.base,
|
|
657
|
+
mirror_long=info.mirror,
|
|
658
|
+
flags=flags,
|
|
659
|
+
)
|
|
660
|
+
return frame, mirror_applied
|
|
661
|
+
|
|
662
|
+
def _draw_fx(self) -> None:
|
|
663
|
+
projectiles = self._projectile_pool.iter_active()
|
|
664
|
+
secondary = self._secondary_projectile_pool.iter_active()
|
|
665
|
+
if not (projectiles or secondary or self._beams or self._explosions):
|
|
666
|
+
return
|
|
667
|
+
cam_x, cam_y, scale_x, scale_y = self._world_params()
|
|
668
|
+
del cam_x, cam_y
|
|
669
|
+
scale = (scale_x + scale_y) * 0.5
|
|
670
|
+
|
|
671
|
+
for proj in projectiles:
|
|
672
|
+
sx, sy = self._world_to_screen(proj.pos_x, proj.pos_y)
|
|
673
|
+
base_radius = {
|
|
674
|
+
0x05: 6.0, # gauss
|
|
675
|
+
0x0B: 10.0, # rocket launcher
|
|
676
|
+
0x15: 7.0, # ion minigun beam seed
|
|
677
|
+
}.get(proj.type_id, 5.0)
|
|
678
|
+
radius = max(1.0, base_radius * scale)
|
|
679
|
+
color = {
|
|
680
|
+
0x05: rl.Color(235, 235, 235, 255),
|
|
681
|
+
0x0B: rl.Color(255, 120, 80, 255),
|
|
682
|
+
0x15: rl.Color(120, 220, 255, 255),
|
|
683
|
+
}.get(proj.type_id, rl.Color(235, 235, 235, 255))
|
|
684
|
+
rl.draw_circle(int(sx), int(sy), radius, color)
|
|
685
|
+
|
|
686
|
+
for proj in secondary:
|
|
687
|
+
sx, sy = self._world_to_screen(proj.pos_x, proj.pos_y)
|
|
688
|
+
if proj.type_id == 4:
|
|
689
|
+
radius = max(1.0, 12.0 * scale)
|
|
690
|
+
rl.draw_circle(int(sx), int(sy), radius, rl.Color(200, 120, 255, 255))
|
|
691
|
+
continue
|
|
692
|
+
if proj.type_id == 3:
|
|
693
|
+
t = _clamp(proj.lifetime, 0.0, 1.0)
|
|
694
|
+
radius = proj.speed * t * 80.0
|
|
695
|
+
alpha = int((1.0 - t) * 180.0)
|
|
696
|
+
color = rl.Color(200, 120, 255, alpha)
|
|
697
|
+
rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
|
|
698
|
+
|
|
699
|
+
for beam in self._beams:
|
|
700
|
+
x0, y0 = self._world_to_screen(beam.x0, beam.y0)
|
|
701
|
+
x1, y1 = self._world_to_screen(beam.x1, beam.y1)
|
|
702
|
+
alpha = int(_clamp(beam.life / 0.08, 0.0, 1.0) * 255.0)
|
|
703
|
+
color = rl.Color(120, 220, 255, alpha)
|
|
704
|
+
rl.draw_line_ex(rl.Vector2(x0, y0), rl.Vector2(x1, y1), 2.0 * scale, color)
|
|
705
|
+
|
|
706
|
+
for fx in self._explosions:
|
|
707
|
+
t = fx.elapsed / fx.duration if fx.duration > 0 else 1.0
|
|
708
|
+
radius = fx.max_radius * _clamp(t, 0.0, 1.0)
|
|
709
|
+
sx, sy = self._world_to_screen(fx.x, fx.y)
|
|
710
|
+
alpha = int((1.0 - _clamp(t, 0.0, 1.0)) * 180.0)
|
|
711
|
+
color = rl.Color(255, 180, 100, alpha) if fx.kind == "rocket" else rl.Color(200, 120, 255, alpha)
|
|
712
|
+
rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
|
|
713
|
+
|
|
714
|
+
def _draw_entities(self) -> None:
|
|
715
|
+
cache = self._state.texture_cache
|
|
716
|
+
if cache is None:
|
|
717
|
+
return
|
|
718
|
+
cam_x, cam_y, scale_x, scale_y = self._world_params()
|
|
719
|
+
del cam_x, cam_y
|
|
720
|
+
|
|
721
|
+
player_tex = cache.get_or_load("trooper", "game/trooper.jaz").texture
|
|
722
|
+
if player_tex is not None:
|
|
723
|
+
for player in self._players:
|
|
724
|
+
self._draw_sprite(
|
|
725
|
+
player_tex,
|
|
726
|
+
CreatureTypeId.TROOPER,
|
|
727
|
+
CreatureFlags(0),
|
|
728
|
+
player.phase,
|
|
729
|
+
player.x,
|
|
730
|
+
player.y,
|
|
731
|
+
scale_x,
|
|
732
|
+
scale_y,
|
|
733
|
+
tint=rl.Color(240, 240, 255, 255),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
for creature in self._creatures:
|
|
737
|
+
type_id = creature.type_id
|
|
738
|
+
if type_id is None:
|
|
739
|
+
continue
|
|
740
|
+
asset = _TYPE_ASSET.get(type_id)
|
|
741
|
+
if asset is None:
|
|
742
|
+
continue
|
|
743
|
+
texture = cache.texture(asset)
|
|
744
|
+
if texture is None:
|
|
745
|
+
rel_path = f"game/{asset}.jaz"
|
|
746
|
+
texture = cache.get_or_load(asset, rel_path).texture
|
|
747
|
+
if texture is None:
|
|
748
|
+
continue
|
|
749
|
+
flags = creature.flags
|
|
750
|
+
|
|
751
|
+
def _to_u8(value: float) -> int:
|
|
752
|
+
return int(_clamp(value, 0.0, 1.0) * 255.0 + 0.5)
|
|
753
|
+
|
|
754
|
+
tint = rl.WHITE
|
|
755
|
+
if creature.tint is not None and any(v is not None for v in creature.tint):
|
|
756
|
+
tint_r, tint_g, tint_b, tint_a = resolve_tint(creature.tint)
|
|
757
|
+
tint = rl.Color(
|
|
758
|
+
_to_u8(tint_r),
|
|
759
|
+
_to_u8(tint_g),
|
|
760
|
+
_to_u8(tint_b),
|
|
761
|
+
_to_u8(tint_a),
|
|
762
|
+
)
|
|
763
|
+
self._draw_sprite(
|
|
764
|
+
texture,
|
|
765
|
+
type_id,
|
|
766
|
+
flags,
|
|
767
|
+
creature.anim_phase,
|
|
768
|
+
creature.x,
|
|
769
|
+
creature.y,
|
|
770
|
+
scale_x,
|
|
771
|
+
scale_y,
|
|
772
|
+
tint=tint,
|
|
773
|
+
size_scale=_clamp(creature.size / 64.0, 0.25, 2.0),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def _draw_sprite(
|
|
777
|
+
self,
|
|
778
|
+
texture: rl.Texture2D,
|
|
779
|
+
type_id: CreatureTypeId,
|
|
780
|
+
flags: CreatureFlags,
|
|
781
|
+
phase: float,
|
|
782
|
+
world_x: float,
|
|
783
|
+
world_y: float,
|
|
784
|
+
scale_x: float,
|
|
785
|
+
scale_y: float,
|
|
786
|
+
*,
|
|
787
|
+
tint: rl.Color,
|
|
788
|
+
size_scale: float = 1.0,
|
|
789
|
+
) -> None:
|
|
790
|
+
info = _TYPE_ANIM.get(type_id)
|
|
791
|
+
if info is None:
|
|
792
|
+
return
|
|
793
|
+
frame, _, _ = creature_anim_select_frame(
|
|
794
|
+
phase,
|
|
795
|
+
base_frame=info.base,
|
|
796
|
+
mirror_long=info.mirror,
|
|
797
|
+
flags=flags,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
grid = 8
|
|
801
|
+
cell = float(texture.width) / grid if grid > 0 else float(texture.width)
|
|
802
|
+
row = frame // grid
|
|
803
|
+
col = frame % grid
|
|
804
|
+
src = rl.Rectangle(float(col * cell), float(row * cell), float(cell), float(cell))
|
|
805
|
+
screen_x, screen_y = self._world_to_screen(world_x, world_y)
|
|
806
|
+
width = cell * scale_x * size_scale
|
|
807
|
+
height = cell * scale_y * size_scale
|
|
808
|
+
dst = rl.Rectangle(screen_x, screen_y, width, height)
|
|
809
|
+
origin = rl.Vector2(width * 0.5, height * 0.5)
|
|
810
|
+
rl.draw_texture_pro(texture, src, dst, origin, 0.0, tint)
|
|
811
|
+
|
|
812
|
+
def _draw_overlay(self) -> None:
|
|
813
|
+
if getattr(self._state, "demo_enabled", False):
|
|
814
|
+
self._draw_demo_upsell_overlay()
|
|
815
|
+
return
|
|
816
|
+
title = f"DEMO MODE ({self._variant_index + 1}/{DEMO_VARIANT_COUNT})"
|
|
817
|
+
hint = "Press any key / click to skip"
|
|
818
|
+
remaining = max(0.0, float(self._demo_time_limit_ms - self._quest_spawn_timeline_ms) / 1000.0)
|
|
819
|
+
weapons = ", ".join(f"P{p.index + 1}:{_weapon_name(p.weapon_id)}" for p in self._world.players)
|
|
820
|
+
detail = f"{weapons} — next in {remaining:0.1f}s"
|
|
821
|
+
rl.draw_text(title, 16, 12, 20, rl.Color(240, 240, 240, 255))
|
|
822
|
+
rl.draw_text(detail, 16, 36, 16, rl.Color(180, 180, 190, 255))
|
|
823
|
+
rl.draw_text(hint, 16, 56, 16, rl.Color(140, 140, 150, 255))
|
|
824
|
+
|
|
825
|
+
def _ensure_upsell_font(self) -> GrimMonoFont:
|
|
826
|
+
if self._upsell_font is not None:
|
|
827
|
+
return self._upsell_font
|
|
828
|
+
missing_assets: list[str] = []
|
|
829
|
+
self._upsell_font = load_grim_mono_font(self._state.assets_dir, missing_assets)
|
|
830
|
+
return self._upsell_font
|
|
831
|
+
|
|
832
|
+
def _draw_demo_upsell_overlay(self) -> None:
|
|
833
|
+
# Modeled after the shareware "Want more ..." overlay in demo_purchase_screen_update
|
|
834
|
+
# (crimsonland.exe 0x0040B740), but without the purchase screen.
|
|
835
|
+
if not _DEMO_UPSELL_MESSAGES:
|
|
836
|
+
return
|
|
837
|
+
|
|
838
|
+
font = self._ensure_upsell_font()
|
|
839
|
+
msg = _DEMO_UPSELL_MESSAGES[self._upsell_message_index]
|
|
840
|
+
|
|
841
|
+
timeline_ms = self._quest_spawn_timeline_ms
|
|
842
|
+
limit_ms = self._demo_time_limit_ms
|
|
843
|
+
var_2c = float(timeline_ms) * 0.016
|
|
844
|
+
|
|
845
|
+
alpha = 1.0
|
|
846
|
+
if var_2c < 20.0:
|
|
847
|
+
alpha = var_2c * 0.05
|
|
848
|
+
if timeline_ms > limit_ms - 500:
|
|
849
|
+
alpha = float(limit_ms - timeline_ms) * 0.002
|
|
850
|
+
alpha = _clamp(alpha, 0.0, 1.0)
|
|
851
|
+
|
|
852
|
+
scale = 0.8
|
|
853
|
+
text_w = float(len(msg)) * 12.8
|
|
854
|
+
|
|
855
|
+
text_x = 50.0
|
|
856
|
+
text_y = var_2c + 50.0
|
|
857
|
+
bg_x = 60.0
|
|
858
|
+
bg_y = text_y - 4.0
|
|
859
|
+
bar_x = 64.0
|
|
860
|
+
bar_y = var_2c + 72.0
|
|
861
|
+
|
|
862
|
+
bg_alpha = int(round(_clamp(alpha * 0.5, 0.0, 1.0) * 255.0))
|
|
863
|
+
bar_alpha = int(round(_clamp(alpha * 0.8, 0.0, 1.0) * 255.0))
|
|
864
|
+
txt_alpha = int(round(_clamp(alpha, 0.0, 1.0) * 255.0))
|
|
865
|
+
|
|
866
|
+
rl.draw_rectangle_rec(
|
|
867
|
+
rl.Rectangle(bg_x, bg_y, text_w + 12.0, 30.0),
|
|
868
|
+
rl.Color(0, 0, 0, bg_alpha),
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
progress = 0.0
|
|
872
|
+
if limit_ms > 0:
|
|
873
|
+
progress = _clamp(float(timeline_ms) / float(limit_ms), 0.0, 1.0)
|
|
874
|
+
rl.draw_rectangle_rec(
|
|
875
|
+
rl.Rectangle(bar_x, bar_y, text_w * progress, 3.0),
|
|
876
|
+
rl.Color(128, 26, 26, bar_alpha),
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
draw_grim_mono_text(font, msg, text_x, text_y, scale, rl.Color(255, 255, 255, txt_alpha))
|
|
880
|
+
|
|
881
|
+
def _update_world(self, dt: float) -> None:
|
|
882
|
+
if not self._world.players:
|
|
883
|
+
return
|
|
884
|
+
inputs = self._build_demo_inputs()
|
|
885
|
+
self._world.update(dt, inputs=inputs, auto_pick_perks=False, game_mode=0, perk_progression_enabled=False)
|
|
886
|
+
|
|
887
|
+
def _build_demo_inputs(self) -> list[PlayerInput]:
|
|
888
|
+
players = self._world.players
|
|
889
|
+
creatures = self._world.creatures.entries
|
|
890
|
+
if len(self._demo_targets) != len(players):
|
|
891
|
+
self._demo_targets = [None] * len(players)
|
|
892
|
+
center_x = float(self._world.world_size) * 0.5
|
|
893
|
+
center_y = float(self._world.world_size) * 0.5
|
|
894
|
+
|
|
895
|
+
inputs: list[PlayerInput] = []
|
|
896
|
+
for idx, player in enumerate(players):
|
|
897
|
+
target_idx = self._select_demo_target(idx, player, creatures)
|
|
898
|
+
aim_x = center_x
|
|
899
|
+
aim_y = center_y
|
|
900
|
+
target = None
|
|
901
|
+
if target_idx is not None and 0 <= target_idx < len(creatures):
|
|
902
|
+
candidate = creatures[target_idx]
|
|
903
|
+
if candidate.hp > 0.0:
|
|
904
|
+
target = candidate
|
|
905
|
+
aim_x = candidate.x
|
|
906
|
+
aim_y = candidate.y
|
|
907
|
+
|
|
908
|
+
move_x, move_y = 0.0, 0.0
|
|
909
|
+
to_cx = center_x - player.pos_x
|
|
910
|
+
to_cy = center_y - player.pos_y
|
|
911
|
+
nx, ny, d = _normalize(to_cx, to_cy)
|
|
912
|
+
if d > 120.0:
|
|
913
|
+
move_x += nx
|
|
914
|
+
move_y += ny
|
|
915
|
+
|
|
916
|
+
if target is not None:
|
|
917
|
+
rx = player.pos_x - target.x
|
|
918
|
+
ry = player.pos_y - target.y
|
|
919
|
+
rnx, rny, rd = _normalize(rx, ry)
|
|
920
|
+
if 0.0 < rd < 160.0:
|
|
921
|
+
strength = (160.0 - rd) / 160.0
|
|
922
|
+
move_x += rnx * (1.5 * strength)
|
|
923
|
+
move_y += rny * (1.5 * strength)
|
|
924
|
+
|
|
925
|
+
orbit_dir = -1.0 if (player.index % 2) else 1.0
|
|
926
|
+
ox, oy, _ = _normalize(-(player.pos_y - center_y), player.pos_x - center_x)
|
|
927
|
+
move_x += ox * 0.55 * orbit_dir
|
|
928
|
+
move_y += oy * 0.55 * orbit_dir
|
|
929
|
+
|
|
930
|
+
fire_down = target is not None
|
|
931
|
+
|
|
932
|
+
inputs.append(
|
|
933
|
+
PlayerInput(
|
|
934
|
+
move_x=move_x,
|
|
935
|
+
move_y=move_y,
|
|
936
|
+
aim_x=aim_x,
|
|
937
|
+
aim_y=aim_y,
|
|
938
|
+
fire_down=fire_down,
|
|
939
|
+
fire_pressed=fire_down,
|
|
940
|
+
reload_pressed=False,
|
|
941
|
+
)
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
return inputs
|
|
945
|
+
|
|
946
|
+
def _nearest_world_creature_index(self, x: float, y: float) -> int | None:
|
|
947
|
+
best_idx = None
|
|
948
|
+
best_dist = 0.0
|
|
949
|
+
for idx, creature in enumerate(self._world.creatures.entries):
|
|
950
|
+
if not (creature.active and creature.hp > 0.0):
|
|
951
|
+
continue
|
|
952
|
+
d = _distance_sq(x, y, creature.x, creature.y)
|
|
953
|
+
if best_idx is None or d < best_dist:
|
|
954
|
+
best_idx = idx
|
|
955
|
+
best_dist = d
|
|
956
|
+
return best_idx
|
|
957
|
+
|
|
958
|
+
def _select_demo_target(self, player_index: int, player: PlayerState, creatures: list) -> int | None:
|
|
959
|
+
candidate = self._nearest_world_creature_index(player.pos_x, player.pos_y)
|
|
960
|
+
current = self._demo_targets[player_index] if player_index < len(self._demo_targets) else None
|
|
961
|
+
if current is None:
|
|
962
|
+
self._demo_targets[player_index] = candidate
|
|
963
|
+
return candidate
|
|
964
|
+
if not (0 <= current < len(creatures)):
|
|
965
|
+
self._demo_targets[player_index] = candidate
|
|
966
|
+
return candidate
|
|
967
|
+
current_creature = creatures[current]
|
|
968
|
+
if current_creature.hp <= 0.0 or not current_creature.active:
|
|
969
|
+
self._demo_targets[player_index] = candidate
|
|
970
|
+
return candidate
|
|
971
|
+
if candidate is None or candidate == current:
|
|
972
|
+
return current
|
|
973
|
+
cand_creature = creatures[candidate]
|
|
974
|
+
if not cand_creature.active or cand_creature.hp <= 0.0:
|
|
975
|
+
return current
|
|
976
|
+
cur_d = math.hypot(current_creature.x - player.pos_x, current_creature.y - player.pos_y)
|
|
977
|
+
cand_d = math.hypot(cand_creature.x - player.pos_x, cand_creature.y - player.pos_y)
|
|
978
|
+
if cand_d + 64.0 < cur_d:
|
|
979
|
+
self._demo_targets[player_index] = candidate
|
|
980
|
+
return candidate
|
|
981
|
+
return current
|
|
982
|
+
|
|
983
|
+
def _update_sim(self, dt: float, dt_ms: int) -> None:
|
|
984
|
+
self._bonus_weapon_power_up_timer = max(0.0, self._bonus_weapon_power_up_timer - dt)
|
|
985
|
+
self._update_creatures(dt, dt_ms)
|
|
986
|
+
self._update_spawn_slots(dt)
|
|
987
|
+
self._update_projectiles(dt)
|
|
988
|
+
self._update_players(dt)
|
|
989
|
+
self._update_fx(dt)
|
|
990
|
+
self._update_camera(dt)
|
|
991
|
+
|
|
992
|
+
def _nearest_player_index(self, x: float, y: float) -> int | None:
|
|
993
|
+
best_idx = None
|
|
994
|
+
best_dist = 0.0
|
|
995
|
+
for idx, player in enumerate(self._players):
|
|
996
|
+
d = _distance_sq(x, y, player.x, player.y)
|
|
997
|
+
if best_idx is None or d < best_dist:
|
|
998
|
+
best_idx = idx
|
|
999
|
+
best_dist = d
|
|
1000
|
+
return best_idx
|
|
1001
|
+
|
|
1002
|
+
def _nearest_creature_index(self, x: float, y: float) -> int | None:
|
|
1003
|
+
best_idx = None
|
|
1004
|
+
best_dist = 0.0
|
|
1005
|
+
for idx, creature in enumerate(self._creatures):
|
|
1006
|
+
if creature.hp <= 0.0:
|
|
1007
|
+
continue
|
|
1008
|
+
d = _distance_sq(x, y, creature.x, creature.y)
|
|
1009
|
+
if best_idx is None or d < best_dist:
|
|
1010
|
+
best_idx = idx
|
|
1011
|
+
best_dist = d
|
|
1012
|
+
return best_idx
|
|
1013
|
+
|
|
1014
|
+
def _update_spawn_slots(self, dt: float) -> None:
|
|
1015
|
+
if not self._spawn_slots:
|
|
1016
|
+
return
|
|
1017
|
+
|
|
1018
|
+
spawn_events: list[tuple[int, float, float]] = []
|
|
1019
|
+
slot_count = len(self._spawn_slots)
|
|
1020
|
+
for slot_idx in range(slot_count):
|
|
1021
|
+
slot = self._spawn_slots[slot_idx]
|
|
1022
|
+
owner_idx = slot.owner_creature
|
|
1023
|
+
if not (0 <= owner_idx < len(self._creatures)):
|
|
1024
|
+
continue
|
|
1025
|
+
owner = self._creatures[owner_idx]
|
|
1026
|
+
if owner.hp <= 0.0:
|
|
1027
|
+
continue
|
|
1028
|
+
child_template_id = tick_spawn_slot(slot, dt)
|
|
1029
|
+
if child_template_id is None:
|
|
1030
|
+
continue
|
|
1031
|
+
spawn_events.append((child_template_id, owner.x, owner.y))
|
|
1032
|
+
|
|
1033
|
+
for child_template_id, x, y in spawn_events:
|
|
1034
|
+
self._spawn(child_template_id, x, y, heading=-100.0)
|
|
1035
|
+
|
|
1036
|
+
def _update_creatures(self, dt: float, dt_ms: int) -> None:
|
|
1037
|
+
if not self._creatures or not self._players:
|
|
1038
|
+
return
|
|
1039
|
+
for creature in self._creatures:
|
|
1040
|
+
if creature.hp <= 0.0:
|
|
1041
|
+
continue
|
|
1042
|
+
type_id = creature.type_id
|
|
1043
|
+
if type_id is None:
|
|
1044
|
+
template = SPAWN_ID_TO_TEMPLATE.get(creature.spawn_id)
|
|
1045
|
+
type_id = template.type_id if template is not None else None
|
|
1046
|
+
|
|
1047
|
+
move_speed = creature.move_speed
|
|
1048
|
+
if move_speed <= 0.0:
|
|
1049
|
+
move_speed = self._creature_speed(type_id) / 30.0
|
|
1050
|
+
|
|
1051
|
+
creature_ai7_tick_link_timer(creature, dt_ms=dt_ms, rand=self._crand.rand)
|
|
1052
|
+
|
|
1053
|
+
target_idx = self._nearest_player_index(creature.x, creature.y)
|
|
1054
|
+
creature.target_player = target_idx
|
|
1055
|
+
if target_idx is None:
|
|
1056
|
+
creature.vx = 0.0
|
|
1057
|
+
creature.vy = 0.0
|
|
1058
|
+
continue
|
|
1059
|
+
target = self._players[target_idx]
|
|
1060
|
+
ai = creature_ai_update_target(
|
|
1061
|
+
creature,
|
|
1062
|
+
player_x=target.x,
|
|
1063
|
+
player_y=target.y,
|
|
1064
|
+
creatures=self._creatures,
|
|
1065
|
+
dt=dt,
|
|
1066
|
+
)
|
|
1067
|
+
creature.move_scale = ai.move_scale
|
|
1068
|
+
if ai.self_damage is not None:
|
|
1069
|
+
creature.hp -= ai.self_damage
|
|
1070
|
+
if creature.hp <= 0.0:
|
|
1071
|
+
continue
|
|
1072
|
+
|
|
1073
|
+
if creature.ai_mode == 7:
|
|
1074
|
+
creature.vx = 0.0
|
|
1075
|
+
creature.vy = 0.0
|
|
1076
|
+
continue
|
|
1077
|
+
|
|
1078
|
+
creature.heading = _angle_approach(
|
|
1079
|
+
creature.heading, creature.target_heading, move_speed * 0.33333334 * 4.0, dt
|
|
1080
|
+
)
|
|
1081
|
+
speed = move_speed * 30.0
|
|
1082
|
+
direction_x = math.cos(creature.heading - math.pi / 2.0)
|
|
1083
|
+
direction_y = math.sin(creature.heading - math.pi / 2.0)
|
|
1084
|
+
creature.vx = direction_x * dt * creature.move_scale * speed
|
|
1085
|
+
creature.vy = direction_y * dt * creature.move_scale * speed
|
|
1086
|
+
|
|
1087
|
+
radius = max(0.0, creature.size)
|
|
1088
|
+
creature.x = _clamp(creature.x + creature.vx, radius, WORLD_SIZE - radius)
|
|
1089
|
+
creature.y = _clamp(creature.y + creature.vy, radius, WORLD_SIZE - radius)
|
|
1090
|
+
|
|
1091
|
+
def _select_player_target(self, player: DemoPlayer) -> int | None:
|
|
1092
|
+
candidate = self._nearest_creature_index(player.x, player.y)
|
|
1093
|
+
current = player.target_creature
|
|
1094
|
+
if current is None:
|
|
1095
|
+
return candidate
|
|
1096
|
+
if not (0 <= current < len(self._creatures)):
|
|
1097
|
+
return candidate
|
|
1098
|
+
current_creature = self._creatures[current]
|
|
1099
|
+
if current_creature.hp <= 0.0:
|
|
1100
|
+
return candidate
|
|
1101
|
+
if candidate is None or candidate == current:
|
|
1102
|
+
return current
|
|
1103
|
+
cand_creature = self._creatures[candidate]
|
|
1104
|
+
if cand_creature.hp <= 0.0:
|
|
1105
|
+
return current
|
|
1106
|
+
cur_d = math.hypot(current_creature.x - player.x, current_creature.y - player.y)
|
|
1107
|
+
cand_d = math.hypot(cand_creature.x - player.x, cand_creature.y - player.y)
|
|
1108
|
+
if cand_d + 64.0 < cur_d:
|
|
1109
|
+
return candidate
|
|
1110
|
+
return current
|
|
1111
|
+
|
|
1112
|
+
def _update_players(self, dt: float) -> None:
|
|
1113
|
+
if not self._players:
|
|
1114
|
+
return
|
|
1115
|
+
center_x = WORLD_SIZE * 0.5
|
|
1116
|
+
center_y = WORLD_SIZE * 0.5
|
|
1117
|
+
shot_cooldown_decay = dt * (1.5 if self._bonus_weapon_power_up_timer > 0.0 else 1.0)
|
|
1118
|
+
for player in self._players:
|
|
1119
|
+
player.shot_cooldown = max(0.0, player.shot_cooldown - shot_cooldown_decay)
|
|
1120
|
+
player.spread_heat = max(0.01, player.spread_heat - dt * 0.4)
|
|
1121
|
+
|
|
1122
|
+
if player.reload_timer > 0.0:
|
|
1123
|
+
player.reload_timer = max(0.0, player.reload_timer - dt)
|
|
1124
|
+
if player.reload_timer <= 0.0:
|
|
1125
|
+
weapon = self._weapon_entry(player.weapon_id)
|
|
1126
|
+
clip_size = int(weapon.clip_size) if weapon is not None and weapon.clip_size is not None else 0
|
|
1127
|
+
player.ammo = max(0, clip_size)
|
|
1128
|
+
player.reload_timer = 0.0
|
|
1129
|
+
player.reload_timer_max = 0.0
|
|
1130
|
+
|
|
1131
|
+
player.target_creature = self._select_player_target(player)
|
|
1132
|
+
target = self._creatures[player.target_creature] if player.target_creature is not None else None
|
|
1133
|
+
if target is not None and target.hp > 0.0:
|
|
1134
|
+
dx = target.x - player.x
|
|
1135
|
+
dy = target.y - player.y
|
|
1136
|
+
nx, ny, _ = _normalize(dx, dy)
|
|
1137
|
+
player.aim_x, player.aim_y = nx, ny
|
|
1138
|
+
else:
|
|
1139
|
+
dx = center_x - player.x
|
|
1140
|
+
dy = center_y - player.y
|
|
1141
|
+
nx, ny, _ = _normalize(dx, dy)
|
|
1142
|
+
player.aim_x, player.aim_y = nx, ny
|
|
1143
|
+
|
|
1144
|
+
move_x, move_y = 0.0, 0.0
|
|
1145
|
+
to_cx = center_x - player.x
|
|
1146
|
+
to_cy = center_y - player.y
|
|
1147
|
+
nx, ny, d = _normalize(to_cx, to_cy)
|
|
1148
|
+
if d > 120.0:
|
|
1149
|
+
move_x += nx
|
|
1150
|
+
move_y += ny
|
|
1151
|
+
|
|
1152
|
+
if target is not None and target.hp > 0.0:
|
|
1153
|
+
rx = player.x - target.x
|
|
1154
|
+
ry = player.y - target.y
|
|
1155
|
+
rnx, rny, rd = _normalize(rx, ry)
|
|
1156
|
+
if 0.0 < rd < 160.0:
|
|
1157
|
+
strength = (160.0 - rd) / 160.0
|
|
1158
|
+
move_x += rnx * (1.5 * strength)
|
|
1159
|
+
move_y += rny * (1.5 * strength)
|
|
1160
|
+
|
|
1161
|
+
orbit_dir = -1.0 if (player.index % 2) else 1.0
|
|
1162
|
+
ox, oy, _ = _normalize(-(player.y - center_y), player.x - center_x)
|
|
1163
|
+
move_x += ox * 0.55 * orbit_dir
|
|
1164
|
+
move_y += oy * 0.55 * orbit_dir
|
|
1165
|
+
|
|
1166
|
+
mnx, mny, _ = _normalize(move_x, move_y)
|
|
1167
|
+
speed = 150.0
|
|
1168
|
+
player.vx = mnx * speed
|
|
1169
|
+
player.vy = mny * speed
|
|
1170
|
+
player.x = _clamp(player.x + player.vx * dt, 0.0, WORLD_SIZE)
|
|
1171
|
+
player.y = _clamp(player.y + player.vy * dt, 0.0, WORLD_SIZE)
|
|
1172
|
+
|
|
1173
|
+
self._player_fire(player, target)
|
|
1174
|
+
|
|
1175
|
+
def _player_fire(self, player: DemoPlayer, target: DemoCreature | None) -> None:
|
|
1176
|
+
weapon = self._weapon_entry(player.weapon_id)
|
|
1177
|
+
if weapon is None:
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
if player.reload_timer > 0.0:
|
|
1181
|
+
return
|
|
1182
|
+
if player.shot_cooldown > 0.0:
|
|
1183
|
+
return
|
|
1184
|
+
if target is None or target.hp <= 0.0:
|
|
1185
|
+
return
|
|
1186
|
+
|
|
1187
|
+
if player.ammo <= 0:
|
|
1188
|
+
reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
|
|
1189
|
+
if self._bonus_weapon_power_up_timer > 0.0:
|
|
1190
|
+
reload_time *= 0.6
|
|
1191
|
+
player.reload_timer_max = max(0.0, reload_time)
|
|
1192
|
+
player.reload_timer = player.reload_timer_max
|
|
1193
|
+
play_sfx(self._state.audio, resolve_weapon_sfx_ref(weapon.reload_sound), rng=self._state.rng)
|
|
1194
|
+
return
|
|
1195
|
+
|
|
1196
|
+
shot_cooldown = float(weapon.shot_cooldown) if weapon.shot_cooldown is not None else 0.0
|
|
1197
|
+
player.shot_cooldown = max(0.02, shot_cooldown)
|
|
1198
|
+
|
|
1199
|
+
spread_inc = float(weapon.spread_heat_inc) if weapon.spread_heat_inc is not None else 0.0
|
|
1200
|
+
player.spread_heat = min(0.48, max(0.01, player.spread_heat + spread_inc))
|
|
1201
|
+
|
|
1202
|
+
theta = math.atan2(player.aim_y, player.aim_x)
|
|
1203
|
+
if player.spread_heat > 0.0:
|
|
1204
|
+
theta += (self._crand_float01() * 2.0 - 1.0) * player.spread_heat
|
|
1205
|
+
angle = theta + math.pi / 2.0
|
|
1206
|
+
|
|
1207
|
+
muzzle_x = player.x + player.aim_x * 16.0
|
|
1208
|
+
muzzle_y = player.y + player.aim_y * 16.0
|
|
1209
|
+
|
|
1210
|
+
play_sfx(self._state.audio, resolve_weapon_sfx_ref(weapon.fire_sound), rng=self._state.rng)
|
|
1211
|
+
|
|
1212
|
+
if player.weapon_id in {WeaponId.GAUSS_GUN, WeaponId.ION_MINIGUN}:
|
|
1213
|
+
meta = float(weapon.projectile_meta) if weapon.projectile_meta is not None else 0.0
|
|
1214
|
+
if meta <= 0.0:
|
|
1215
|
+
meta = 45.0
|
|
1216
|
+
type_id = projectile_type_id_from_weapon_id(player.weapon_id)
|
|
1217
|
+
if type_id is None:
|
|
1218
|
+
return
|
|
1219
|
+
self._projectile_pool.spawn(
|
|
1220
|
+
pos_x=muzzle_x,
|
|
1221
|
+
pos_y=muzzle_y,
|
|
1222
|
+
angle=angle,
|
|
1223
|
+
type_id=type_id,
|
|
1224
|
+
owner_id=-100,
|
|
1225
|
+
base_damage=meta,
|
|
1226
|
+
)
|
|
1227
|
+
elif player.weapon_id == WeaponId.ROCKET_LAUNCHER:
|
|
1228
|
+
self._secondary_projectile_pool.spawn(
|
|
1229
|
+
pos_x=muzzle_x,
|
|
1230
|
+
pos_y=muzzle_y,
|
|
1231
|
+
angle=angle,
|
|
1232
|
+
type_id=1,
|
|
1233
|
+
)
|
|
1234
|
+
elif player.weapon_id == WeaponId.PULSE_GUN:
|
|
1235
|
+
self._secondary_projectile_pool.spawn(
|
|
1236
|
+
pos_x=muzzle_x,
|
|
1237
|
+
pos_y=muzzle_y,
|
|
1238
|
+
angle=angle,
|
|
1239
|
+
type_id=4,
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
player.ammo = max(0, player.ammo - 1)
|
|
1243
|
+
if player.ammo <= 0:
|
|
1244
|
+
reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
|
|
1245
|
+
if self._bonus_weapon_power_up_timer > 0.0:
|
|
1246
|
+
reload_time *= 0.6
|
|
1247
|
+
player.reload_timer_max = max(0.0, reload_time)
|
|
1248
|
+
player.reload_timer = player.reload_timer_max
|
|
1249
|
+
play_sfx(self._state.audio, resolve_weapon_sfx_ref(weapon.reload_sound), rng=self._state.rng)
|
|
1250
|
+
|
|
1251
|
+
def _update_projectiles(self, dt: float) -> None:
|
|
1252
|
+
damage_scale_by_type: dict[int, float] = {}
|
|
1253
|
+
for type_id in (0x05, 0x0B, 0x15):
|
|
1254
|
+
weapon = weapon_entry_for_projectile_type_id(type_id)
|
|
1255
|
+
scale = float(weapon.damage_scale) if weapon is not None and weapon.damage_scale is not None else 0.0
|
|
1256
|
+
damage_scale_by_type[type_id] = scale if scale > 0.0 else 1.0
|
|
1257
|
+
|
|
1258
|
+
hits = self._projectile_pool.update(
|
|
1259
|
+
dt,
|
|
1260
|
+
self._creatures,
|
|
1261
|
+
world_size=WORLD_SIZE,
|
|
1262
|
+
damage_scale_by_type=damage_scale_by_type,
|
|
1263
|
+
rng=self._crand.rand,
|
|
1264
|
+
)
|
|
1265
|
+
for type_id, origin_x, origin_y, hit_x, hit_y, *_ in hits:
|
|
1266
|
+
if type_id == 0x15:
|
|
1267
|
+
self._beams.append(
|
|
1268
|
+
DemoBeam(
|
|
1269
|
+
x0=origin_x,
|
|
1270
|
+
y0=origin_y,
|
|
1271
|
+
x1=hit_x,
|
|
1272
|
+
y1=hit_y,
|
|
1273
|
+
life=0.08,
|
|
1274
|
+
)
|
|
1275
|
+
)
|
|
1276
|
+
if type_id == 0x0B:
|
|
1277
|
+
self._explosions.append(
|
|
1278
|
+
DemoExplosion(
|
|
1279
|
+
kind="rocket",
|
|
1280
|
+
x=hit_x,
|
|
1281
|
+
y=hit_y,
|
|
1282
|
+
elapsed=0.0,
|
|
1283
|
+
duration=0.35,
|
|
1284
|
+
max_radius=90.0,
|
|
1285
|
+
damage_per_tick=0.0,
|
|
1286
|
+
tick_interval=1.0,
|
|
1287
|
+
)
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
self._secondary_projectile_pool.update_pulse_gun(dt, self._creatures)
|
|
1291
|
+
self._creatures = [c for c in self._creatures if c.hp > 0.0]
|
|
1292
|
+
|
|
1293
|
+
def _update_fx(self, dt: float) -> None:
|
|
1294
|
+
if self._beams:
|
|
1295
|
+
beams: list[DemoBeam] = []
|
|
1296
|
+
for beam in self._beams:
|
|
1297
|
+
beam.life -= dt
|
|
1298
|
+
if beam.life > 0.0:
|
|
1299
|
+
beams.append(beam)
|
|
1300
|
+
self._beams = beams
|
|
1301
|
+
|
|
1302
|
+
if not self._explosions:
|
|
1303
|
+
return
|
|
1304
|
+
survivors: list[DemoExplosion] = []
|
|
1305
|
+
for fx in self._explosions:
|
|
1306
|
+
fx.elapsed += dt
|
|
1307
|
+
if fx.damage_per_tick > 0.0 and fx.tick_interval > 0.0:
|
|
1308
|
+
fx.tick_accum += dt
|
|
1309
|
+
while fx.tick_accum >= fx.tick_interval:
|
|
1310
|
+
fx.tick_accum -= fx.tick_interval
|
|
1311
|
+
self._apply_explosion_damage(fx)
|
|
1312
|
+
if fx.elapsed < fx.duration:
|
|
1313
|
+
survivors.append(fx)
|
|
1314
|
+
self._explosions = survivors
|
|
1315
|
+
self._creatures = [c for c in self._creatures if c.hp > 0.0]
|
|
1316
|
+
|
|
1317
|
+
def _apply_explosion_damage(self, fx: DemoExplosion) -> None:
|
|
1318
|
+
t = fx.elapsed / fx.duration if fx.duration > 0 else 1.0
|
|
1319
|
+
radius = fx.max_radius * _clamp(t, 0.0, 1.0)
|
|
1320
|
+
rsq = radius * radius
|
|
1321
|
+
for creature in self._creatures:
|
|
1322
|
+
if creature.hp <= 0.0:
|
|
1323
|
+
continue
|
|
1324
|
+
if _distance_sq(fx.x, fx.y, creature.x, creature.y) <= rsq:
|
|
1325
|
+
creature.hp -= fx.damage_per_tick
|
|
1326
|
+
|
|
1327
|
+
def _update_camera(self, dt: float) -> None:
|
|
1328
|
+
if not self._players:
|
|
1329
|
+
return
|
|
1330
|
+
screen_w = float(self._state.config.screen_width)
|
|
1331
|
+
screen_h = float(self._state.config.screen_height)
|
|
1332
|
+
if screen_w > WORLD_SIZE:
|
|
1333
|
+
screen_w = WORLD_SIZE
|
|
1334
|
+
if screen_h > WORLD_SIZE:
|
|
1335
|
+
screen_h = WORLD_SIZE
|
|
1336
|
+
|
|
1337
|
+
if len(self._players) == 1:
|
|
1338
|
+
focus_x = self._players[0].x
|
|
1339
|
+
focus_y = self._players[0].y
|
|
1340
|
+
else:
|
|
1341
|
+
focus_x = sum(p.x for p in self._players) / len(self._players)
|
|
1342
|
+
focus_y = sum(p.y for p in self._players) / len(self._players)
|
|
1343
|
+
|
|
1344
|
+
desired_x = (screen_w * 0.5) - focus_x
|
|
1345
|
+
desired_y = (screen_h * 0.5) - focus_y
|
|
1346
|
+
|
|
1347
|
+
min_x = screen_w - WORLD_SIZE
|
|
1348
|
+
min_y = screen_h - WORLD_SIZE
|
|
1349
|
+
if desired_x > -1.0:
|
|
1350
|
+
desired_x = -1.0
|
|
1351
|
+
if desired_y > -1.0:
|
|
1352
|
+
desired_y = -1.0
|
|
1353
|
+
if desired_x < min_x:
|
|
1354
|
+
desired_x = min_x
|
|
1355
|
+
if desired_y < min_y:
|
|
1356
|
+
desired_y = min_y
|
|
1357
|
+
|
|
1358
|
+
t = _clamp(dt * 6.0, 0.0, 1.0)
|
|
1359
|
+
self._camera_x = _lerp(self._camera_x, desired_x, t)
|
|
1360
|
+
self._camera_y = _lerp(self._camera_y, desired_y, t)
|