crimsonland 0.1.0.dev14__py3-none-any.whl → 0.1.0.dev16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. crimson/cli.py +63 -0
  2. crimson/creatures/damage.py +111 -36
  3. crimson/creatures/runtime.py +246 -156
  4. crimson/creatures/spawn.py +7 -3
  5. crimson/debug.py +9 -0
  6. crimson/demo.py +38 -45
  7. crimson/effects.py +7 -13
  8. crimson/frontend/high_scores_layout.py +81 -0
  9. crimson/frontend/panels/base.py +4 -1
  10. crimson/frontend/panels/controls.py +0 -15
  11. crimson/frontend/panels/databases.py +291 -3
  12. crimson/frontend/panels/mods.py +0 -15
  13. crimson/frontend/panels/play_game.py +0 -16
  14. crimson/game.py +689 -3
  15. crimson/gameplay.py +921 -569
  16. crimson/modes/base_gameplay_mode.py +33 -12
  17. crimson/modes/components/__init__.py +2 -0
  18. crimson/modes/components/highscore_record_builder.py +58 -0
  19. crimson/modes/components/perk_menu_controller.py +325 -0
  20. crimson/modes/quest_mode.py +94 -272
  21. crimson/modes/rush_mode.py +12 -43
  22. crimson/modes/survival_mode.py +109 -330
  23. crimson/modes/tutorial_mode.py +46 -247
  24. crimson/modes/typo_mode.py +11 -38
  25. crimson/oracle.py +396 -0
  26. crimson/perks.py +5 -2
  27. crimson/player_damage.py +95 -36
  28. crimson/projectiles.py +539 -320
  29. crimson/render/projectile_draw_registry.py +637 -0
  30. crimson/render/projectile_render_registry.py +110 -0
  31. crimson/render/secondary_projectile_draw_registry.py +206 -0
  32. crimson/render/world_renderer.py +58 -707
  33. crimson/sim/world_state.py +118 -61
  34. crimson/typo/spawns.py +5 -12
  35. crimson/ui/demo_trial_overlay.py +3 -11
  36. crimson/ui/formatting.py +24 -0
  37. crimson/ui/game_over.py +12 -58
  38. crimson/ui/hud.py +72 -39
  39. crimson/ui/layout.py +20 -0
  40. crimson/ui/perk_menu.py +9 -34
  41. crimson/ui/quest_results.py +28 -70
  42. crimson/ui/text_input.py +20 -0
  43. crimson/views/_ui_helpers.py +27 -0
  44. crimson/views/aim_debug.py +15 -32
  45. crimson/views/animations.py +18 -28
  46. crimson/views/arsenal_debug.py +22 -32
  47. crimson/views/bonuses.py +23 -36
  48. crimson/views/camera_debug.py +16 -29
  49. crimson/views/camera_shake.py +9 -33
  50. crimson/views/corpse_stamp_debug.py +13 -21
  51. crimson/views/decals_debug.py +36 -23
  52. crimson/views/fonts.py +8 -25
  53. crimson/views/ground.py +4 -21
  54. crimson/views/lighting_debug.py +42 -45
  55. crimson/views/particles.py +33 -42
  56. crimson/views/perk_menu_debug.py +3 -10
  57. crimson/views/player.py +50 -44
  58. crimson/views/player_sprite_debug.py +24 -31
  59. crimson/views/projectile_fx.py +57 -52
  60. crimson/views/projectile_render_debug.py +24 -33
  61. crimson/views/projectiles.py +24 -37
  62. crimson/views/spawn_plan.py +13 -29
  63. crimson/views/sprites.py +14 -29
  64. crimson/views/terrain.py +6 -23
  65. crimson/views/ui.py +7 -24
  66. crimson/views/wicons.py +28 -33
  67. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/METADATA +1 -1
  68. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/RECORD +73 -62
  69. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/WHEEL +1 -1
  70. grim/config.py +29 -1
  71. grim/console.py +7 -10
  72. grim/math.py +12 -0
  73. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  import math
5
+ from typing import Callable
5
6
 
6
7
  from ..bonuses import BonusId
7
8
  from ..creatures.damage import creature_apply_damage
@@ -35,6 +36,107 @@ class WorldEvents:
35
36
  sfx: list[str]
