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
crimson/game.py CHANGED
@@ -38,7 +38,7 @@ from grim.terrain_render import GroundRenderer
38
38
  from grim.view import View, ViewContext
39
39
  from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
40
40
 
41
- from .debug import debug_enabled
41
+ from .debug import debug_enabled, set_debug_enabled
42
42
  from grim import music
43
43
 
44
44
  from .demo import DemoView
@@ -57,12 +57,78 @@ from .frontend.high_scores_layout import (
57
57
  HS_BUTTON_STEP_Y,
58
58
  HS_BUTTON_X,
59
59
  HS_BUTTON_Y0,
60
+ HS_LOCAL_DATE_X,
61
+ HS_LOCAL_DATE_Y,
62
+ HS_LOCAL_FRAGS_X,
63
+ HS_LOCAL_FRAGS_Y,
64
+ HS_LOCAL_HIT_X,
65
+ HS_LOCAL_HIT_Y,
66
+ HS_LOCAL_LABEL_X,
67
+ HS_LOCAL_LABEL_Y,
68
+ HS_LOCAL_NAME_X,
69
+ HS_LOCAL_NAME_Y,
70
+ HS_LOCAL_RANK_X,
71
+ HS_LOCAL_RANK_Y,
72
+ HS_LOCAL_SCORE_LABEL_X,
73
+ HS_LOCAL_SCORE_LABEL_Y,
74
+ HS_LOCAL_SCORE_VALUE_X,
75
+ HS_LOCAL_SCORE_VALUE_Y,
76
+ HS_LOCAL_TIME_LABEL_X,
77
+ HS_LOCAL_TIME_LABEL_Y,
78
+ HS_LOCAL_TIME_VALUE_X,
79
+ HS_LOCAL_TIME_VALUE_Y,
80
+ HS_LOCAL_WEAPON_X,
81
+ HS_LOCAL_WEAPON_Y,
82
+ HS_LOCAL_WICON_X,
83
+ HS_LOCAL_WICON_Y,
60
84
  HS_LEFT_PANEL_HEIGHT,
61
85
  HS_LEFT_PANEL_POS_X,
62
86
  HS_LEFT_PANEL_POS_Y,
87
+ HS_QUEST_ARROW_X,
88
+ HS_QUEST_ARROW_Y,
89
+ HS_RIGHT_CHECK_X,
90
+ HS_RIGHT_CHECK_Y,
63
91
  HS_RIGHT_PANEL_HEIGHT,
64
92
  HS_RIGHT_PANEL_POS_X,
65
93
  HS_RIGHT_PANEL_POS_Y,
94
+ HS_RIGHT_GAME_MODE_DROP_X,
95
+ HS_RIGHT_GAME_MODE_DROP_Y,
96
+ HS_RIGHT_GAME_MODE_VALUE_X,
97
+ HS_RIGHT_GAME_MODE_VALUE_Y,
98
+ HS_RIGHT_GAME_MODE_WIDGET_W,
99
+ HS_RIGHT_GAME_MODE_WIDGET_X,
100
+ HS_RIGHT_GAME_MODE_WIDGET_Y,
101
+ HS_RIGHT_GAME_MODE_X,
102
+ HS_RIGHT_GAME_MODE_Y,
103
+ HS_RIGHT_NUMBER_PLAYERS_X,
104
+ HS_RIGHT_NUMBER_PLAYERS_Y,
105
+ HS_RIGHT_PLAYER_COUNT_DROP_X,
106
+ HS_RIGHT_PLAYER_COUNT_DROP_Y,
107
+ HS_RIGHT_PLAYER_COUNT_VALUE_X,
108
+ HS_RIGHT_PLAYER_COUNT_VALUE_Y,
109
+ HS_RIGHT_PLAYER_COUNT_WIDGET_W,
110
+ HS_RIGHT_PLAYER_COUNT_WIDGET_X,
111
+ HS_RIGHT_PLAYER_COUNT_WIDGET_Y,
112
+ HS_RIGHT_SCORE_LIST_DROP_X,
113
+ HS_RIGHT_SCORE_LIST_DROP_Y,
114
+ HS_RIGHT_SCORE_LIST_VALUE_X,
115
+ HS_RIGHT_SCORE_LIST_VALUE_Y,
116
+ HS_RIGHT_SCORE_LIST_WIDGET_W,
117
+ HS_RIGHT_SCORE_LIST_WIDGET_X,
118
+ HS_RIGHT_SCORE_LIST_WIDGET_Y,
119
+ HS_RIGHT_SCORE_LIST_X,
120
+ HS_RIGHT_SCORE_LIST_Y,
121
+ HS_RIGHT_SHOW_INTERNET_X,
122
+ HS_RIGHT_SHOW_INTERNET_Y,
123
+ HS_RIGHT_SHOW_SCORES_DROP_X,
124
+ HS_RIGHT_SHOW_SCORES_DROP_Y,
125
+ HS_RIGHT_SHOW_SCORES_VALUE_X,
126
+ HS_RIGHT_SHOW_SCORES_VALUE_Y,
127
+ HS_RIGHT_SHOW_SCORES_WIDGET_W,
128
+ HS_RIGHT_SHOW_SCORES_WIDGET_X,
129
+ HS_RIGHT_SHOW_SCORES_WIDGET_Y,
130
+ HS_RIGHT_SHOW_SCORES_X,
131
+ HS_RIGHT_SHOW_SCORES_Y,
66
132
  )
