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.
Files changed (138) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +153 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +377 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +663 -0
  34. crimson/gameplay.py +2450 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1039 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1338 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +56 -0
  67. crimson/sim/world_state.py +421 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +414 -0
  86. crimson/views/bonuses.py +201 -0
  87. crimson/views/camera_debug.py +359 -0
  88. crimson/views/camera_shake.py +229 -0
  89. crimson/views/corpse_stamp_debug.py +324 -0
  90. crimson/views/decals_debug.py +739 -0
  91. crimson/views/empty.py +19 -0
  92. crimson/views/fonts.py +114 -0
  93. crimson/views/game_over.py +117 -0
  94. crimson/views/ground.py +259 -0
  95. crimson/views/lighting_debug.py +1166 -0
  96. crimson/views/particles.py +293 -0
  97. crimson/views/perk_menu_debug.py +430 -0
  98. crimson/views/perks.py +398 -0
  99. crimson/views/player.py +433 -0
  100. crimson/views/player_sprite_debug.py +314 -0
  101. crimson/views/projectile_fx.py +608 -0
  102. crimson/views/projectile_render_debug.py +407 -0
  103. crimson/views/projectiles.py +221 -0
  104. crimson/views/quest_title_overlay.py +108 -0
  105. crimson/views/registry.py +34 -0
  106. crimson/views/rush.py +16 -0
  107. crimson/views/small_font_debug.py +204 -0
  108. crimson/views/spawn_plan.py +363 -0
  109. crimson/views/sprites.py +214 -0
  110. crimson/views/survival.py +15 -0
  111. crimson/views/terrain.py +132 -0
  112. crimson/views/ui.py +123 -0
  113. crimson/views/wicons.py +166 -0
  114. crimson/weapon_sfx.py +63 -0
  115. crimson/weapons.py +860 -0
  116. crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
  117. crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
  118. crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
  119. crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
  120. grim/__init__.py +20 -0
  121. grim/app.py +92 -0
  122. grim/assets.py +231 -0
  123. grim/audio.py +106 -0
  124. grim/config.py +294 -0
  125. grim/console.py +737 -0
  126. grim/fonts/__init__.py +7 -0
  127. grim/fonts/grim_mono.py +111 -0
  128. grim/fonts/small.py +120 -0
  129. grim/input.py +44 -0
  130. grim/jaz.py +103 -0
  131. grim/math.py +17 -0
  132. grim/music.py +403 -0
  133. grim/paq.py +76 -0
  134. grim/rand.py +37 -0
  135. grim/sfx.py +276 -0
  136. grim/sfx_map.py +103 -0
  137. grim/terrain_render.py +840 -0
  138. grim/view.py +16 -0