36
37
 
37
38
 
39
+ @dataclass(slots=True)
40
+ class _WorldDtCtx:
41
+ dt: float
42
+ players: list[PlayerState]
43
+
44
+
45
+ _WorldDtStep = Callable[[_WorldDtCtx], None]
46
+
47
+
48
+ def _world_dt_reflex_boosted(ctx: _WorldDtCtx) -> None:
49
+ if ctx.dt > 0.0 and ctx.players and perk_active(ctx.players[0], PerkId.REFLEX_BOOSTED):
50
+ ctx.dt *= 0.9
51
+
52
+
53
+ _WORLD_DT_STEPS: tuple[_WorldDtStep, ...] = (_world_dt_reflex_boosted,)
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class _PlayerDeathCtx:
58
+ state: GameplayState
59
+ creatures: CreaturePool
60
+ players: list[PlayerState]
61
+ player_index: int
62
+ player: PlayerState
63
+ dt: float
64
+ world_size: float
65
+ detail_preset: int
66
+ fx_queue: FxQueue
67
+ deaths: list[object]
68
+
69
+
70
+ _PlayerDeathHook = Callable[[_PlayerDeathCtx], None]
71
+
72
+
73
+ def _player_death_final_revenge(ctx: _PlayerDeathCtx) -> None:
74
+ player = ctx.player
75
+ if not perk_active(player, PerkId.FINAL_REVENGE):
76
+ return
77
+
78
+ px = float(player.pos_x)
79
+ py = float(player.pos_y)
80
+ rand = ctx.state.rng.rand
81
+ ctx.state.effects.spawn_explosion_burst(
82
+ pos_x=px,
83
+ pos_y=py,
84
+ scale=1.8,
85
+ rand=rand,
86
+ detail_preset=int(ctx.detail_preset),
87
+ )
88
+
89
+ prev_guard = bool(ctx.state.bonus_spawn_guard)
90
+ ctx.state.bonus_spawn_guard = True
91
+ for creature_idx, creature in enumerate(ctx.creatures.entries):
92
+ if not creature.active:
93
+ continue
94
+ if float(creature.hp) <= 0.0:
95
+ continue
96
+
97
+ dx = float(creature.x) - px
98
+ dy = float(creature.y) - py
99
+ if abs(dx) > 512.0 or abs(dy) > 512.0:
100
+ continue
101
+
102
+ remaining = 512.0 - math.hypot(dx, dy)
103
+ if remaining <= 0.0:
104
+ continue
105
+
106
+ damage = remaining * 5.0
107
+ death_start_needed = float(creature.hp) > 0.0 and float(creature.hitbox_size) == CREATURE_HITBOX_ALIVE
108
+ killed = creature_apply_damage(
109
+ creature,
110
+ damage_amount=damage,
111
+ damage_type=3,
112
+ impulse_x=0.0,
113
+ impulse_y=0.0,
114
+ owner_id=-1 - int(player.index),
115
+ dt=float(ctx.dt),
116
+ players=ctx.players,
117
+ rand=rand,
118
+ )
119
+ if killed and death_start_needed:
120
+ ctx.deaths.append(
121
+ ctx.creatures.handle_death(
122
+ int(creature_idx),
123
+ state=ctx.state,
124
+ players=ctx.players,
125
+ rand=rand,
126
+ detail_preset=int(ctx.detail_preset),
127
+ world_width=float(ctx.world_size),
128
+ world_height=float(ctx.world_size),
129
+ fx_queue=ctx.fx_queue,
130
+ )
131
+ )
132
+ ctx.state.bonus_spawn_guard = prev_guard
133
+ ctx.state.sfx_queue.append("sfx_explosion_large")
134
+ ctx.state.sfx_queue.append("sfx_shockwave")
135
+
136
+
137
+ _PLAYER_DEATH_HOOKS: tuple[_PlayerDeathHook, ...] = (_player_death_final_revenge,)
138
+
139
+
38
140
  @dataclass(slots=True)
39
141
  class WorldState:
40
142
  spawn_env: SpawnEnv
@@ -84,9 +186,10 @@ class WorldState:
84
186
  game_mode: int,
85
187
  perk_progression_enabled: bool,
86
188
  ) -> WorldEvents:
87
- dt = float(dt)
88
- if dt > 0.0 and self.players and perk_active(self.players[0], PerkId.REFLEX_BOOSTED):
89
- dt *= 0.9
189
+ dt_ctx = _WorldDtCtx(dt=float(dt), players=self.players)
190
+ for step in _WORLD_DT_STEPS:
191
+ step(dt_ctx)
192
+ dt = float(dt_ctx.dt)
90
193
 
91
194
  if inputs is None:
92
195
  inputs = [PlayerInput() for _ in self.players]
@@ -194,66 +297,20 @@ class WorldState:
194
297
  continue
195
298
  if float(player.health) >= 0.0:
196
299
  continue
197
- if not perk_active(player, PerkId.FINAL_REVENGE):
198
- continue
199
-
200
- px = float(player.pos_x)
201
- py = float(player.pos_y)
202
- rand = self.state.rng.rand
203
- self.state.effects.spawn_explosion_burst(
204
- pos_x=px,
205
- pos_y=py,
206
- scale=1.8,
207
- rand=rand,
300
+ death_ctx = _PlayerDeathCtx(
301
+ state=self.state,
302
+ creatures=self.creatures,
303
+ players=self.players,
304
+ player_index=int(idx),
305
+ player=player,
306
+ dt=float(dt),
307
+ world_size=float(world_size),
208
308
  detail_preset=int(detail_preset),
309
+ fx_queue=fx_queue,
310
+ deaths=deaths,
209
311
  )
210
-
211
- prev_guard = bool(self.state.bonus_spawn_guard)
212
- self.state.bonus_spawn_guard = True
213
- for creature_idx, creature in enumerate(self.creatures.entries):
214
- if not creature.active:
215
- continue
216
- if float(creature.hp) <= 0.0:
217
- continue
218
-
219
- dx = float(creature.x) - px
220
- dy = float(creature.y) - py
221
- if abs(dx) > 512.0 or abs(dy) > 512.0:
222
- continue
223
-
224
- remaining = 512.0 - math.hypot(dx, dy)
225
- if remaining <= 0.0:
226
- continue
227
-
228
- damage = remaining * 5.0
229
- death_start_needed = float(creature.hp) > 0.0 and float(creature.hitbox_size) == CREATURE_HITBOX_ALIVE
230
- killed = creature_apply_damage(
231
- creature,
232
- damage_amount=damage,
233
- damage_type=3,
234
- impulse_x=0.0,
235
- impulse_y=0.0,
236
- owner_id=-1 - int(player.index),
237
- dt=float(dt),
238
- players=self.players,
239
- rand=rand,
240
- )
241
- if killed and death_start_needed:
242
- deaths.append(
243
- self.creatures.handle_death(
244
- int(creature_idx),
245
- state=self.state,
246
- players=self.players,
247
- rand=rand,
248
- detail_preset=int(detail_preset),
249
- world_width=float(world_size),
250
- world_height=float(world_size),
251
- fx_queue=fx_queue,
252
- )
253
- )
254
- self.state.bonus_spawn_guard = prev_guard
255
- self.state.sfx_queue.append("sfx_explosion_large")
256
- self.state.sfx_queue.append("sfx_shockwave")
312
+ for hook in _PLAYER_DEATH_HOOKS:
313
+ hook(death_ctx)
257
314
 
258
315
  def _kill_creature_no_corpse(creature_index: int, owner_id: int) -> None:
259
316
  idx = int(creature_index)
crimson/typo/spawns.py CHANGED
@@ -3,16 +3,9 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  import math
5
5
 
6
- from ..creatures.spawn import CreatureTypeId
7
-
8
-
9
- def _clamp(value: float, lo: float, hi: float) -> float:
10
- if value < lo:
11
- return lo
12
- if value > hi:
13
- return hi
14
- return value
6
+ from grim.math import clamp
15
7
 
8
+ from ..creatures.spawn import CreatureTypeId
16
9
 
17
10
  @dataclass(frozen=True, slots=True)
18
11
  class TypoSpawnCall:
@@ -47,9 +40,9 @@ def tick_typo_spawns(
47
40
  y = math.cos(t) * 256.0 + float(world_height) * 0.5
48
41
 
49
42
  tint_t = float(elapsed_ms + 1)
50
- tint_r = _clamp(tint_t * 0.0000083333334 + 0.3, 0.0, 1.0)
51
- tint_g = _clamp(tint_t * 10000.0 + 0.3, 0.0, 1.0)
52
- tint_b = _clamp(math.sin(tint_t * 0.0001) + 0.3, 0.0, 1.0)
43
+ tint_r = clamp(tint_t * 0.0000083333334 + 0.3, 0.0, 1.0)
44
+ tint_g = clamp(tint_t * 10000.0 + 0.3, 0.0, 1.0)
45
+ tint_b = clamp(math.sin(tint_t * 0.0001) + 0.3, 0.0, 1.0)
53
46
  tint = (tint_r, tint_g, tint_b, 1.0)
54
47
 
55
48
  spawns.append(
@@ -6,6 +6,7 @@ import pyray as rl
6
6
 
7
7
  from grim.assets import PaqTextureCache
8
8
  from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
9
+ from grim.math import clamp
9
10
 
10
11
  from ..demo_trial import DemoTrialOverlayInfo
11
12
  from .perk_menu import (
@@ -20,15 +21,6 @@ from .perk_menu import (
20
21
 
21
22
  DEMO_PURCHASE_URL = "http://buy.crimsonland.com"
22
23
 
23
-
24
- def _clamp(value: float, lo: float, hi: float) -> float:
25
- if value < lo:
26
- return lo
27
- if value > hi:
28
- return hi
29
- return value
30
-
31
-
32
24
  class DemoTrialOverlayUi:
33
25
  def __init__(self, assets_root: Path) -> None:
34
26
  self._assets_root = assets_root
@@ -94,8 +86,8 @@ class DemoTrialOverlayUi:
94
86
  mouse = rl.get_mouse_position()
95
87
  screen_w = float(rl.get_screen_width())
96
88
  screen_h = float(rl.get_screen_height())
97
- mouse.x = _clamp(float(mouse.x), 0.0, max(0.0, screen_w - 1.0))
98
- mouse.y = _clamp(float(mouse.y), 0.0, max(0.0, screen_h - 1.0))
89
+ mouse.x = clamp(float(mouse.x), 0.0, max(0.0, screen_w - 1.0))
90
+ mouse.y = clamp(float(mouse.y), 0.0, max(0.0, screen_h - 1.0))
99
91
 
100
92
  click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
101
93
  panel_x, panel_y = self._panel_xy(screen_w=screen_w, screen_h=screen_h)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def format_ordinal(value_1_based: int) -> str:
5
+ value = int(value_1_based)
6
+ if value % 100 in (11, 12, 13):
7
+ suffix = "th"
8
+ elif value % 10 == 1:
9
+ suffix = "st"
10
+ elif value % 10 == 2:
11
+ suffix = "nd"
12
+ elif value % 10 == 3:
13
+ suffix = "rd"
14
+ else:
15
+ suffix = "th"
16
+ return f"{value}{suffix}"
17
+
18
+
19
+ def format_time_mm_ss(ms: int) -> str:
20
+ total_s = max(0, int(ms)) // 1000
21
+ minutes = total_s // 60
22
+ seconds = total_s % 60
23
+ return f"{minutes}:{seconds:02d}"
24
+
crimson/ui/game_over.py CHANGED
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  import pyray as rl
9
9
 
10
10
  from grim.assets import TextureLoader
11
+ from grim.config import CrimsonConfig
11
12
  from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
12
13
 
13
14
  from ..persistence.highscores import (
@@ -20,7 +21,9 @@ from ..persistence.highscores import (
20
21
  upsert_highscore_record,
21
22
  )
22
23
  from ..weapons import WEAPON_BY_ID
24
+ from .formatting import format_ordinal, format_time_mm_ss
23
25
  from .hud import HudAssets
26
+ from .layout import ui_origin, ui_scale
24
27
  from .perk_menu import (
25
28
  PerkMenuAssets,
26
29
  UiButtonState,
@@ -32,20 +35,7 @@ from .perk_menu import (
32
35
  load_perk_menu_assets,
33
36
  )
34
37
  from .shadow import UI_SHADOW_OFFSET, draw_ui_quad_shadow
35
-
36
-
37
- UI_BASE_WIDTH = 640.0
38
- UI_BASE_HEIGHT = 480.0
39
-
40
-
41
- def ui_scale(screen_w: float, screen_h: float) -> float:
42
- # Matches the classic UI-space helpers we use elsewhere: render in 640x480 pixel space.
43
- return 1.0
44
-
45
-
46
- def ui_origin(screen_w: float, screen_h: float, scale: float) -> tuple[float, float]:
47
- return 0.0, 0.0
48
-
38
+ from .text_input import poll_text_input
49
39
 
50
40
  GAME_OVER_PANEL_X = -45.0
51
41
  GAME_OVER_PANEL_Y = 210.0
@@ -69,28 +59,6 @@ COLOR_SCORE_LABEL = rl.Color(230, 230, 230, 255)
69
59
  COLOR_SCORE_VALUE = rl.Color(230, 230, 255, 255)
70
60
 
71
61
 
72
- def _format_ordinal(value_1_based: int) -> str:
73
- value = int(value_1_based)
74
- if value % 100 in (11, 12, 13):
75
- suffix = "th"
76
- elif value % 10 == 1:
77
- suffix = "st"
78
- elif value % 10 == 2:
79
- suffix = "nd"
80
- elif value % 10 == 3:
81
- suffix = "rd"
82
- else:
83
- suffix = "th"
84
- return f"{value}{suffix}"
85
-
86
-
87
- def _format_time_mm_ss(ms: int) -> str:
88
- total_s = max(0, int(ms)) // 1000
89
- minutes = total_s // 60
90
- seconds = total_s % 60
91
- return f"{minutes}:{seconds:02d}"
92
-
93
-
94
62
  def _weapon_icon_src(texture: rl.Texture, weapon_id_native: int) -> rl.Rectangle | None:
95
63
  weapon_id = int(weapon_id_native)
96
64
  entry = WEAPON_BY_ID.get(int(weapon_id))
@@ -143,22 +111,6 @@ def _draw_texture_centered(tex: rl.Texture, x: float, y: float, w: float, h: flo
143
111
  rl.draw_texture_pro(tex, src, dst, rl.Vector2(0.0, 0.0), 0.0, tint)
144
112
 
145
113
 
146
- def _poll_text_input(max_len: int, *, allow_space: bool = True) -> str:
147
- out = ""
148
- while True:
149
- value = rl.get_char_pressed()
150
- if value == 0:
151
- break
152
- if value < 0x20 or value > 0xFF:
153
- continue
154
- if not allow_space and value == 0x20:
155
- continue
156
- if len(out) >= max_len:
157
- continue
158
- out += chr(int(value))
159
- return out
160
-
161
-
162
114
  def _ease_out_cubic(t: float) -> float:
163
115
  t = max(0.0, min(1.0, float(t)))
164
116
  return 1.0 - (1.0 - t) ** 3
@@ -169,7 +121,7 @@ class GameOverUi:
169
121
  assets_root: Path
170
122
  base_dir: Path
171
123
 
172
- config: object # CrimsonConfig-like
124
+ config: CrimsonConfig
173
125
 
174
126
  assets: GameOverAssets | None = None
175
127
  font: SmallFontData | None = None
@@ -309,7 +261,7 @@ class GameOverUi:
309
261
  rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER)
310
262
  if self.phase == -1:
311
263
  # If in the top 100, prompt for a name. Otherwise show score-too-low message and buttons.
312
- game_mode_id = int(getattr(self.config, "data", {}).get("game_mode", 1))
264
+ game_mode_id = int(self.config.data.get("game_mode", 1))
313
265
  candidate = record.copy()
314
266
  candidate.game_mode_id = game_mode_id
315
267
  self._candidate_record = candidate
@@ -328,7 +280,7 @@ class GameOverUi:
328
280
  # Basic text input behavior for the name-entry phase.
329
281
  if self.phase == 0:
330
282
  click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
331
- typed = _poll_text_input(NAME_MAX_EDIT - len(self.input_text), allow_space=True)
283
+ typed = poll_text_input(NAME_MAX_EDIT - len(self.input_text), allow_space=True)
332
284
  if typed:
333
285
  self.input_text = (self.input_text[: self.input_caret] + typed + self.input_text[self.input_caret :])[:NAME_MAX_EDIT]
334
286
  self.input_caret = min(len(self.input_text), self.input_caret + len(typed))
@@ -369,6 +321,8 @@ class GameOverUi:
369
321
  play_sfx("sfx_ui_typeenter")
370
322
  candidate = (self._candidate_record or record).copy()
371
323
  candidate.set_name(self.input_text)
324
+ self.config.set_player_name(self.input_text)
325
+ self.config.save()
372
326
  path = scores_path_for_config(self.base_dir, self.config)
373
327
  if not self._saved:
374
328
  upsert_highscore_record(path, candidate)
@@ -449,7 +403,7 @@ class GameOverUi:
449
403
  score_value_w = self._text_width(score_value, 1.0 * scale)
450
404
  self._draw_small(score_value, base_x + 32.0 * scale - score_value_w * 0.5, base_y + 15.0 * scale, 1.0 * scale, value_color)
451
405
 
452
- rank_value = _format_ordinal(int(self.rank) + 1)
406
+ rank_value = format_ordinal(int(self.rank) + 1)
453
407
  rank_text = f"Rank: {rank_value}"
454
408
  rank_w = self._text_width(rank_text, 1.0 * scale)
455
409
  self._draw_small(rank_text, base_x + 32.0 * scale - rank_w * 0.5, base_y + 30.0 * scale, 1.0 * scale, label_color)
@@ -487,7 +441,7 @@ class GameOverUi:
487
441
  origin = rl.Vector2(16.0 * scale, 16.0 * scale)
488
442
  rl.draw_texture_pro(hud_assets.clock_pointer, src, dst, origin, rotation, rl.Color(255, 255, 255, int(255 * alpha)))
489
443
 
490
- time_text = _format_time_mm_ss(elapsed_ms)
444
+ time_text = format_time_mm_ss(elapsed_ms)
491
445
  self._draw_small(time_text, col2_x + 40.0 * scale, base_y + 19.0 * scale, 1.0 * scale, label_color)
492
446
 
493
447
  # Second row: weapon icon + frags + hit ratio (suppressed while entering the name).
@@ -572,7 +526,7 @@ class GameOverUi:
572
526
  panel_tex = self.assets.menu_panel
573
527
  src = rl.Rectangle(0.0, 0.0, float(panel_tex.width), float(panel_tex.height))
574
528
  dst = rl.Rectangle(panel.x, panel.y, panel.width, panel.height)
575
- fx_detail = bool(int(getattr(self.config, "data", {}).get("fx_detail_0", 0) or 0))
529
+ fx_detail = bool(int(self.config.data.get("fx_detail_0", 0) or 0))
576
530
  if fx_detail:
577
531
  draw_ui_quad_shadow(
578
532
  texture=panel_tex,
crimson/ui/hud.py CHANGED
@@ -56,8 +56,6 @@ HUD_BONUS_PANEL_OFFSET_Y = -11.0
56
56
  HUD_XP_BAR_RGBA = (0.1, 0.3, 0.6, 1.0)
57
57
  HUD_QUEST_LEFT_Y_SHIFT = 80.0
58
58
 
59
- _SURVIVAL_XP_SMOOTHED = 0
60
-
61
59
 
62
60
  @dataclass(slots=True)
63
61
  class HudAssets:
@@ -85,6 +83,46 @@ class HudRenderFlags:
85
83
  show_quest_hud: bool
86
84
 
87
85
 
86
+ @dataclass(slots=True)
87
+ class HudState:
88
+ survival_xp_smoothed: int = 0
89
+
90
+ def smooth_xp(self, target: int, frame_dt_ms: float) -> int:
91
+ target = int(target)
92
+ if target <= 0:
93
+ self.survival_xp_smoothed = 0
94
+ return 0
95
+
96
+ smoothed = int(self.survival_xp_smoothed)
97
+ if smoothed == target:
98
+ return smoothed
99
+
100
+ step = max(1, int(frame_dt_ms) // 2)
101
+ diff = abs(smoothed - target)
102
+ if diff > 1000:
103
+ step *= diff // 100
104
+
105
+ if smoothed < target:
106
+ smoothed += step
107
+ if smoothed > target:
108
+ smoothed = target
109
+ else:
110
+ smoothed -= step
111
+ if smoothed < target:
112
+ smoothed = target
113
+
114
+ self.survival_xp_smoothed = smoothed
115
+ return smoothed
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class HudLayout:
120
+ scale: float
121
+ text_scale: float
122
+ line_h: float
123
+ hud_y_shift: float
124
+
125
+
88
126
  def hud_flags_for_game_mode(game_mode_id: int) -> HudRenderFlags:
89
127
  """Match `hud_update_and_render` (0x0041ca90) flag mapping."""
90
128
 
@@ -139,6 +177,14 @@ def hud_ui_scale(screen_w: float, screen_h: float) -> float:
139
177
  return float(scale)
140
178
 
141
179
 
180
+ def hud_layout(screen_w: float, screen_h: float, *, font: SmallFontData | None, show_quest_hud: bool) -> HudLayout:
181
+ scale = hud_ui_scale(float(screen_w), float(screen_h))
182
+ text_scale = 1.0 * scale
183
+ line_h = float(font.cell_size) * text_scale if font is not None else 18.0 * text_scale
184
+ hud_y_shift = HUD_QUEST_LEFT_Y_SHIFT if show_quest_hud else 0.0
185
+ return HudLayout(scale=scale, text_scale=text_scale, line_h=line_h, hud_y_shift=hud_y_shift)
186
+
187
+
142
188
  def load_hud_assets(assets_root: Path) -> HudAssets:
143
189
  loader = TextureLoader.from_assets_root(assets_root)
144
190
  return HudAssets(
@@ -178,29 +224,20 @@ def _with_alpha(color: rl.Color, alpha: float) -> rl.Color:
178
224
  return rl.Color(color.r, color.g, color.b, int(color.a * alpha))
179
225
 
180
226
 
181
- def _smooth_xp(target: int, frame_dt_ms: float) -> int:
182
- global _SURVIVAL_XP_SMOOTHED
183
- target = int(target)
184
- if target <= 0:
185
- _SURVIVAL_XP_SMOOTHED = 0
186
- return 0
187
- smoothed = int(_SURVIVAL_XP_SMOOTHED)
188
- if smoothed == target:
189
- return smoothed
190
- step = max(1, int(frame_dt_ms) // 2)
191
- diff = abs(smoothed - target)
192
- if diff > 1000:
193
- step *= diff // 100
194
- if smoothed < target:
195
- smoothed += step
196
- if smoothed > target:
197
- smoothed = target
198
- else:
199
- smoothed -= step
200
- if smoothed < target:
201
- smoothed = target
202
- _SURVIVAL_XP_SMOOTHED = smoothed
203
- return smoothed
227
+ def _quest_panel_slide_x(time_ms: float) -> float:
228
+ time_ms = float(time_ms)
229
+ if time_ms < 1000.0:
230
+ return (1000.0 - time_ms) * -0.128
231
+ return 0.0
232
+
233
+
234
+ def _survival_xp_progress_ratio(*, xp: int, level: int) -> float:
235
+ level = max(1, int(level))
236
+ prev_threshold = 0 if level <= 1 else survival_level_threshold(level - 1)
237
+ next_threshold = survival_level_threshold(level)
238
+ if next_threshold <= prev_threshold:
239
+ return 0.0
240
+ return (int(xp) - prev_threshold) / float(next_threshold - prev_threshold)
204
241
 
205
242
 
206
243
  def _draw_progress_bar(x: float, y: float, width: float, ratio: float, rgba: tuple[float, float, float, float], scale: float) -> None:
@@ -275,6 +312,7 @@ def _bonus_icon_src(texture: rl.Texture, icon_id: int) -> rl.Rectangle:
275
312
  def draw_hud_overlay(
276
313
  assets: HudAssets,
277
314
  *,
315
+ state: HudState,
278
316
  player: PlayerState,
279
317
  players: list[PlayerState] | None = None,
280
318
  bonus_hud: BonusHudState | None = None,
@@ -293,6 +331,7 @@ def draw_hud_overlay(
293
331
  ) -> float:
294
332
  if frame_dt_ms is None:
295
333
  frame_dt_ms = max(0.0, float(rl.get_frame_time()) * 1000.0)
334
+ state = state or HudState()
296
335
  hud_players = list(players) if players is not None else [player]
297
336
  if not hud_players:
298
337
  hud_players = [player]
@@ -300,9 +339,10 @@ def draw_hud_overlay(
300
339
 
301
340
  screen_w = float(rl.get_screen_width())
302
341
  screen_h = float(rl.get_screen_height())
303
- scale = hud_ui_scale(screen_w, screen_h)
304
- text_scale = 1.0 * scale
305
- line_h = float(font.cell_size) * text_scale if font is not None else 18.0 * text_scale
342
+ layout = hud_layout(screen_w, screen_h, font=font, show_quest_hud=show_quest_hud)
343
+ scale = layout.scale
344
+ text_scale = layout.text_scale
345
+ line_h = layout.line_h
306
346
 
307
347
  def sx(value: float) -> float:
308
348
  return value * scale
@@ -314,7 +354,7 @@ def draw_hud_overlay(
314
354
  alpha = max(0.0, min(1.0, float(alpha)))
315
355
  text_color = _with_alpha(HUD_TEXT_COLOR, alpha)
316
356
  panel_text_color = _with_alpha(HUD_TEXT_COLOR, alpha * HUD_PANEL_ALPHA)
317
- hud_y_shift = HUD_QUEST_LEFT_Y_SHIFT if show_quest_hud else 0.0
357
+ hud_y_shift = layout.hud_y_shift
318
358
 
319
359
  # Top bar background.
320
360
  if assets.game_top is not None:
@@ -502,9 +542,7 @@ def draw_hud_overlay(
502
542
  # Quest HUD panels (mm:ss timer + progress).
503
543
  if show_quest_hud:
504
544
  time_ms = max(0.0, float(elapsed_ms))
505
- slide_x = 0.0
506
- if time_ms < 1000.0:
507
- slide_x = (1000.0 - time_ms) * -0.128
545
+ slide_x = _quest_panel_slide_x(time_ms)
508
546
 
509
547
  quest_panel_alpha = alpha * 0.7
510
548
  quest_text_color = _with_alpha(HUD_TEXT_COLOR, quest_panel_alpha)
@@ -580,7 +618,7 @@ def draw_hud_overlay(
580
618
 
581
619
  # Survival XP panel.
582
620
  xp_target = int(player.experience if score is None else score)
583
- xp_display = _smooth_xp(xp_target, frame_dt_ms) if show_xp else xp_target
621
+ xp_display = state.smooth_xp(xp_target, frame_dt_ms) if show_xp else xp_target
584
622
  if show_xp and assets.ind_panel is not None:
585
623
  panel_x, panel_y = HUD_SURV_PANEL_POS
586
624
  panel_y += hud_y_shift
@@ -623,12 +661,7 @@ def draw_hud_overlay(
623
661
  panel_text_color,
624
662
  )
625
663
 
626
- level = max(1, int(player.level))
627
- prev_threshold = 0 if level <= 1 else survival_level_threshold(level - 1)
628
- next_threshold = survival_level_threshold(level)
629
- progress_ratio = 0.0
630
- if next_threshold > prev_threshold:
631
- progress_ratio = (xp_target - prev_threshold) / float(next_threshold - prev_threshold)
664
+ progress_ratio = _survival_xp_progress_ratio(xp=xp_target, level=int(player.level))
632
665
  bar_x, bar_y = HUD_SURV_PROGRESS_POS
633
666
  bar_y += hud_y_shift
634
667
  bar_w = HUD_SURV_PROGRESS_WIDTH
crimson/ui/layout.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ UI_BASE_WIDTH = 640.0
5
+ UI_BASE_HEIGHT = 480.0
6
+
7
+
8
+ def ui_scale(screen_w: float, screen_h: float) -> float:
9
+ # Classic UI-space: draw in backbuffer pixels.
10
+ return 1.0
11
+
12
+
13
+ def ui_origin(screen_w: float, screen_h: float, scale: float) -> tuple[float, float]:
14
+ return 0.0, 0.0
15
+
16
+
17
+ def menu_widescreen_y_shift(layout_w: float) -> float:
18
+ # ui_menu_layout_init: pos_y += (screen_width / 640.0) * 150.0 - 150.0
19
+ return (layout_w / UI_BASE_WIDTH) * 150.0 - 150.0
20
+