crimsonland 0.1.0.dev15__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 (75) hide show
  1. crimson/cli.py +61 -0
  2. crimson/creatures/damage.py +111 -36
  3. crimson/creatures/runtime.py +246 -156
  4. crimson/creatures/spawn.py +7 -3
  5. crimson/demo.py +38 -45
  6. crimson/effects.py +7 -13
  7. crimson/frontend/high_scores_layout.py +81 -0
  8. crimson/frontend/panels/base.py +4 -1
  9. crimson/frontend/panels/controls.py +0 -15
  10. crimson/frontend/panels/databases.py +291 -3
  11. crimson/frontend/panels/mods.py +0 -15
  12. crimson/frontend/panels/play_game.py +0 -16
  13. crimson/game.py +441 -1
  14. crimson/gameplay.py +905 -569
  15. crimson/modes/base_gameplay_mode.py +33 -12
  16. crimson/modes/components/__init__.py +2 -0
  17. crimson/modes/components/highscore_record_builder.py +58 -0
  18. crimson/modes/components/perk_menu_controller.py +325 -0
  19. crimson/modes/quest_mode.py +58 -273
  20. crimson/modes/rush_mode.py +12 -43
  21. crimson/modes/survival_mode.py +71 -328
  22. crimson/modes/tutorial_mode.py +46 -247
  23. crimson/modes/typo_mode.py +11 -38
  24. crimson/oracle.py +396 -0
  25. crimson/perks.py +5 -2
  26. crimson/player_damage.py +94 -37
  27. crimson/projectiles.py +539 -320
  28. crimson/render/projectile_draw_registry.py +637 -0
  29. crimson/render/projectile_render_registry.py +110 -0
  30. crimson/render/secondary_projectile_draw_registry.py +206 -0
  31. crimson/render/world_renderer.py +58 -707
  32. crimson/sim/world_state.py +118 -61
  33. crimson/typo/spawns.py +5 -12
  34. crimson/ui/demo_trial_overlay.py +3 -11
  35. crimson/ui/formatting.py +24 -0
  36. crimson/ui/game_over.py +12 -58
  37. crimson/ui/hud.py +72 -39
  38. crimson/ui/layout.py +20 -0
  39. crimson/ui/perk_menu.py +9 -34
  40. crimson/ui/quest_results.py +12 -64
  41. crimson/ui/text_input.py +20 -0
  42. crimson/views/_ui_helpers.py +27 -0
  43. crimson/views/aim_debug.py +15 -32
  44. crimson/views/animations.py +18 -28
  45. crimson/views/arsenal_debug.py +22 -32
  46. crimson/views/bonuses.py +23 -36
  47. crimson/views/camera_debug.py +16 -29
  48. crimson/views/camera_shake.py +9 -33
  49. crimson/views/corpse_stamp_debug.py +13 -21
  50. crimson/views/decals_debug.py +36 -23
  51. crimson/views/fonts.py +8 -25
  52. crimson/views/ground.py +4 -21
  53. crimson/views/lighting_debug.py +42 -45
  54. crimson/views/particles.py +33 -42
  55. crimson/views/perk_menu_debug.py +3 -10
  56. crimson/views/player.py +50 -44
  57. crimson/views/player_sprite_debug.py +24 -31
  58. crimson/views/projectile_fx.py +57 -52
  59. crimson/views/projectile_render_debug.py +24 -33
  60. crimson/views/projectiles.py +24 -37
  61. crimson/views/spawn_plan.py +13 -29
  62. crimson/views/sprites.py +14 -29
  63. crimson/views/terrain.py +6 -23
  64. crimson/views/ui.py +7 -24
  65. crimson/views/wicons.py +28 -33
  66. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/METADATA +1 -1
  67. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/RECORD +72 -64
  68. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/WHEEL +2 -2
  69. grim/config.py +29 -1
  70. grim/console.py +7 -10
  71. grim/math.py +12 -0
  72. crimson/.DS_Store +0 -0
  73. crimson/creatures/.DS_Store +0 -0
  74. grim/.DS_Store +0 -0
  75. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