67
133
  from .frontend.menu import (
68
134
  MENU_PANEL_HEIGHT,
@@ -113,6 +179,7 @@ class GameConfig:
113
179
  seed: int | None = None
114
180
  demo_enabled: bool = False
115
181
  no_intro: bool = False
182
+ debug: bool = False
116
183
 
117
184
 
118
185
  @dataclass(slots=True)
@@ -190,6 +257,25 @@ QUEST_BACK_BUTTON_X_OFFSET = 138.0
190
257
  QUEST_BACK_BUTTON_Y_OFFSET = 212.0
191
258
  QUEST_PANEL_HEIGHT = 378.0
192
259
 
260
+ # game_update_victory_screen (0x00406350): used as the "end note" screen after the final quest.
261
+ END_NOTE_PANEL_POS_X = -45.0
262
+ END_NOTE_PANEL_POS_Y = 110.0
263
+ END_NOTE_PANEL_GEOM_X0 = -63.0
264
+ END_NOTE_PANEL_GEOM_Y0 = -81.0
265
+ END_NOTE_PANEL_W = 510.0
266
+ END_NOTE_PANEL_H = 378.0
267
+
268
+ END_NOTE_HEADER_X_OFFSET = 214.0 # v11 + 44 - 10 in the decompile, relative to panel-left
269
+ END_NOTE_HEADER_Y_OFFSET = 46.0 # (base_y + 40) + 6 in the decompile, relative to panel-top
270
+ END_NOTE_BODY_X_OFFSET = END_NOTE_HEADER_X_OFFSET - 8.0
271
+ END_NOTE_BODY_Y_GAP = 32.0
272
+ END_NOTE_LINE_STEP_Y = 14.0
273
+ END_NOTE_AFTER_BODY_Y_GAP = 22.0 # 14 + 8 in the decompile
274
+
275
+ END_NOTE_BUTTON_X_OFFSET = 266.0 # (v11 + 44 + 20) - 4 + 26, relative to panel-left
276
+ END_NOTE_BUTTON_Y_OFFSET = 210.0 # (base_y + 40) + 170 in the decompile, relative to panel-top
277
+ END_NOTE_BUTTON_STEP_Y = 32.0
278
+
193
279
 
194
280
  class QuestsMenuView:
195
281
  """Quest selection menu.
@@ -282,6 +368,7 @@ class QuestsMenuView:
282
368
  self._cursor_pulse_time += min(dt, 0.1) * 1.1
283
369
 
284
370
  config = self._state.config
371
+ status = self._state.status
285
372
 
286
373
  # The original forcibly clears hardcore in the demo build.
287
374
  if self._state.demo_enabled:
@@ -289,6 +376,14 @@ class QuestsMenuView:
289
376
  config.data["hardcore_flag"] = 0
290
377
  self._dirty = True
291
378
 
379
+ if debug_enabled() and rl.is_key_pressed(rl.KeyboardKey.KEY_F5):
380
+ unlock = 49
381
+ if int(status.quest_unlock_index) < unlock:
382
+ status.quest_unlock_index = unlock
383
+ if int(status.quest_unlock_index_full) < unlock:
384
+ status.quest_unlock_index_full = unlock
385
+ self._state.console.log.log("debug: unlocked all quests")
386
+
292
387
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
293
388
  self._action = "open_play_game"
294
389
  return
@@ -374,7 +469,7 @@ class QuestsMenuView:
374
469
  # `sub_447d40` base sums:
375
470
  # x_sum = <ui_element_x> + <ui_element_offset_x> (x=-5)
376
471
  # y_sum = <ui_element_y> + <ui_element_offset_y> (y=185 + widescreen shift via ui_menu_layout_init)
377
- x_sum = QUEST_MENU_BASE_X + MENU_PANEL_OFFSET_X
472
+ x_sum = QUEST_MENU_BASE_X + QUEST_MENU_PANEL_OFFSET_X
378
473
  y_sum = QUEST_MENU_BASE_Y + MENU_PANEL_OFFSET_Y + self._widescreen_y_shift
379
474
 
380
475
  title_x = x_sum + QUEST_TITLE_X_OFFSET
@@ -1288,6 +1383,9 @@ class QuestResultsView:
1288
1383
  self._action = "start_quest"
1289
1384
  return
1290
1385
  if action == "play_next":
1386
+ if int(self._quest_stage_major) == 5 and int(self._quest_stage_minor) == 10:
1387
+ self._action = "end_note"
1388
+ return
1291
1389
  next_level = _next_quest_level(self._quest_level)
1292
1390
  if next_level is not None:
1293
1391
  self._state.pending_quest_level = next_level
@@ -1336,6 +1434,217 @@ class QuestResultsView:
1336
1434
  self._action = "open_high_scores"
1337
1435
 
1338
1436
 
1437
+ class EndNoteView:
1438
+ """Final quest "Show End Note" flow.
1439
+
1440
+ Classic:
1441
+ - quest_results_screen_update uses "Show End Note" instead of "Play Next" for quest 5.10
1442
+ - clicking it transitions to state 0x15 (game_update_victory_screen @ 0x00406350)
1443
+ """
1444
+
1445
+ def __init__(self, state: GameState) -> None:
1446
+ self._state = state
1447
+ self._ground: GroundRenderer | None = None
1448
+ self._small_font: SmallFontData | None = None
1449
+ self._panel_tex: rl.Texture2D | None = None
1450
+ self._button_textures: UiButtonTextureSet | None = None
1451
+ self._action: str | None = None
1452
+ self._cursor_pulse_time = 0.0
1453
+
1454
+ self._survival_button = UiButtonState("Survival", force_wide=True)
1455
+ self._rush_button = UiButtonState(" Rush ", force_wide=True)
1456
+ self._typo_button = UiButtonState("Typ'o'Shooter", force_wide=True)
1457
+ self._main_menu_button = UiButtonState("Main Menu", force_wide=True)
1458
+
1459
+ def open(self) -> None:
1460
+ self._action = None
1461
+ self._cursor_pulse_time = 0.0
1462
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
1463
+
1464
+ cache = _ensure_texture_cache(self._state)
1465
+ self._panel_tex = cache.get_or_load("ui_menuPanel", "ui/ui_menuPanel.jaz").texture
1466
+ button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
1467
+ button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
1468
+ self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=button_md)
1469
+ self._small_font = None
1470
+
1471
+ def close(self) -> None:
1472
+ self._ground = None
1473
+ self._small_font = None
1474
+ self._panel_tex = None
1475
+ self._button_textures = None
1476
+
1477
+ def update(self, dt: float) -> None:
1478
+ if self._state.audio is not None:
1479
+ update_audio(self._state.audio, dt)
1480
+ if self._ground is not None:
1481
+ self._ground.process_pending()
1482
+ self._cursor_pulse_time += min(float(dt), 0.1) * 1.1
1483
+
1484
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
1485
+ if self._state.audio is not None:
1486
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1487
+ self._action = "back_to_menu"
1488
+ return
1489
+
1490
+ textures = self._button_textures
1491
+ if textures is None or (textures.button_sm is None and textures.button_md is None):
1492
+ return
1493
+
1494
+ screen_w = float(rl.get_screen_width())
1495
+ scale = 1.0
1496
+
1497
+ layout_w = screen_w / scale if scale else screen_w
1498
+ widescreen_shift_y = MenuView._menu_widescreen_y_shift(layout_w)
1499
+
1500
+ panel_left = (END_NOTE_PANEL_GEOM_X0 + END_NOTE_PANEL_POS_X) * scale
1501
+ panel_top = (END_NOTE_PANEL_GEOM_Y0 + END_NOTE_PANEL_POS_Y + widescreen_shift_y) * scale
1502
+
1503
+ button_x = panel_left + END_NOTE_BUTTON_X_OFFSET * scale
1504
+ button_y = panel_top + END_NOTE_BUTTON_Y_OFFSET * scale
1505
+
1506
+ font = self._ensure_small_font()
1507
+ mouse = rl.get_mouse_position()
1508
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1509
+ dt_ms = min(float(dt), 0.1) * 1000.0
1510
+
1511
+ survival_w = button_width(font, self._survival_button.label, scale=scale, force_wide=self._survival_button.force_wide)
1512
+ if button_update(self._survival_button, x=button_x, y=button_y, width=survival_w, dt_ms=dt_ms, mouse=mouse, click=click):
1513
+ self._state.config.data["game_mode"] = 1
1514
+ if self._state.audio is not None:
1515
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1516
+ self._action = "start_survival"
1517
+ return
1518
+
1519
+ button_y += END_NOTE_BUTTON_STEP_Y * scale
1520
+ rush_w = button_width(font, self._rush_button.label, scale=scale, force_wide=self._rush_button.force_wide)
1521
+ if button_update(self._rush_button, x=button_x, y=button_y, width=rush_w, dt_ms=dt_ms, mouse=mouse, click=click):
1522
+ self._state.config.data["game_mode"] = 2
1523
+ if self._state.audio is not None:
1524
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1525
+ self._action = "start_rush"
1526
+ return
1527
+
1528
+ button_y += END_NOTE_BUTTON_STEP_Y * scale
1529
+ typo_w = button_width(font, self._typo_button.label, scale=scale, force_wide=self._typo_button.force_wide)
1530
+ if button_update(self._typo_button, x=button_x, y=button_y, width=typo_w, dt_ms=dt_ms, mouse=mouse, click=click):
1531
+ self._state.config.data["game_mode"] = 4
1532
+ self._state.screen_fade_alpha = 0.0
1533
+ self._state.screen_fade_ramp = True
1534
+ if self._state.audio is not None:
1535
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1536
+ self._action = "start_typo"
1537
+ return
1538
+
1539
+ button_y += END_NOTE_BUTTON_STEP_Y * scale
1540
+ main_w = button_width(font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
1541
+ if button_update(self._main_menu_button, x=button_x, y=button_y, width=main_w, dt_ms=dt_ms, mouse=mouse, click=click):
1542
+ if self._state.audio is not None:
1543
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1544
+ self._action = "back_to_menu"
1545
+ return
1546
+
1547
+ def draw(self) -> None:
1548
+ rl.clear_background(rl.BLACK)
1549
+ pause_background = self._state.pause_background
1550
+ if pause_background is not None:
1551
+ pause_background.draw_pause_background()
1552
+ elif self._ground is not None:
1553
+ self._ground.draw(0.0, 0.0)
1554
+ _draw_screen_fade(self._state)
1555
+
1556
+ panel_tex = self._panel_tex
1557
+ if panel_tex is None:
1558
+ return
1559
+
1560
+ screen_w = float(rl.get_screen_width())
1561
+ scale = 1.0
1562
+ layout_w = screen_w / scale if scale else screen_w
1563
+ widescreen_shift_y = MenuView._menu_widescreen_y_shift(layout_w)
1564
+
1565
+ panel_left = (END_NOTE_PANEL_GEOM_X0 + END_NOTE_PANEL_POS_X) * scale
1566
+ panel_top = (END_NOTE_PANEL_GEOM_Y0 + END_NOTE_PANEL_POS_Y + widescreen_shift_y) * scale
1567
+ panel = rl.Rectangle(
1568
+ float(panel_left),
1569
+ float(panel_top),
1570
+ float(END_NOTE_PANEL_W * scale),
1571
+ float(END_NOTE_PANEL_H * scale),
1572
+ )
1573
+
1574
+ fx_detail = bool(int(self._state.config.data.get("fx_detail_0", 0) or 0))
1575
+ draw_classic_menu_panel(panel_tex, dst=panel, tint=rl.WHITE, shadow=fx_detail)
1576
+
1577
+ font = self._ensure_small_font()
1578
+ hardcore = bool(int(self._state.config.data.get("hardcore_flag", 0) or 0))
1579
+ header = " Incredible!" if hardcore else "Congratulations!"
1580
+ body_lines = (
1581
+ [
1582
+ "You've done the thing we all thought was",
1583
+ "virtually impossible. To reward your",
1584
+ "efforts a new weapon has been unlocked ",
1585
+ "for you: Splitter Gun.",
1586
+ "",
1587
+ "",
1588
+ ]
1589
+ if hardcore
1590
+ else [
1591
+ "You've completed all the levels but the battle",
1592
+ "isn't over yet! With all of the unlocked perks",
1593
+ "and weapons your Survival is just a bit easier.",
1594
+ "You can also replay the quests in Hardcore.",
1595
+ "As an additional reward for your victorious",
1596
+ "playing, a completely new and different game",
1597
+ "mode is unlocked for you: Typ'o'Shooter.",
1598
+ ]
1599
+ )
1600
+
1601
+ header_x = panel_left + END_NOTE_HEADER_X_OFFSET * scale
1602
+ header_y = panel_top + END_NOTE_HEADER_Y_OFFSET * scale
1603
+ header_color = rl.Color(255, 255, 255, int(255 * 0.8))
1604
+ body_color = rl.Color(255, 255, 255, int(255 * 0.5))
1605
+
1606
+ draw_small_text(font, header, header_x, header_y, 1.5 * scale, header_color)
1607
+
1608
+ body_x = panel_left + END_NOTE_BODY_X_OFFSET * scale
1609
+ body_y = header_y + END_NOTE_BODY_Y_GAP * scale
1610
+ for idx, line in enumerate(body_lines):
1611
+ draw_small_text(font, line, body_x, body_y, 1.0 * scale, body_color)
1612
+ if idx != len(body_lines) - 1:
1613
+ body_y += END_NOTE_LINE_STEP_Y * scale
1614
+ body_y += END_NOTE_AFTER_BODY_Y_GAP * scale
1615
+ draw_small_text(font, "Good luck with your battles, trooper!", body_x, body_y, 1.0 * scale, body_color)
1616
+
1617
+ textures = self._button_textures
1618
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
1619
+ button_x = panel_left + END_NOTE_BUTTON_X_OFFSET * scale
1620
+ button_y = panel_top + END_NOTE_BUTTON_Y_OFFSET * scale
1621
+ survival_w = button_width(font, self._survival_button.label, scale=scale, force_wide=self._survival_button.force_wide)
1622
+ button_draw(textures, font, self._survival_button, x=button_x, y=button_y, width=survival_w, scale=scale)
1623
+ button_y += END_NOTE_BUTTON_STEP_Y * scale
1624
+ rush_w = button_width(font, self._rush_button.label, scale=scale, force_wide=self._rush_button.force_wide)
1625
+ button_draw(textures, font, self._rush_button, x=button_x, y=button_y, width=rush_w, scale=scale)
1626
+ button_y += END_NOTE_BUTTON_STEP_Y * scale
1627
+ typo_w = button_width(font, self._typo_button.label, scale=scale, force_wide=self._typo_button.force_wide)
1628
+ button_draw(textures, font, self._typo_button, x=button_x, y=button_y, width=typo_w, scale=scale)
1629
+ button_y += END_NOTE_BUTTON_STEP_Y * scale
1630
+ main_w = button_width(font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
1631
+ button_draw(textures, font, self._main_menu_button, x=button_x, y=button_y, width=main_w, scale=scale)
1632
+
1633
+ _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
1634
+
1635
+ def take_action(self) -> str | None:
1636
+ action = self._action
1637
+ self._action = None
1638
+ return action
1639
+
1640
+ def _ensure_small_font(self) -> SmallFontData:
1641
+ if self._small_font is not None:
1642
+ return self._small_font
1643
+ missing_assets: list[str] = []
1644
+ self._small_font = load_small_font(self._state.assets_dir, missing_assets)
1645
+ return self._small_font
1646
+
1647
+
1339
1648
  class QuestFailedView:
1340
1649
  def __init__(self, state: GameState) -> None:
1341
1650
  self._state = state
@@ -1558,6 +1867,10 @@ class HighScoresView:
1558
1867
  self._small_font: SmallFontData | None = None
1559
1868
  self._button_tex: rl.Texture2D | None = None
1560
1869
  self._button_textures: UiButtonTextureSet | None = None
1870
+ self._check_on: rl.Texture2D | None = None
1871
+ self._drop_off: rl.Texture2D | None = None
1872
+ self._arrow_tex: rl.Texture2D | None = None
1873
+ self._wicons_tex: rl.Texture2D | None = None
1561
1874
  self._update_button = UiButtonState("Update scores", force_wide=True)
1562
1875
  self._play_button = UiButtonState("Play a game", force_wide=True)
1563
1876
  self._back_button = UiButtonState("Back", force_wide=False)
@@ -1588,6 +1901,16 @@ class HighScoresView:
1588
1901
  self._button_tex = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
1589
1902
  button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
1590
1903
  self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=self._button_tex)
1904
+ self._check_on = cache.get_or_load("ui_checkOn", "ui/ui_checkOn.jaz").texture
1905
+ self._drop_off = cache.get_or_load("ui_dropOff", "ui/ui_dropDownOff.jaz").texture
1906
+ self._arrow_tex = cache.get_or_load("ui_arrow", "ui/ui_arrow.jaz").texture
1907
+
1908
+ if self._wicons_tex is not None:
1909
+ rl.unload_texture(self._wicons_tex)
1910
+ self._wicons_tex = None
1911
+ wicons_path = self._state.assets_dir / "crimson" / "ui" / "ui_wicons.png"
1912
+ if wicons_path.is_file():
1913
+ self._wicons_tex = rl.load_texture(str(wicons_path))
1591
1914
 
1592
1915
  request = self._state.pending_high_scores
1593
1916
  self._state.pending_high_scores = None
@@ -1623,9 +1946,15 @@ class HighScoresView:
1623
1946
  if self._small_font is not None:
1624
1947
  rl.unload_texture(self._small_font.texture)
1625
1948
  self._small_font = None
1949
+ if self._wicons_tex is not None:
1950
+ rl.unload_texture(self._wicons_tex)
1951
+ self._wicons_tex = None
1626
1952
  self._assets = None
1627
1953
  self._button_tex = None
1628
1954
  self._button_textures = None
1955
+ self._check_on = None
1956
+ self._drop_off = None
1957
+ self._arrow_tex = None
1629
1958
  self._request = None
1630
1959
  self._records = []
1631
1960
  self._scroll_index = 0
@@ -1779,10 +2108,22 @@ class HighScoresView:
1779
2108
  )
1780
2109
 
1781
2110
  title = "High scores - Quests" if int(mode_id) == 3 else f"High scores - {self._mode_label(mode_id, quest_major, quest_minor)}"
1782
- draw_small_text(font, title, left_x0 + 269.0 * scale, left_y0 + 41.0 * scale, 1.0 * scale, rl.Color(255, 255, 255, 255))
2111
+ title_x = 269.0
2112
+ if int(mode_id) == 1:
2113
+ # state_14:High scores - Survival title at x=168 (panel left_x0 is -98).
2114
+ title_x = 266.0
2115
+ draw_small_text(font, title, left_x0 + title_x * scale, left_y0 + 41.0 * scale, 1.0 * scale, rl.Color(255, 255, 255, 255))
1783
2116
  if int(mode_id) == 3:
1784
2117
  quest_label = f"{int(quest_major)}.{int(quest_minor)}: {self._quest_title(quest_major, quest_minor)}"
1785
2118
  draw_small_text(font, quest_label, left_x0 + 236.0 * scale, left_y0 + 63.0 * scale, 1.0 * scale, rl.Color(255, 255, 255, 255))
2119
+ arrow = self._arrow_tex
2120
+ if arrow is not None:
2121
+ dst_w = float(arrow.width) * scale
2122
+ dst_h = float(arrow.height) * scale
2123
+ # state_14 draws ui_arrow.jaz flipped (uv 1..0) to point left.
2124
+ src = rl.Rectangle(float(arrow.width), 0.0, -float(arrow.width), float(arrow.height))
2125
+ dst = rl.Rectangle(left_x0 + HS_QUEST_ARROW_X * scale, left_y0 + HS_QUEST_ARROW_Y * scale, dst_w, dst_h)
2126
+ rl.draw_texture_pro(arrow, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
1786
2127
 
1787
2128
  header_color = rl.Color(255, 255, 255, int(255 * 0.85))
1788
2129
  row_y0 = left_y0 + 84.0 * scale
@@ -1841,9 +2182,351 @@ class HighScoresView:
1841
2182
  scale=scale,
1842
2183
  )
1843
2184
 
2185
+ self._draw_right_panel(font=font, right_x0=right_x0, right_y0=right_y0, scale=scale, mode_id=mode_id, highlight_rank=highlight_rank)
1844
2186
  self._draw_sign(assets)
1845
2187
  _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
1846
2188
 
2189
+ def _draw_right_panel(
2190
+ self,
2191
+ *,
2192
+ font: SmallFontData,
2193
+ right_x0: float,
2194
+ right_y0: float,
2195
+ scale: float,
2196
+ mode_id: int,
2197
+ highlight_rank: int | None,
2198
+ ) -> None:
2199
+ if int(mode_id) == 3:
2200
+ self._draw_right_panel_quest_options(font=font, right_x0=right_x0, right_y0=right_y0, scale=scale)
2201
+ return
2202
+ self._draw_right_panel_local_score(
2203
+ font=font,
2204
+ right_x0=right_x0,
2205
+ right_y0=right_y0,
2206
+ scale=scale,
2207
+ highlight_rank=highlight_rank,
2208
+ )
2209
+
2210
+ def _draw_right_panel_quest_options(self, *, font: SmallFontData, right_x0: float, right_y0: float, scale: float) -> None:
2211
+ text_scale = 1.0 * scale
2212
+ text_color = rl.Color(255, 255, 255, int(255 * 0.8))
2213
+
2214
+ check_on = self._check_on
2215
+ if check_on is not None:
2216
+ check_w = float(check_on.width) * scale
2217
+ check_h = float(check_on.height) * scale
2218
+ rl.draw_texture_pro(
2219
+ check_on,
2220
+ rl.Rectangle(0.0, 0.0, float(check_on.width), float(check_on.height)),
2221
+ rl.Rectangle(right_x0 + HS_RIGHT_CHECK_X * scale, right_y0 + HS_RIGHT_CHECK_Y * scale, check_w, check_h),
2222
+ rl.Vector2(0.0, 0.0),
2223
+ 0.0,
2224
+ rl.WHITE,
2225
+ )
2226
+ draw_small_text(
2227
+ font,
2228
+ "Show internet scores",
2229
+ right_x0 + HS_RIGHT_SHOW_INTERNET_X * scale,
2230
+ right_y0 + HS_RIGHT_SHOW_INTERNET_Y * scale,
2231
+ text_scale,
2232
+ text_color,
2233
+ )
2234
+ draw_small_text(
2235
+ font,
2236
+ "Number of players",
2237
+ right_x0 + HS_RIGHT_NUMBER_PLAYERS_X * scale,
2238
+ right_y0 + HS_RIGHT_NUMBER_PLAYERS_Y * scale,
2239
+ text_scale,
2240
+ text_color,
2241
+ )
2242
+ draw_small_text(
2243
+ font,
2244
+ "Game mode",
2245
+ right_x0 + HS_RIGHT_GAME_MODE_X * scale,
2246
+ right_y0 + HS_RIGHT_GAME_MODE_Y * scale,
2247
+ text_scale,
2248
+ text_color,
2249
+ )
2250
+ draw_small_text(
2251
+ font,
2252
+ "Show scores:",
2253
+ right_x0 + HS_RIGHT_SHOW_SCORES_X * scale,
2254
+ right_y0 + HS_RIGHT_SHOW_SCORES_Y * scale,
2255
+ text_scale,
2256
+ text_color,
2257
+ )
2258
+ draw_small_text(
2259
+ font,
2260
+ "Selected score list:",
2261
+ right_x0 + HS_RIGHT_SCORE_LIST_X * scale,
2262
+ right_y0 + HS_RIGHT_SCORE_LIST_Y * scale,
2263
+ text_scale,
2264
+ text_color,
2265
+ )
2266
+
2267
+ # Closed list widgets (state_14 quest variant): white border + black fill.
2268
+ widget_h = 16.0 * scale
2269
+ for wx, wy, ww in (
2270
+ (HS_RIGHT_PLAYER_COUNT_WIDGET_X, HS_RIGHT_PLAYER_COUNT_WIDGET_Y, HS_RIGHT_PLAYER_COUNT_WIDGET_W),
2271
+ (HS_RIGHT_GAME_MODE_WIDGET_X, HS_RIGHT_GAME_MODE_WIDGET_Y, HS_RIGHT_GAME_MODE_WIDGET_W),
2272
+ (HS_RIGHT_SHOW_SCORES_WIDGET_X, HS_RIGHT_SHOW_SCORES_WIDGET_Y, HS_RIGHT_SHOW_SCORES_WIDGET_W),
2273
+ (HS_RIGHT_SCORE_LIST_WIDGET_X, HS_RIGHT_SCORE_LIST_WIDGET_Y, HS_RIGHT_SCORE_LIST_WIDGET_W),
2274
+ ):
2275
+ x = right_x0 + float(wx) * scale
2276
+ y = right_y0 + float(wy) * scale
2277
+ w = float(ww) * scale
2278
+ rl.draw_rectangle(int(x), int(y), int(w), int(widget_h), rl.WHITE)
2279
+ rl.draw_rectangle(int(x) + 1, int(y) + 1, max(0, int(w) - 2), max(0, int(widget_h) - 2), rl.BLACK)
2280
+
2281
+ # Values (static in the oracle).
2282
+ draw_small_text(
2283
+ font,
2284
+ "1 player",
2285
+ right_x0 + HS_RIGHT_PLAYER_COUNT_VALUE_X * scale,
2286
+ right_y0 + HS_RIGHT_PLAYER_COUNT_VALUE_Y * scale,
2287
+ text_scale,
2288
+ text_color,
2289
+ )
2290
+ draw_small_text(
2291
+ font,
2292
+ "Quests",
2293
+ right_x0 + HS_RIGHT_GAME_MODE_VALUE_X * scale,
2294
+ right_y0 + HS_RIGHT_GAME_MODE_VALUE_Y * scale,
2295
+ text_scale,
2296
+ text_color,
2297
+ )
2298
+ draw_small_text(
2299
+ font,
2300
+ "Best of all time",
2301
+ right_x0 + HS_RIGHT_SHOW_SCORES_VALUE_X * scale,
2302
+ right_y0 + HS_RIGHT_SHOW_SCORES_VALUE_Y * scale,
2303
+ text_scale,
2304
+ text_color,
2305
+ )
2306
+ draw_small_text(
2307
+ font,
2308
+ "default",
2309
+ right_x0 + HS_RIGHT_SCORE_LIST_VALUE_X * scale,
2310
+ right_y0 + HS_RIGHT_SCORE_LIST_VALUE_Y * scale,
2311
+ text_scale,
2312
+ text_color,
2313
+ )
2314
+
2315
+ drop_off = self._drop_off
2316
+ if drop_off is None:
2317
+ return
2318
+ drop_w = float(drop_off.width) * scale
2319
+ drop_h = float(drop_off.height) * scale
2320
+ for dx, dy in (
2321
+ (HS_RIGHT_PLAYER_COUNT_DROP_X, HS_RIGHT_PLAYER_COUNT_DROP_Y),
2322
+ (HS_RIGHT_GAME_MODE_DROP_X, HS_RIGHT_GAME_MODE_DROP_Y),
2323
+ (HS_RIGHT_SHOW_SCORES_DROP_X, HS_RIGHT_SHOW_SCORES_DROP_Y),
2324
+ (HS_RIGHT_SCORE_LIST_DROP_X, HS_RIGHT_SCORE_LIST_DROP_Y),
2325
+ ):
2326
+ rl.draw_texture_pro(
2327
+ drop_off,
2328
+ rl.Rectangle(0.0, 0.0, float(drop_off.width), float(drop_off.height)),
2329
+ rl.Rectangle(right_x0 + float(dx) * scale, right_y0 + float(dy) * scale, drop_w, drop_h),
2330
+ rl.Vector2(0.0, 0.0),
2331
+ 0.0,
2332
+ rl.WHITE,
2333
+ )
2334
+
2335
+ def _draw_right_panel_local_score(
2336
+ self,
2337
+ *,
2338
+ font: SmallFontData,
2339
+ right_x0: float,
2340
+ right_y0: float,
2341
+ scale: float,
2342
+ highlight_rank: int | None,
2343
+ ) -> None:
2344
+ if not self._records:
2345
+ return
2346
+ idx = int(highlight_rank) if highlight_rank is not None else int(self._scroll_index)
2347
+ if idx < 0:
2348
+ idx = 0
2349
+ if idx >= len(self._records):
2350
+ idx = len(self._records) - 1
2351
+ entry = self._records[idx]
2352
+
2353
+ text_scale = 1.0 * scale
2354
+ text_color = rl.Color(255, 255, 255, int(255 * 0.8))
2355
+
2356
+ name = ""
2357
+ try:
2358
+ name = str(entry.name())
2359
+ except Exception:
2360
+ name = ""
2361
+ if not name:
2362
+ name = "???"
2363
+ draw_small_text(font, name, right_x0 + HS_LOCAL_NAME_X * scale, right_y0 + HS_LOCAL_NAME_Y * scale, text_scale, text_color)
2364
+ draw_small_text(
2365
+ font,
2366
+ "Local score",
2367
+ right_x0 + HS_LOCAL_LABEL_X * scale,
2368
+ right_y0 + HS_LOCAL_LABEL_Y * scale,
2369
+ text_scale,
2370
+ text_color,
2371
+ )
2372
+
2373
+ date_text = self._format_score_date(entry)
2374
+ if date_text:
2375
+ draw_small_text(
2376
+ font,
2377
+ date_text,
2378
+ right_x0 + HS_LOCAL_DATE_X * scale,
2379
+ right_y0 + HS_LOCAL_DATE_Y * scale,
2380
+ text_scale,
2381
+ text_color,
2382
+ )
2383
+
2384
+ draw_small_text(
2385
+ font,
2386
+ "Score",
2387
+ right_x0 + HS_LOCAL_SCORE_LABEL_X * scale,
2388
+ right_y0 + HS_LOCAL_SCORE_LABEL_Y * scale,
2389
+ text_scale,
2390
+ text_color,
2391
+ )
2392
+ draw_small_text(
2393
+ font,
2394
+ "Game time",
2395
+ right_x0 + HS_LOCAL_TIME_LABEL_X * scale,
2396
+ right_y0 + HS_LOCAL_TIME_LABEL_Y * scale,
2397
+ text_scale,
2398
+ text_color,
2399
+ )
2400
+
2401
+ score_value = f"{int(getattr(entry, 'score_xp', 0))}"
2402
+ draw_small_text(
2403
+ font,
2404
+ score_value,
2405
+ right_x0 + HS_LOCAL_SCORE_VALUE_X * scale,
2406
+ right_y0 + HS_LOCAL_SCORE_VALUE_Y * scale,
2407
+ text_scale,
2408
+ text_color,
2409
+ )
2410
+
2411
+ elapsed_ms = int(getattr(entry, "survival_elapsed_ms", 0) or 0)
2412
+ draw_small_text(
2413
+ font,
2414
+ self._format_elapsed_mm_ss(elapsed_ms),
2415
+ right_x0 + HS_LOCAL_TIME_VALUE_X * scale,
2416
+ right_y0 + HS_LOCAL_TIME_VALUE_Y * scale,
2417
+ text_scale,
2418
+ text_color,
2419
+ )
2420
+
2421
+ draw_small_text(
2422
+ font,
2423
+ f"Rank: {self._ordinal(idx + 1)}",
2424
+ right_x0 + HS_LOCAL_RANK_X * scale,
2425
+ right_y0 + HS_LOCAL_RANK_Y * scale,
2426
+ text_scale,
2427
+ text_color,
2428
+ )
2429
+
2430
+ frags = int(getattr(entry, "creature_kill_count", 0) or 0)
2431
+ draw_small_text(
2432
+ font,
2433
+ f"Frags: {frags}",
2434
+ right_x0 + HS_LOCAL_FRAGS_X * scale,
2435
+ right_y0 + HS_LOCAL_FRAGS_Y * scale,
2436
+ text_scale,
2437
+ text_color,
2438
+ )
2439
+
2440
+ shots_fired = int(getattr(entry, "shots_fired", 0) or 0)
2441
+ shots_hit = int(getattr(entry, "shots_hit", 0) or 0)
2442
+ hit_pct = 0
2443
+ if shots_fired > 0:
2444
+ hit_pct = int((shots_hit * 100) // shots_fired)
2445
+ draw_small_text(
2446
+ font,
2447
+ f"Hit %: {hit_pct}%",
2448
+ right_x0 + HS_LOCAL_HIT_X * scale,
2449
+ right_y0 + HS_LOCAL_HIT_Y * scale,
2450
+ text_scale,
2451
+ text_color,
2452
+ )
2453
+
2454
+ weapon_id = int(getattr(entry, "most_used_weapon_id", 0) or 0)
2455
+ weapon_name, icon_index = self._weapon_label_and_icon(weapon_id)
2456
+ if icon_index is not None:
2457
+ self._draw_wicon(icon_index, x=right_x0 + HS_LOCAL_WICON_X * scale, y=right_y0 + HS_LOCAL_WICON_Y * scale, scale=scale)
2458
+ draw_small_text(
2459
+ font,
2460
+ weapon_name,
2461
+ right_x0 + HS_LOCAL_WEAPON_X * scale,
2462
+ right_y0 + HS_LOCAL_WEAPON_Y * scale,
2463
+ text_scale,
2464
+ text_color,
2465
+ )
2466
+
2467
+ def _draw_wicon(self, icon_index: int, *, x: float, y: float, scale: float) -> None:
2468
+ tex = self._wicons_tex
2469
+ if tex is None:
2470
+ return
2471
+ idx = int(icon_index)
2472
+ if idx < 0 or idx > 31:
2473
+ return
2474
+ cols = 4
2475
+ rows = 8
2476
+ icon_w = float(tex.width) / float(cols)
2477
+ icon_h = float(tex.height) / float(rows)
2478
+ src_x = float(idx % cols) * icon_w
2479
+ src_y = float(idx // cols) * icon_h
2480
+ rl.draw_texture_pro(
2481
+ tex,
2482
+ rl.Rectangle(src_x, src_y, icon_w, icon_h),
2483
+ rl.Rectangle(float(x), float(y), icon_w * scale, icon_h * scale),
2484
+ rl.Vector2(0.0, 0.0),
2485
+ 0.0,
2486
+ rl.WHITE,
2487
+ )
2488
+
2489
+ @staticmethod
2490
+ def _ordinal(value: int) -> str:
2491
+ n = int(value)
2492
+ if 10 <= (n % 100) <= 20:
2493
+ return f"{n}th"
2494
+ suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
2495
+ return f"{n}{suffix}"
2496
+
2497
+ @staticmethod
2498
+ def _format_elapsed_mm_ss(value_ms: int) -> str:
2499
+ total = max(0, int(value_ms)) // 1000
2500
+ minutes, seconds = divmod(total, 60)
2501
+ return f"{minutes}:{seconds:02d}"
2502
+
2503
+ @staticmethod
2504
+ def _format_score_date(entry: object) -> str:
2505
+ try:
2506
+ day = int(getattr(entry, "day", 0) or 0)
2507
+ month = int(getattr(entry, "month", 0) or 0)
2508
+ year_off = int(getattr(entry, "year_offset", 0) or 0)
2509
+ except Exception:
2510
+ return ""
2511
+ if day <= 0 or month <= 0:
2512
+ return ""
2513
+ months = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
2514
+ month_name = months[month - 1] if 1 <= month <= 12 else f"{month}"
2515
+ year = 2000 + year_off if year_off >= 0 else 2000
2516
+ return f"{day}. {month_name} {year}"
2517
+
2518
+ @staticmethod
2519
+ def _weapon_label_and_icon(weapon_id: int) -> tuple[str, int | None]:
2520
+ try:
2521
+ from .weapons import WEAPON_BY_ID
2522
+ except Exception:
2523
+ WEAPON_BY_ID = {}
2524
+ weapon = WEAPON_BY_ID.get(int(weapon_id))
2525
+ if weapon is None:
2526
+ return f"Weapon {int(weapon_id)}", None
2527
+ name = weapon.name or f"weapon_{int(weapon.weapon_id)}"
2528
+ return name, weapon.icon_index
2529
+
1847
2530
  def _draw_sign(self, assets: MenuAssets) -> None:
1848
2531
  if assets.sign is None:
1849
2532
  return
@@ -1944,6 +2627,7 @@ class GameLoopView:
1944
2627
  "start_quest": QuestGameView(state),
1945
2628
  "quest_results": QuestResultsView(state),
1946
2629
  "quest_failed": QuestFailedView(state),
2630
+ "end_note": EndNoteView(state),
1947
2631
  "open_high_scores": HighScoresView(state),
1948
2632
  "start_survival": SurvivalGameView(state),
1949
2633
  "start_rush": RushGameView(state),
@@ -2474,6 +3158,8 @@ def _resolve_assets_dir(config: GameConfig) -> Path:
2474
3158
 
2475
3159
 
2476
3160
  def run_game(config: GameConfig) -> None:
3161
+ if config.debug:
3162
+ set_debug_enabled(True)
2477
3163
  base_dir = config.base_dir
2478
3164
  base_dir.mkdir(parents=True, exist_ok=True)
2479
3165
  crash_path = base_dir / "crash.log"