grim/console.py ADDED
@@ -0,0 +1,737 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Callable, Iterable
6
+
7
+ import math
8
+ import pyray as rl
9
+
10
+ from . import paq
11
+ from grim.fonts.grim_mono import (
12
+ GrimMonoFont,
13
+ draw_grim_mono_text,
14
+ load_grim_mono_font,
15
+ )
16
+ from grim.fonts.small import (
17
+ SmallFontData,
18
+ draw_small_text,
19
+ load_small_font,
20
+ measure_small_text_width,
21
+ )
22
+
23
+ CONSOLE_LOG_NAME = "console.log"
24
+ MAX_CONSOLE_LINES = 0x1000
25
+ MAX_CONSOLE_INPUT = 0x3FF
26
+ DEFAULT_CONSOLE_HEIGHT = 300
27
+ EXTENDED_CONSOLE_HEIGHT = 480
28
+ CONSOLE_VERSION_TEXT = "Crimsonland 1.9.93"
29
+ CONSOLE_ANIM_SPEED = 3.5
30
+ CONSOLE_BLINK_SPEED = 3.0
31
+ CONSOLE_LINE_HEIGHT = 16.0
32
+ CONSOLE_MONO_SCALE = 0.5
33
+ CONSOLE_SMALL_SCALE = 1.0
34
+ CONSOLE_TEXT_X = 10.0
35
+ CONSOLE_INPUT_X_MONO = 26.0
36
+ CONSOLE_VERSION_OFFSET_X = 210.0
37
+ CONSOLE_VERSION_OFFSET_Y = 18.0
38
+ CONSOLE_BORDER_HEIGHT = 4.0
39
+ CONSOLE_BG_COLOR = (0.140625, 0.1875, 0.2890625)
40
+ CONSOLE_BORDER_COLOR = (0.21875, 0.265625, 0.3671875)
41
+ CONSOLE_PROMPT_MONO = ">"
42
+ CONSOLE_PROMPT_SMALL_FMT = ">%s"
43
+ CONSOLE_CARET_TEXT = "_"
44
+ SCRIPT_PAQ_NAMES = ("music.paq", "crimson.paq", "sfx.paq")
45
+
46
+ CommandHandler = Callable[[list[str]], None]
47
+
48
+
49
+ def game_build_path(base_dir: Path, name: str) -> Path:
50
+ return base_dir / name
51
+
52
+
53
+ def _parse_float(value: str) -> float:
54
+ try:
55
+ return float(value)
56
+ except ValueError:
57
+ return 0.0
58
+
59
+
60
+ def _normalize_script_path(name: str) -> Path:
61
+ raw = name.strip().strip("\"'")
62
+ normalized = raw.replace("\\", "/")
63
+ return Path(normalized)
64
+
65
+
66
+ def _resolve_script_path(console: "ConsoleState", target: Path) -> Path | None:
67
+ if target.is_absolute():
68
+ return target if target.is_file() else None
69
+ for base in console.script_dirs:
70
+ candidate = base / target
71
+ if candidate.is_file():
72
+ return candidate
73
+ return None
74
+
75
+
76
+ def _primary_script_dirs(console: "ConsoleState") -> tuple[Path, ...]:
77
+ dirs: list[Path] = [console.base_dir]
78
+ if console.assets_dir is not None and console.assets_dir not in dirs:
79
+ dirs.append(console.assets_dir)
80
+ return tuple(dirs)
81
+
82
+
83
+ def _resolve_script_path_in(target: Path, roots: Iterable[Path]) -> Path | None:
84
+ if target.is_absolute():
85
+ return target if target.is_file() else None
86
+ for base in roots:
87
+ candidate = base / target
88
+ if candidate.is_file():
89
+ return candidate
90
+ return None
91
+
92
+
93
+ def _iter_script_paq_paths(console: "ConsoleState") -> Iterable[Path]:
94
+ roots: list[Path] = []
95
+ if console.assets_dir is not None:
96
+ roots.append(console.assets_dir)
97
+ if console.base_dir not in roots:
98
+ roots.append(console.base_dir)
99
+ for root in roots:
100
+ for name in SCRIPT_PAQ_NAMES:
101
+ path = root / name
102
+ if path.is_file():
103
+ yield path
104
+
105
+
106
+ def _load_script_from_paq(console: "ConsoleState", target: Path) -> str | None:
107
+ if target.is_absolute():
108
+ return None
109
+ normalized = target.as_posix().replace("\\", "/")
110
+ normalized_lower = normalized.lower()
111
+ for paq_path in _iter_script_paq_paths(console):
112
+ try:
113
+ for name, data in paq.iter_entries(paq_path):
114
+ entry_name = name.replace("\\", "/")
115
+ if entry_name == normalized or entry_name.lower() == normalized_lower:
116
+ return data.decode("utf-8", errors="ignore")
117
+ except OSError:
118
+ continue
119
+ except Exception:
120
+ continue
121
+ return None
122
+
123
+
124
+ def _clamp(value: float, low: float, high: float) -> float:
125
+ return max(low, min(high, value))
126
+
127
+
128
+ def _rgba(r: float, g: float, b: float, a: float) -> rl.Color:
129
+ return rl.Color(
130
+ int(_clamp(r, 0.0, 1.0) * 255),
131
+ int(_clamp(g, 0.0, 1.0) * 255),
132
+ int(_clamp(b, 0.0, 1.0) * 255),
133
+ int(_clamp(a, 0.0, 1.0) * 255),
134
+ )
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class ConsoleCvar:
139
+ name: str
140
+ value: str
141
+ value_f: float
142
+
143
+ @classmethod
144
+ def from_value(cls, name: str, value: str) -> "ConsoleCvar":
145
+ return cls(name=name, value=value, value_f=_parse_float(value))
146
+
147
+
148
+ @dataclass(slots=True)
149
+ class ConsoleLog:
150
+ base_dir: Path
151
+ lines: list[str] = field(default_factory=list)
152
+ flushed_index: int = 0
153
+
154
+ def log(self, message: str) -> None:
155
+ self.lines.append(message)
156
+ if len(self.lines) > MAX_CONSOLE_LINES:
157
+ overflow = len(self.lines) - MAX_CONSOLE_LINES
158
+ del self.lines[:overflow]
159
+ self.flushed_index = max(0, self.flushed_index - overflow)
160
+
161
+ def clear(self) -> None:
162
+ self.lines.clear()
163
+ self.flushed_index = 0
164
+
165
+ def flush(self) -> None:
166
+ if self.flushed_index >= len(self.lines):
167
+ return
168
+ path = game_build_path(self.base_dir, CONSOLE_LOG_NAME)
169
+ path.parent.mkdir(parents=True, exist_ok=True)
170
+ with path.open("a", encoding="utf-8") as handle:
171
+ for line in self.lines[self.flushed_index :]:
172
+ handle.write(line.rstrip() + "\n")
173
+ self.flushed_index = len(self.lines)
174
+
175
+
176
+ @dataclass(slots=True)
177
+ class ConsoleState:
178
+ base_dir: Path
179
+ log: ConsoleLog
180
+ assets_dir: Path | None = None
181
+ script_dirs: tuple[Path, ...] = field(default_factory=tuple)
182
+ commands: dict[str, CommandHandler] = field(default_factory=dict)
183
+ cvars: dict[str, ConsoleCvar] = field(default_factory=dict)
184
+ open_flag: bool = False
185
+ input_enabled: bool = False
186
+ input_ready: bool = False
187
+ input_buffer: str = ""
188
+ input_caret: int = 0
189
+ history: list[str] = field(default_factory=list)
190
+ history_index: int | None = None
191
+ history_pending: str = ""
192
+ scroll_offset: int = 0
193
+ height_px: int = DEFAULT_CONSOLE_HEIGHT
194
+ echo_enabled: bool = True
195
+ quit_requested: bool = False
196
+ prompt_string: str = "> %s"
197
+ _mono_font: GrimMonoFont | None = field(default=None, init=False, repr=False)
198
+ _mono_font_error: str | None = field(default=None, init=False, repr=False)
199
+ _small_font: SmallFontData | None = field(default=None, init=False, repr=False)
200
+ _small_font_error: str | None = field(default=None, init=False, repr=False)
201
+ _slide_t: float = 1.0
202
+ _offset_y: float = field(default=0.0, init=False)
203
+ _blink_time: float = 0.0
204
+
205
+ def register_command(self, name: str, handler: CommandHandler) -> None:
206
+ self.commands[name] = handler
207
+
208
+ def register_cvar(self, name: str, value: str) -> None:
209
+ self.cvars[name] = ConsoleCvar.from_value(name, value)
210
+
211
+ def add_script_dir(self, path: Path | None) -> None:
212
+ if path is None:
213
+ return
214
+ if path in self.script_dirs:
215
+ return
216
+ self.script_dirs = (*self.script_dirs, path)
217
+
218
+ def set_open(self, open_flag: bool) -> None:
219
+ self.open_flag = open_flag
220
+ self.input_enabled = open_flag
221
+ self.input_ready = False
222
+ self.history_index = None
223
+ self._flush_input_queue()
224
+
225
+ def toggle_open(self) -> None:
226
+ self.set_open(not self.open_flag)
227
+
228
+ def handle_hotkey(self) -> None:
229
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_GRAVE):
230
+ self.toggle_open()
231
+
232
+ def exec_line(self, line: str) -> None:
233
+ tokens = self._tokenize_line(line)
234
+ if not tokens:
235
+ return
236
+ name, args = tokens[0], tokens[1:]
237
+ cvar = self.cvars.get(name)
238
+ if cvar is not None:
239
+ if args:
240
+ value = " ".join(args)
241
+ cvar.value = value
242
+ cvar.value_f = _parse_float(value)
243
+ self.log.log(f"\"{cvar.name}\" set to \"{cvar.value}\" ({cvar.value_f:.6f})")
244
+ else:
245
+ self.log.log(f"\"{cvar.name}\" is \"{cvar.value}\" ({cvar.value_f:.6f})")
246
+ return
247
+ handler = self.commands.get(name)
248
+ if handler is not None:
249
+ handler(args)
250
+ return
251
+ self.log.log(f"Unknown command \"{name}\"")
252
+
253
+ def update(self, dt: float) -> None:
254
+ frame_dt = min(dt, 0.1)
255
+ self._blink_time += frame_dt
256
+ self._update_slide(frame_dt)
257
+ if not self.open_flag or not self.input_enabled:
258
+ return
259
+ ctrl_down = rl.is_key_down(rl.KeyboardKey.KEY_LEFT_CONTROL) or rl.is_key_down(rl.KeyboardKey.KEY_RIGHT_CONTROL)
260
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
261
+ if ctrl_down:
262
+ self._scroll_lines(1)
263
+ else:
264
+ self._history_prev()
265
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
266
+ if ctrl_down:
267
+ self._scroll_lines(-1)
268
+ else:
269
+ self._history_next()
270
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
271
+ self._scroll_lines(2)
272
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
273
+ self._scroll_lines(-2)
274
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT):
275
+ self.input_caret = max(0, self.input_caret - 1)
276
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
277
+ self.input_caret = min(len(self.input_buffer), self.input_caret + 1)
278
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
279
+ self._scroll_lines(0x14)
280
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
281
+ self.scroll_offset = 0
282
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
283
+ self._autocomplete()
284
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_BACKSPACE):
285
+ if self.input_caret > 0:
286
+ self._exit_history_edit()
287
+ self.input_buffer = (
288
+ self.input_buffer[: self.input_caret - 1] + self.input_buffer[self.input_caret :]
289
+ )
290
+ self.input_caret -= 1
291
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DELETE):
292
+ if self.input_caret < len(self.input_buffer):
293
+ self._exit_history_edit()
294
+ self.input_buffer = (
295
+ self.input_buffer[: self.input_caret] + self.input_buffer[self.input_caret + 1 :]
296
+ )
297
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
298
+ self._submit_input()
299
+ self._poll_text_input()
300
+
301
+ def draw(self) -> None:
302
+ height = float(self.height_px)
303
+ if height <= 0.0:
304
+ return
305
+ ratio = self._open_ratio(height)
306
+ if ratio <= 0.0:
307
+ return
308
+ screen_w = float(rl.get_screen_width())
309
+ offset_y = self._offset_y
310
+ rl.draw_rectangle(
311
+ 0,
312
+ int(offset_y),
313
+ int(screen_w),
314
+ int(height),
315
+ _rgba(*CONSOLE_BG_COLOR, ratio),
316
+ )
317
+ border_y = int(offset_y + height - CONSOLE_BORDER_HEIGHT)
318
+ rl.draw_rectangle(
319
+ 0,
320
+ border_y,
321
+ int(screen_w),
322
+ int(CONSOLE_BORDER_HEIGHT),
323
+ _rgba(*CONSOLE_BORDER_COLOR, ratio),
324
+ )
325
+
326
+ version_x = screen_w - CONSOLE_VERSION_OFFSET_X
327
+ version_y = offset_y + height - CONSOLE_VERSION_OFFSET_Y
328
+ self._draw_version_text(version_x, version_y, _rgba(1.0, 1.0, 1.0, ratio * 0.3))
329
+
330
+ visible, visible_count = self._visible_log_block(height)
331
+ input_y = offset_y + (visible_count + 1) * CONSOLE_LINE_HEIGHT
332
+ text_color = _rgba(1.0, 1.0, 1.0, ratio)
333
+ use_mono = self._use_mono_font()
334
+ if use_mono:
335
+ self._draw_mono_text(CONSOLE_PROMPT_MONO, CONSOLE_TEXT_X, input_y, text_color)
336
+ self._draw_mono_text(self.input_buffer, CONSOLE_INPUT_X_MONO, input_y, text_color)
337
+ else:
338
+ prompt = CONSOLE_PROMPT_SMALL_FMT.replace("%s", self.input_buffer)
339
+ self._draw_small_text(prompt, CONSOLE_TEXT_X, input_y, text_color)
340
+
341
+ log_color = _rgba(0.6, 0.6, 0.7, ratio)
342
+ y = offset_y + CONSOLE_LINE_HEIGHT
343
+ for line in visible:
344
+ if use_mono:
345
+ self._draw_mono_text(line, CONSOLE_TEXT_X, y, log_color)
346
+ else:
347
+ self._draw_small_text(line, CONSOLE_TEXT_X, y, log_color)
348
+ y += CONSOLE_LINE_HEIGHT
349
+
350
+ caret_alpha = ratio * self._caret_blink_alpha()
351
+ caret_color = _rgba(1.0, 1.0, 1.0, caret_alpha)
352
+ caret_y = input_y + 2.0
353
+ if use_mono:
354
+ caret_x = CONSOLE_INPUT_X_MONO + float(self.input_caret) * 8.0
355
+ self._draw_mono_text(CONSOLE_CARET_TEXT, caret_x, caret_y, caret_color)
356
+ else:
357
+ caret_x = self._small_caret_x()
358
+ self._draw_small_text(CONSOLE_CARET_TEXT, caret_x, caret_y, caret_color)
359
+
360
+ def close(self) -> None:
361
+ if self._mono_font is not None:
362
+ rl.unload_texture(self._mono_font.texture)
363
+ self._mono_font = None
364
+ if self._small_font is not None:
365
+ rl.unload_texture(self._small_font.texture)
366
+ self._small_font = None
367
+
368
+ def _tokenize_line(self, line: str) -> list[str]:
369
+ stripped = line.strip()
370
+ if not stripped:
371
+ return []
372
+ if stripped.startswith("//"):
373
+ return []
374
+ return stripped.split()
375
+
376
+ def _prompt_text(self) -> str:
377
+ if "%s" in self.prompt_string:
378
+ return self.prompt_string.replace("%s", self.input_buffer)
379
+ return f"{self.prompt_string}{self.input_buffer}"
380
+
381
+ def _history_prev(self) -> None:
382
+ if not self.history:
383
+ return
384
+ if self.history_index is None:
385
+ self.history_index = len(self.history) - 1
386
+ self.history_pending = self.input_buffer
387
+ elif self.history_index > 0:
388
+ self.history_index -= 1
389
+ self.input_buffer = self.history[self.history_index]
390
+ self.input_caret = len(self.input_buffer)
391
+
392
+ def _history_next(self) -> None:
393
+ if self.history_index is None:
394
+ return
395
+ if self.history_index < len(self.history) - 1:
396
+ self.history_index += 1
397
+ self.input_buffer = self.history[self.history_index]
398
+ else:
399
+ self.history_index = None
400
+ self.input_buffer = self.history_pending
401
+ self.input_caret = len(self.input_buffer)
402
+
403
+ def _exit_history_edit(self) -> None:
404
+ if self.history_index is not None:
405
+ self.history_index = None
406
+ self.history_pending = self.input_buffer
407
+
408
+ def _submit_input(self) -> None:
409
+ line = self.input_buffer.strip()
410
+ self.input_ready = True
411
+ self.input_buffer = ""
412
+ self.input_caret = 0
413
+ self.history_index = None
414
+ if not line:
415
+ return
416
+ if self.echo_enabled:
417
+ if "%s" in self.prompt_string:
418
+ self.log.log(self.prompt_string.replace("%s", line))
419
+ else:
420
+ self.log.log(f"{self.prompt_string}{line}")
421
+ if not self.history or self.history[-1] != line:
422
+ self.history.append(line)
423
+ self.exec_line(line)
424
+ self.input_ready = False
425
+ self.scroll_offset = 0
426
+
427
+ def _poll_text_input(self) -> None:
428
+ while True:
429
+ value = rl.get_char_pressed()
430
+ if value == 0:
431
+ break
432
+ if value < 0x20 or value > 0xFF:
433
+ continue
434
+ if len(self.input_buffer) >= MAX_CONSOLE_INPUT:
435
+ continue
436
+ char = chr(value)
437
+ self._exit_history_edit()
438
+ self.input_buffer = (
439
+ self.input_buffer[: self.input_caret] + char + self.input_buffer[self.input_caret :]
440
+ )
441
+ self.input_caret += 1
442
+
443
+ def _autocomplete(self) -> None:
444
+ if not self.input_buffer:
445
+ return
446
+ token_start = len(self.input_buffer) - len(self.input_buffer.lstrip())
447
+ if token_start >= len(self.input_buffer):
448
+ return
449
+ token_end = self.input_buffer.find(" ", token_start)
450
+ if token_end == -1:
451
+ token_end = len(self.input_buffer)
452
+ if self.input_caret > token_end:
453
+ return
454
+ prefix = self.input_buffer[token_start:self.input_caret]
455
+ if not prefix:
456
+ return
457
+ match = self._autocomplete_name(prefix, self.cvars.keys())
458
+ if match is None:
459
+ match = self._autocomplete_name(prefix, self.commands.keys())
460
+ if match is None:
461
+ return
462
+ self.input_buffer = self.input_buffer[:token_start] + match + self.input_buffer[token_end:]
463
+ self.input_caret = token_start + len(match)
464
+
465
+ def _autocomplete_name(self, prefix: str, names: Iterable[str]) -> str | None:
466
+ for name in names:
467
+ if name == prefix:
468
+ return name
469
+ for name in names:
470
+ if name.startswith(prefix):
471
+ return name
472
+ return None
473
+
474
+ def _scroll_lines(self, delta: int) -> None:
475
+ max_offset = self._max_scroll_offset()
476
+ if max_offset <= 0:
477
+ self.scroll_offset = 0
478
+ return
479
+ self.scroll_offset = max(0, min(max_offset, self.scroll_offset + int(delta)))
480
+
481
+ def _max_visible_lines(self, height: float | None = None) -> int:
482
+ use_height = height if height is not None else float(self.height_px)
483
+ if use_height <= 0.0:
484
+ return 0
485
+ return max(int(use_height // CONSOLE_LINE_HEIGHT) - 2, 0)
486
+
487
+ def _max_scroll_offset(self) -> int:
488
+ max_lines = self._max_visible_lines()
489
+ log_count = len(self.log.lines)
490
+ visible = min(log_count, max_lines)
491
+ return max(0, log_count - visible)
492
+
493
+ def _visible_log_block(self, height: float) -> tuple[list[str], int]:
494
+ max_lines = self._max_visible_lines(height)
495
+ log_count = len(self.log.lines)
496
+ visible_count = min(log_count, max_lines)
497
+ if visible_count <= 0:
498
+ return [], 0
499
+ max_offset = max(0, log_count - visible_count)
500
+ if self.scroll_offset > max_offset:
501
+ self.scroll_offset = max_offset
502
+ start = max(0, log_count - visible_count - self.scroll_offset)
503
+ end = min(log_count, start + visible_count)
504
+ return self.log.lines[start:end], visible_count
505
+
506
+ def _update_slide(self, dt: float) -> None:
507
+ if self.open_flag:
508
+ self._slide_t = max(0.0, self._slide_t - dt * CONSOLE_ANIM_SPEED)
509
+ else:
510
+ self._slide_t = min(1.0, self._slide_t + dt * CONSOLE_ANIM_SPEED)
511
+ height = float(self.height_px)
512
+ if height <= 0.0:
513
+ self._offset_y = -height
514
+ return
515
+ eased = math.sin((1.0 - self._slide_t) * math.pi / 2.0)
516
+ self._offset_y = eased * height - height
517
+
518
+ def _open_ratio(self, height: float) -> float:
519
+ if height <= 0.0:
520
+ return 0.0
521
+ return _clamp((height + self._offset_y) / height, 0.0, 1.0)
522
+
523
+ def _caret_blink_alpha(self) -> float:
524
+ pulse = math.sin(self._blink_time * CONSOLE_BLINK_SPEED)
525
+ value = max(0.2, abs(pulse) ** 2)
526
+ return _clamp(value, 0.0, 1.0)
527
+
528
+ def _use_mono_font(self) -> bool:
529
+ cvar = self.cvars.get("con_monoFont")
530
+ if cvar is None:
531
+ return False
532
+ return bool(cvar.value_f)
533
+
534
+ def _ensure_mono_font(self) -> GrimMonoFont | None:
535
+ if self._mono_font is not None:
536
+ return self._mono_font
537
+ if self._mono_font_error is not None:
538
+ return None
539
+ if self.assets_dir is None:
540
+ self._mono_font_error = "missing assets dir"
541
+ return None
542
+ missing_assets: list[str] = []
543
+ try:
544
+ self._mono_font = load_grim_mono_font(self.assets_dir, missing_assets)
545
+ except FileNotFoundError as exc:
546
+ self._mono_font_error = str(exc)
547
+ self._mono_font = None
548
+ return self._mono_font
549
+
550
+ def _ensure_small_font(self) -> SmallFontData | None:
551
+ if self._small_font is not None:
552
+ return self._small_font
553
+ if self._small_font_error is not None:
554
+ return None
555
+ if self.assets_dir is None:
556
+ self._small_font_error = "missing assets dir"
557
+ return None
558
+ missing_assets: list[str] = []
559
+ try:
560
+ self._small_font = load_small_font(self.assets_dir, missing_assets)
561
+ except FileNotFoundError as exc:
562
+ self._small_font_error = str(exc)
563
+ self._small_font = None
564
+ return self._small_font
565
+
566
+ def _draw_mono_text(self, text: str, x: float, y: float, color: rl.Color) -> None:
567
+ font = self._ensure_mono_font()
568
+ if font is None:
569
+ rl.draw_text(text, int(x), int(y), int(16 * CONSOLE_MONO_SCALE), color)
570
+ return
571
+ advance = font.advance * CONSOLE_MONO_SCALE
572
+ draw_grim_mono_text(font, text, x - advance, y, CONSOLE_MONO_SCALE, color)
573
+
574
+ def _draw_small_text(self, text: str, x: float, y: float, color: rl.Color) -> None:
575
+ font = self._ensure_small_font()
576
+ if font is None:
577
+ rl.draw_text(text, int(x), int(y), int(16 * CONSOLE_SMALL_SCALE), color)
578
+ return
579
+ draw_small_text(font, text, x, y, CONSOLE_SMALL_SCALE, color)
580
+
581
+ def _draw_version_text(self, x: float, y: float, color: rl.Color) -> None:
582
+ font = self._ensure_small_font()
583
+ if font is None:
584
+ self._draw_mono_text(CONSOLE_VERSION_TEXT, x, y, color)
585
+ return
586
+ draw_small_text(font, CONSOLE_VERSION_TEXT, x, y, CONSOLE_SMALL_SCALE, color)
587
+
588
+ def _small_caret_x(self) -> float:
589
+ font = self._ensure_small_font()
590
+ if font is None:
591
+ return CONSOLE_TEXT_X + 16.0 + float(self.input_caret) * 8.0
592
+ prompt_w = measure_small_text_width(font, CONSOLE_PROMPT_SMALL_FMT.replace("%s", ""), CONSOLE_SMALL_SCALE)
593
+ input_w = measure_small_text_width(font, self.input_buffer[: self.input_caret], CONSOLE_SMALL_SCALE)
594
+ return CONSOLE_TEXT_X + prompt_w + input_w
595
+
596
+ def _flush_input_queue(self) -> None:
597
+ while rl.get_char_pressed():
598
+ pass
599
+ while rl.get_key_pressed():
600
+ pass
601
+
602
+
603
+ def create_console(base_dir: Path, assets_dir: Path | None = None) -> ConsoleState:
604
+ script_dirs: tuple[Path, ...] = (base_dir,)
605
+ if assets_dir is not None and assets_dir != base_dir:
606
+ script_dirs = (*script_dirs, assets_dir)
607
+ console = ConsoleState(
608
+ base_dir=base_dir,
609
+ log=ConsoleLog(base_dir=base_dir),
610
+ assets_dir=assets_dir,
611
+ script_dirs=script_dirs,
612
+ )
613
+ console.register_cvar("version", CONSOLE_VERSION_TEXT)
614
+ console.register_cvar("con_monoFont", "1")
615
+ if console.open_flag:
616
+ console._slide_t = 0.0
617
+ console._offset_y = 0.0
618
+ else:
619
+ console._slide_t = 1.0
620
+ console._offset_y = -float(console.height_px)
621
+ register_core_commands(console)
622
+ return console
623
+
624
+
625
+ def _make_noop_command(console: ConsoleState, name: str) -> CommandHandler:
626
+ def _handler(args: list[str]) -> None:
627
+ console.log.log(f"command {name} called with {len(args)} args")
628
+
629
+ return _handler
630
+
631
+
632
+ def register_boot_commands(
633
+ console: ConsoleState, handlers: dict[str, CommandHandler] | None = None
634
+ ) -> None:
635
+ resolved = handlers or {}
636
+ commands = (
637
+ "setGammaRamp",
638
+ "snd_addGameTune",
639
+ "generateterrain",
640
+ "telltimesurvived",
641
+ "setresourcepaq",
642
+ "loadtexture",
643
+ "openurl",
644
+ "sndfreqadjustment",
645
+ )
646
+ for name in commands:
647
+ handler = resolved.get(name)
648
+ if handler is None:
649
+ handler = _make_noop_command(console, name)
650
+ console.register_command(name, handler)
651
+
652
+
653
+ def register_core_cvars(console: ConsoleState, width: int, height: int) -> None:
654
+ console.register_cvar("v_width", str(width))
655
+ console.register_cvar("v_height", str(height))
656
+
657
+
658
+ def register_core_commands(console: ConsoleState) -> None:
659
+ def cmdlist(_args: list[str]) -> None:
660
+ for name in console.commands.keys():
661
+ console.log.log(name)
662
+ console.log.log(f"{len(console.commands)} commands")
663
+
664
+ def vars_cmd(_args: list[str]) -> None:
665
+ for name in console.cvars.keys():
666
+ console.log.log(name)
667
+ console.log.log(f"{len(console.cvars)} variables")
668
+
669
+ def cmd_set(args: list[str]) -> None:
670
+ if len(args) < 2:
671
+ console.log.log("Usage: set <var> <value>")
672
+ return
673
+ name = args[0]
674
+ value = " ".join(args[1:])
675
+ console.register_cvar(name, value)
676
+ console.log.log(f"'{name}' set to '{value}'")
677
+
678
+ def cmd_echo(args: list[str]) -> None:
679
+ if not args:
680
+ console.log.log(f"echo is {'on' if console.echo_enabled else 'off'}")
681
+ return
682
+ mode = args[0].lower()
683
+ if mode in {"on", "off"}:
684
+ console.echo_enabled = mode == "on"
685
+ console.log.log(f"echo {mode}")
686
+ return
687
+ console.log.log(" ".join(args))
688
+
689
+ def cmd_quit(_args: list[str]) -> None:
690
+ console.quit_requested = True
691
+
692
+ def cmd_clear(_args: list[str]) -> None:
693
+ console.log.clear()
694
+ console.scroll_offset = 0
695
+
696
+ def cmd_extend(_args: list[str]) -> None:
697
+ console.height_px = EXTENDED_CONSOLE_HEIGHT
698
+
699
+ def cmd_minimize(_args: list[str]) -> None:
700
+ console.height_px = DEFAULT_CONSOLE_HEIGHT
701
+
702
+ def cmd_exec(args: list[str]) -> None:
703
+ if not args:
704
+ console.log.log("exec <script>")
705
+ return
706
+ target = _normalize_script_path(args[0])
707
+ try:
708
+ script_text: str | None = None
709
+ primary_dirs = _primary_script_dirs(console)
710
+ path = _resolve_script_path_in(target, primary_dirs)
711
+ if path is None:
712
+ script_text = _load_script_from_paq(console, target)
713
+ if path is None and script_text is None:
714
+ fallback_dirs = [path for path in console.script_dirs if path not in primary_dirs]
715
+ path = _resolve_script_path_in(target, fallback_dirs)
716
+ if path is None and script_text is None:
717
+ console.log.log(f"Cannot open file '{args[0]}'")
718
+ return
719
+ console.log.log(f"Executing '{args[0]}'")
720
+ if script_text is None:
721
+ script_text = path.read_text(encoding="utf-8", errors="ignore")
722
+ for raw_line in script_text.splitlines():
723
+ line = raw_line.strip()
724
+ if line:
725
+ console.exec_line(line)
726
+ except OSError:
727
+ console.log.log(f"Cannot open file '{args[0]}'")
728
+
729
+ console.register_command("cmdlist", cmdlist)
730
+ console.register_command("vars", vars_cmd)
731
+ console.register_command("set", cmd_set)
732
+ console.register_command("echo", cmd_echo)
733
+ console.register_command("quit", cmd_quit)
734
+ console.register_command("clear", cmd_clear)
735
+ console.register_command("extendconsole", cmd_extend)
736
+ console.register_command("minimizeconsole", cmd_minimize)
737
+ console.register_command("exec", cmd_exec)