@@ -11,6 +11,7 @@ from grim.audio import AudioState, update_audio
11
11
  from grim.console import ConsoleState
12
12
  from grim.config import CrimsonConfig
13
13
  from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
14
+ from grim.math import clamp
14
15
  from grim.view import ViewContext
15
16
 
16
17
  from ..gameplay import _creature_find_in_radius, perk_count_get
@@ -18,20 +19,12 @@ from ..game_world import GameWorld
18
19
  from ..persistence.highscores import HighScoreRecord
19
20
  from ..perks import PerkId
20
21
  from ..ui.game_over import GameOverUi
21
- from ..ui.hud import HudAssets, draw_target_health_bar, load_hud_assets
22
+ from ..ui.hud import HudAssets, HudState, draw_target_health_bar, load_hud_assets
22
23
 
23
24
  if TYPE_CHECKING:
24
25
  from ..persistence.save_status import GameStatus
25
26
 
26
27
 
27
- def _clamp(value: float, lo: float, hi: float) -> float:
28
- if value < lo:
29
- return lo
30
- if value > hi:
31
- return hi
32
- return value
33
-
34
-
35
28
  class _ScreenFade(Protocol):
36
29
  screen_fade_alpha: float
37
30
 
@@ -57,6 +50,7 @@ class BaseGameplayMode:
57
50
  self._hud_missing: list[str] = []
58
51
  self._small: SmallFontData | None = None
59
52
  self._hud_assets: HudAssets | None = None
53
+ self._hud_state = HudState()
60
54
 
61
55
  self._default_game_mode_id = int(default_game_mode_id)
62
56
  self._config = config
@@ -141,7 +135,7 @@ class BaseGameplayMode:
141
135
  return
142
136
  hp = float(getattr(creature, "hp", 0.0))
143
137
  max_hp = float(getattr(creature, "max_hp", 0.0))
144
- if hp <= 0.0 or max_hp <= 0.0:
138
+ if max_hp <= 0.0:
145
139
  return
146
140
 
147
141
  ratio = hp / max_hp
@@ -208,8 +202,8 @@ class BaseGameplayMode:
208
202
  mouse = rl.get_mouse_position()
209
203
  screen_w = float(rl.get_screen_width())
210
204
  screen_h = float(rl.get_screen_height())
211
- self._ui_mouse_x = _clamp(float(mouse.x), 0.0, max(0.0, screen_w - 1.0))
212
- self._ui_mouse_y = _clamp(float(mouse.y), 0.0, max(0.0, screen_h - 1.0))
205
+ self._ui_mouse_x = clamp(float(mouse.x), 0.0, max(0.0, screen_w - 1.0))
206
+ self._ui_mouse_y = clamp(float(mouse.y), 0.0, max(0.0, screen_h - 1.0))
213
207
 
214
208
  def _tick_frame(self, dt: float, *, clamp_cursor_pulse: bool = False) -> tuple[float, float]:
215
209
  dt_frame = float(dt)
@@ -248,6 +242,7 @@ class BaseGameplayMode:
248
242
  self._hud_assets = load_hud_assets(self._assets_root)
249
243
  if self._hud_assets.missing:
250
244
  self._hud_missing = list(self._hud_assets.missing)
245
+ self._hud_state = HudState()
251
246
 
252
247
  self._game_over_active = False
253
248
  self._game_over_record = None
@@ -283,6 +278,32 @@ class BaseGameplayMode:
283
278
  self._action = None
284
279
  return action
285
280
 
281
+ def _update_game_over_ui(self, dt: float) -> None:
282
+ record = self._game_over_record
283
+ if record is None:
284
+ self._enter_game_over()
285
+ record = self._game_over_record
286
+ if record is None:
287
+ return
288
+
289
+ action = self._game_over_ui.update(
290
+ dt,
291
+ record=record,
292
+ player_name_default=self._player_name_default(),
293
+ play_sfx=self._world.audio_router.play_sfx,
294
+ rand=self._state.rng.rand,
295
+ mouse=self._ui_mouse_pos(),
296
+ )
297
+ if action == "play_again":
298
+ self.open()
299
+ return
300
+ if action == "high_scores":
301
+ self._action = "open_high_scores"
302
+ return
303
+ if action == "main_menu":
304
+ self._action = "back_to_menu"
305
+ self.close_requested = True
306
+
286
307
  def draw_pause_background(self) -> None:
287
308
  self._world.draw(draw_aim_indicators=False)
288
309
 
@@ -0,0 +1,2 @@
1
+ """Reusable gameplay-mode controllers."""
2
+
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from ...gameplay import GameplayState, PlayerState, most_used_weapon_id_for_player
4
+ from ...persistence.highscores import HighScoreRecord
5
+
6
+
7
+ def clamp_shots(fired: int, hit: int) -> tuple[int, int]:
8
+ fired = max(0, int(fired))
9
+ hit = max(0, min(int(hit), fired))
10
+ return fired, hit
11
+
12
+
13
+ def shots_from_state(state: GameplayState, *, player_index: int) -> tuple[int, int]:
14
+ fired = 0
15
+ hit = 0
16
+ try:
17
+ fired = int(state.shots_fired[int(player_index)])
18
+ hit = int(state.shots_hit[int(player_index)])
19
+ except Exception:
20
+ fired = 0
21
+ hit = 0
22
+ return clamp_shots(fired, hit)
23
+
24
+
25
+ def build_highscore_record_for_game_over(
26
+ *,
27
+ state: GameplayState,
28
+ player: PlayerState,
29
+ survival_elapsed_ms: int,
30
+ creature_kill_count: int,
31
+ game_mode_id: int,
32
+ shots_fired: int | None = None,
33
+ shots_hit: int | None = None,
34
+ clamp_shots_hit: bool = True,
35
+ ) -> HighScoreRecord:
36
+ record = HighScoreRecord.blank()
37
+ record.score_xp = int(player.experience)
38
+ record.survival_elapsed_ms = int(survival_elapsed_ms)
39
+ record.creature_kill_count = int(creature_kill_count)
40
+
41
+ weapon_id = most_used_weapon_id_for_player(
42
+ state, player_index=int(player.index), fallback_weapon_id=int(player.weapon_id)
43
+ )
44
+ record.most_used_weapon_id = int(weapon_id)
45
+
46
+ if shots_fired is None or shots_hit is None:
47
+ fired, hit = shots_from_state(state, player_index=int(player.index))
48
+ else:
49
+ fired = int(shots_fired)
50
+ hit = int(shots_hit)
51
+ if clamp_shots_hit:
52
+ fired, hit = clamp_shots(fired, hit)
53
+
54
+ record.shots_fired = fired
55
+ record.shots_hit = hit
56
+ record.game_mode_id = int(game_mode_id)
57
+ return record
58
+
@@ -0,0 +1,325 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable, Sequence
5
+
6
+ import pyray as rl
7
+
8
+ from grim.fonts.small import SmallFontData
9
+ from grim.math import clamp
10
+
11
+ from ...gameplay import GameplayState, PerkSelectionState, PlayerState, perk_selection_current_choices, perk_selection_pick
12
+ from ...perks import PerkId, perk_display_description, perk_display_name
13
+ from ...ui.menu_panel import draw_classic_menu_panel
14
+ from ...ui.perk_menu import (
15
+ PERK_MENU_TRANSITION_MS,
16
+ PerkMenuAssets,
17
+ PerkMenuLayout,
18
+ UiButtonState,
19
+ button_draw,
20
+ button_update,
21
+ button_width,
22
+ draw_menu_item,
23
+ draw_ui_text,
24
+ menu_item_hit_rect,
25
+ perk_menu_compute_layout,
26
+ perk_menu_panel_slide_x,
27
+ ui_origin,
28
+ ui_scale,
29
+ wrap_ui_text,
30
+ )
31
+
32
+ PlaySfxFn = Callable[[str], None]
33
+ OnCloseFn = Callable[[], None]
34
+
35
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
36
+ UI_SPONSOR_COLOR = rl.Color(255, 255, 255, int(255 * 0.5))
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class PerkMenuContext:
41
+ state: GameplayState
42
+ perk_state: PerkSelectionState
43
+ players: Sequence[PlayerState]
44
+ creatures: Sequence[object]
45
+ player: PlayerState
46
+ game_mode: int
47
+ player_count: int
48
+ fx_toggle: int
49
+
50
+ font: SmallFontData | None
51
+ assets: PerkMenuAssets | None
52
+ mouse: rl.Vector2
53
+ fx_detail: bool = False
54
+ play_sfx: PlaySfxFn | None = None
55
+
56
+
57
+ class PerkMenuController:
58
+ def __init__(self, *, cancel_label: str = "Cancel", on_close: OnCloseFn | None = None) -> None:
59
+ self._cancel_label = cancel_label
60
+ self._on_close = on_close
61
+ self.reset()
62
+
63
+ @property
64
+ def open(self) -> bool:
65
+ return bool(self._open)
66
+
67
+ @open.setter
68
+ def open(self, value: bool) -> None:
69
+ if not value and self._open:
70
+ self.close()
71
+ else:
72
+ self._open = bool(value)
73
+
74
+ @property
75
+ def selected_index(self) -> int:
76
+ return int(self._selected_index)
77
+
78
+ @selected_index.setter
79
+ def selected_index(self, value: int) -> None:
80
+ self._selected_index = int(value)
81
+
82
+ @property
83
+ def timeline_ms(self) -> float:
84
+ return float(self._timeline_ms)
85
+
86
+ @timeline_ms.setter
87
+ def timeline_ms(self, value: float) -> None:
88
+ self._timeline_ms = float(value)
89
+
90
+ @property
91
+ def active(self) -> bool:
92
+ return bool(self._open) or self._timeline_ms > 1e-3
93
+
94
+ def reset(self) -> None:
95
+ self._layout = PerkMenuLayout()
96
+ self._cancel_button = UiButtonState(self._cancel_label)
97
+ self._open = False
98
+ self._selected_index = 0
99
+ self._timeline_ms = 0.0
100
+
101
+ def close(self) -> None:
102
+ if not self._open:
103
+ return
104
+ self._open = False
105
+ if self._on_close is not None:
106
+ self._on_close()
107
+
108
+ def open_if_available(self, ctx: PerkMenuContext) -> bool:
109
+ if self._open:
110
+ return True
111
+ if ctx.assets is None:
112
+ return False
113
+ choices = perk_selection_current_choices(
114
+ ctx.state,
115
+ ctx.players,
116
+ ctx.perk_state,
117
+ game_mode=int(ctx.game_mode),
118
+ player_count=int(ctx.player_count),
119
+ )
120
+ if not choices:
121
+ self._open = False
122
+ return False
123
+ if ctx.play_sfx is not None:
124
+ ctx.play_sfx("sfx_ui_panelclick")
125
+ self._open = True
126
+ self._selected_index = 0
127
+ return True
128
+
129
+ def tick_timeline(self, dt_ui_ms: float) -> None:
130
+ if self._open:
131
+ self._timeline_ms = clamp(self._timeline_ms + float(dt_ui_ms), 0.0, PERK_MENU_TRANSITION_MS)
132
+ else:
133
+ self._timeline_ms = clamp(self._timeline_ms - float(dt_ui_ms), 0.0, PERK_MENU_TRANSITION_MS)
134
+
135
+ def handle_input(self, ctx: PerkMenuContext, *, dt_frame: float, dt_ui_ms: float) -> None:
136
+ if ctx.assets is None:
137
+ self.close()
138
+ return
139
+
140
+ choices = perk_selection_current_choices(
141
+ ctx.state,
142
+ ctx.players,
143
+ ctx.perk_state,
144
+ game_mode=int(ctx.game_mode),
145
+ player_count=int(ctx.player_count),
146
+ )
147
+ if not choices:
148
+ self.close()
149
+ return
150
+
151
+ if self._selected_index >= len(choices):
152
+ self._selected_index = 0
153
+
154
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
155
+ self._selected_index = (self._selected_index + 1) % len(choices)
156
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
157
+ self._selected_index = (self._selected_index - 1) % len(choices)
158
+
159
+ screen_w = float(rl.get_screen_width())
160
+ screen_h = float(rl.get_screen_height())
161
+ scale = ui_scale(screen_w, screen_h)
162
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
163
+ slide_x = perk_menu_panel_slide_x(self._timeline_ms, width=self._layout.panel_w)
164
+
165
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
166
+
167
+ master_owned = int(ctx.player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
168
+ expert_owned = int(ctx.player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
169
+ computed = perk_menu_compute_layout(
170
+ self._layout,
171
+ screen_w=screen_w,
172
+ origin_x=origin_x,
173
+ origin_y=origin_y,
174
+ scale=scale,
175
+ choice_count=len(choices),
176
+ expert_owned=expert_owned,
177
+ master_owned=master_owned,
178
+ panel_slide_x=slide_x,
179
+ )
180
+
181
+ for idx, perk_id in enumerate(choices):
182
+ label = perk_display_name(int(perk_id), fx_toggle=int(ctx.fx_toggle))
183
+ item_x = computed.list_x
184
+ item_y = computed.list_y + float(idx) * computed.list_step_y
185
+ rect = menu_item_hit_rect(ctx.font, label, x=item_x, y=item_y, scale=scale)
186
+ if rl.check_collision_point_rec(ctx.mouse, rect):
187
+ self._selected_index = idx
188
+ if click:
189
+ if ctx.play_sfx is not None:
190
+ ctx.play_sfx("sfx_ui_buttonclick")
191
+ picked = perk_selection_pick(
192
+ ctx.state,
193
+ ctx.players,
194
+ ctx.perk_state,
195
+ idx,
196
+ game_mode=int(ctx.game_mode),
197
+ player_count=int(ctx.player_count),
198
+ dt=float(dt_frame),
199
+ creatures=ctx.creatures,
200
+ )
201
+ if picked is not None and ctx.play_sfx is not None:
202
+ ctx.play_sfx("sfx_ui_bonus")
203
+ self.close()
204
+ return
205
+ break
206
+
207
+ cancel_w = button_width(
208
+ ctx.font,
209
+ self._cancel_button.label,
210
+ scale=scale,
211
+ force_wide=self._cancel_button.force_wide,
212
+ )
213
+ cancel_x = computed.cancel_x
214
+ cancel_y = computed.cancel_y
215
+ if button_update(
216
+ self._cancel_button,
217
+ x=cancel_x,
218
+ y=cancel_y,
219
+ width=cancel_w,
220
+ dt_ms=float(dt_ui_ms),
221
+ mouse=ctx.mouse,
222
+ click=click,
223
+ ):
224
+ if ctx.play_sfx is not None:
225
+ ctx.play_sfx("sfx_ui_buttonclick")
226
+ self.close()
227
+ return
228
+
229
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) or rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
230
+ if ctx.play_sfx is not None:
231
+ ctx.play_sfx("sfx_ui_buttonclick")
232
+ picked = perk_selection_pick(
233
+ ctx.state,
234
+ ctx.players,
235
+ ctx.perk_state,
236
+ self._selected_index,
237
+ game_mode=int(ctx.game_mode),
238
+ player_count=int(ctx.player_count),
239
+ dt=float(dt_frame),
240
+ creatures=ctx.creatures,
241
+ )
242
+ if picked is not None and ctx.play_sfx is not None:
243
+ ctx.play_sfx("sfx_ui_bonus")
244
+ self.close()
245
+
246
+ def draw(self, ctx: PerkMenuContext) -> None:
247
+ menu_t = clamp(self._timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
248
+ if menu_t <= 1e-3:
249
+ return
250
+ if ctx.assets is None:
251
+ return
252
+
253
+ choices = perk_selection_current_choices(
254
+ ctx.state,
255
+ ctx.players,
256
+ ctx.perk_state,
257
+ game_mode=int(ctx.game_mode),
258
+ player_count=int(ctx.player_count),
259
+ )
260
+ if not choices:
261
+ return
262
+
263
+ screen_w = float(rl.get_screen_width())
264
+ screen_h = float(rl.get_screen_height())
265
+ scale = ui_scale(screen_w, screen_h)
266
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
267
+ slide_x = perk_menu_panel_slide_x(self._timeline_ms, width=self._layout.panel_w)
268
+
269
+ master_owned = int(ctx.player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
270
+ expert_owned = int(ctx.player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
271
+ computed = perk_menu_compute_layout(
272
+ self._layout,
273
+ screen_w=screen_w,
274
+ origin_x=origin_x,
275
+ origin_y=origin_y,
276
+ scale=scale,
277
+ choice_count=len(choices),
278
+ expert_owned=expert_owned,
279
+ master_owned=master_owned,
280
+ panel_slide_x=slide_x,
281
+ )
282
+
283
+ panel_tex = ctx.assets.menu_panel
284
+ if panel_tex is not None:
285
+ draw_classic_menu_panel(panel_tex, dst=computed.panel, shadow=bool(ctx.fx_detail))
286
+
287
+ title_tex = ctx.assets.title_pick_perk
288
+ if title_tex is not None:
289
+ src = rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height))
290
+ rl.draw_texture_pro(title_tex, src, computed.title, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
291
+
292
+ sponsor = None
293
+ if master_owned:
294
+ sponsor = "extra perks sponsored by the Perk Master"
295
+ elif expert_owned:
296
+ sponsor = "extra perk sponsored by the Perk Expert"
297
+ if sponsor:
298
+ draw_ui_text(ctx.font, sponsor, computed.sponsor_x, computed.sponsor_y, scale=scale, color=UI_SPONSOR_COLOR)
299
+
300
+ for idx, perk_id in enumerate(choices):
301
+ label = perk_display_name(int(perk_id), fx_toggle=int(ctx.fx_toggle))
302
+ item_x = computed.list_x
303
+ item_y = computed.list_y + float(idx) * computed.list_step_y
304
+ rect = menu_item_hit_rect(ctx.font, label, x=item_x, y=item_y, scale=scale)
305
+ hovered = rl.check_collision_point_rec(ctx.mouse, rect) or (idx == self._selected_index)
306
+ draw_menu_item(ctx.font, label, x=item_x, y=item_y, scale=scale, hovered=hovered)
307
+
308
+ selected = choices[self._selected_index]
309
+ desc = perk_display_description(int(selected), fx_toggle=int(ctx.fx_toggle))
310
+ desc_x = float(computed.desc.x)
311
+ desc_y = float(computed.desc.y)
312
+ desc_w = float(computed.desc.width)
313
+ desc_h = float(computed.desc.height)
314
+ desc_scale = scale * 0.85
315
+ desc_lines = wrap_ui_text(ctx.font, desc, max_width=desc_w, scale=desc_scale)
316
+ line_h = float(ctx.font.cell_size * desc_scale) if ctx.font is not None else float(20 * desc_scale)
317
+ y = desc_y
318
+ for line in desc_lines:
319
+ if y + line_h > desc_y + desc_h:
320
+ break
321
+ draw_ui_text(ctx.font, line, desc_x, y, scale=desc_scale, color=UI_TEXT_COLOR)
322
+ y += line_h
323
+
324
+ cancel_w = button_width(ctx.font, self._cancel_button.label, scale=scale, force_wide=self._cancel_button.force_wide)
325
+ button_draw(ctx.assets, ctx.font, self._cancel_button, x=computed.cancel_x, y=computed.cancel_y, width=cancel_w, scale=scale)