crimsonland 0.1.0.dev5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crimson/__init__.py +24 -0
- crimson/assets_fetch.py +60 -0
- crimson/atlas.py +92 -0
- crimson/audio_router.py +155 -0
- crimson/bonuses.py +167 -0
- crimson/camera.py +75 -0
- crimson/cli.py +380 -0
- crimson/creatures/__init__.py +8 -0
- crimson/creatures/ai.py +186 -0
- crimson/creatures/anim.py +173 -0
- crimson/creatures/damage.py +103 -0
- crimson/creatures/runtime.py +1019 -0
- crimson/creatures/spawn.py +2871 -0
- crimson/debug.py +7 -0
- crimson/demo.py +1360 -0
- crimson/demo_trial.py +140 -0
- crimson/effects.py +1086 -0
- crimson/effects_atlas.py +73 -0
- crimson/frontend/__init__.py +1 -0
- crimson/frontend/assets.py +43 -0
- crimson/frontend/boot.py +424 -0
- crimson/frontend/menu.py +700 -0
- crimson/frontend/panels/__init__.py +1 -0
- crimson/frontend/panels/base.py +410 -0
- crimson/frontend/panels/controls.py +132 -0
- crimson/frontend/panels/mods.py +128 -0
- crimson/frontend/panels/options.py +409 -0
- crimson/frontend/panels/play_game.py +627 -0
- crimson/frontend/panels/stats.py +351 -0
- crimson/frontend/transitions.py +31 -0
- crimson/game.py +2533 -0
- crimson/game_modes.py +15 -0
- crimson/game_world.py +652 -0
- crimson/gameplay.py +2467 -0
- crimson/input_codes.py +176 -0
- crimson/modes/__init__.py +1 -0
- crimson/modes/base_gameplay_mode.py +219 -0
- crimson/modes/quest_mode.py +502 -0
- crimson/modes/rush_mode.py +300 -0
- crimson/modes/survival_mode.py +792 -0
- crimson/modes/tutorial_mode.py +648 -0
- crimson/modes/typo_mode.py +472 -0
- crimson/paths.py +23 -0
- crimson/perks.py +828 -0
- crimson/persistence/__init__.py +1 -0
- crimson/persistence/highscores.py +385 -0
- crimson/persistence/save_status.py +245 -0
- crimson/player_damage.py +77 -0
- crimson/projectiles.py +1133 -0
- crimson/quests/__init__.py +18 -0
- crimson/quests/helpers.py +147 -0
- crimson/quests/registry.py +49 -0
- crimson/quests/results.py +164 -0
- crimson/quests/runtime.py +91 -0
- crimson/quests/tier1.py +620 -0
- crimson/quests/tier2.py +652 -0
- crimson/quests/tier3.py +579 -0
- crimson/quests/tier4.py +721 -0
- crimson/quests/tier5.py +886 -0
- crimson/quests/timeline.py +115 -0
- crimson/quests/types.py +70 -0
- crimson/render/__init__.py +1 -0
- crimson/render/terrain_fx.py +88 -0
- crimson/render/world_renderer.py +1941 -0
- crimson/sim/__init__.py +1 -0
- crimson/sim/world_defs.py +67 -0
- crimson/sim/world_state.py +422 -0
- crimson/terrain_assets.py +19 -0
- crimson/tutorial/__init__.py +12 -0
- crimson/tutorial/timeline.py +291 -0
- crimson/typo/__init__.py +2 -0
- crimson/typo/names.py +233 -0
- crimson/typo/player.py +43 -0
- crimson/typo/spawns.py +73 -0
- crimson/typo/typing.py +52 -0
- crimson/ui/__init__.py +3 -0
- crimson/ui/cursor.py +95 -0
- crimson/ui/demo_trial_overlay.py +235 -0
- crimson/ui/game_over.py +660 -0
- crimson/ui/hud.py +601 -0
- crimson/ui/perk_menu.py +388 -0
- crimson/views/__init__.py +40 -0
- crimson/views/aim_debug.py +276 -0
- crimson/views/animations.py +274 -0
- crimson/views/arsenal_debug.py +404 -0
- crimson/views/audio_bootstrap.py +47 -0
- crimson/views/bonuses.py +201 -0
- crimson/views/camera_debug.py +359 -0
- crimson/views/camera_shake.py +229 -0
- crimson/views/corpse_stamp_debug.py +324 -0
- crimson/views/decals_debug.py +739 -0
- crimson/views/empty.py +19 -0
- crimson/views/fonts.py +114 -0
- crimson/views/game_over.py +117 -0
- crimson/views/ground.py +259 -0
- crimson/views/lighting_debug.py +1166 -0
- crimson/views/particles.py +293 -0
- crimson/views/perk_menu_debug.py +430 -0
- crimson/views/perks.py +398 -0
- crimson/views/player.py +434 -0
- crimson/views/player_sprite_debug.py +314 -0
- crimson/views/projectile_fx.py +609 -0
- crimson/views/projectile_render_debug.py +393 -0
- crimson/views/projectiles.py +221 -0
- crimson/views/quest_title_overlay.py +108 -0
- crimson/views/registry.py +34 -0
- crimson/views/rush.py +16 -0
- crimson/views/small_font_debug.py +204 -0
- crimson/views/spawn_plan.py +363 -0
- crimson/views/sprites.py +214 -0
- crimson/views/survival.py +15 -0
- crimson/views/terrain.py +132 -0
- crimson/views/ui.py +123 -0
- crimson/views/wicons.py +166 -0
- crimson/weapon_sfx.py +63 -0
- crimson/weapons.py +860 -0
- crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
- crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
- crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
- crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
- grim/__init__.py +20 -0
- grim/app.py +92 -0
- grim/assets.py +231 -0
- grim/audio.py +106 -0
- grim/config.py +294 -0
- grim/console.py +737 -0
- grim/fonts/__init__.py +7 -0
- grim/fonts/grim_mono.py +111 -0
- grim/fonts/small.py +120 -0
- grim/input.py +44 -0
- grim/jaz.py +103 -0
- grim/math.py +17 -0
- grim/music.py +403 -0
- grim/paq.py +76 -0
- grim/rand.py +37 -0
- grim/sfx.py +276 -0
- grim/sfx_map.py +103 -0
- grim/terrain_render.py +840 -0
- grim/view.py +16 -0
grim/fonts/__init__.py
ADDED
grim/fonts/grim_mono.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pyray as rl
|
|
7
|
+
|
|
8
|
+
from grim.assets import PaqTextureCache, find_paq_path, load_paq_entries_from_path
|
|
9
|
+
|
|
10
|
+
GRIM_MONO_ADVANCE = 16.0
|
|
11
|
+
GRIM_MONO_DRAW_SIZE = 32.0
|
|
12
|
+
GRIM_MONO_LINE_HEIGHT = 28.0
|
|
13
|
+
GRIM_MONO_TEXTURE_FILTER = rl.TEXTURE_FILTER_BILINEAR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class GrimMonoFont:
|
|
18
|
+
texture: rl.Texture
|
|
19
|
+
grid: int = 16
|
|
20
|
+
cell_width: float = 16.0
|
|
21
|
+
cell_height: float = 16.0
|
|
22
|
+
advance: float = GRIM_MONO_ADVANCE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_grim_mono_font(assets_root: Path, missing_assets: list[str]) -> GrimMonoFont:
|
|
26
|
+
# Prefer crimson.paq (runtime source-of-truth), but fall back to extracted
|
|
27
|
+
# assets when present for development convenience.
|
|
28
|
+
paq_path = find_paq_path(assets_root)
|
|
29
|
+
|
|
30
|
+
atlas_png = assets_root / "crimson" / "load" / "default_font_courier.png"
|
|
31
|
+
atlas_tga = assets_root / "crimson" / "load" / "default_font_courier.tga"
|
|
32
|
+
|
|
33
|
+
texture: rl.Texture | None = None
|
|
34
|
+
if paq_path is not None:
|
|
35
|
+
try:
|
|
36
|
+
entries = load_paq_entries_from_path(paq_path)
|
|
37
|
+
cache = PaqTextureCache(entries=entries, textures={})
|
|
38
|
+
texture_asset = cache.get_or_load("default_font_courier", "load/default_font_courier.tga")
|
|
39
|
+
texture = texture_asset.texture
|
|
40
|
+
except Exception:
|
|
41
|
+
texture = None
|
|
42
|
+
|
|
43
|
+
if texture is None:
|
|
44
|
+
if atlas_png.is_file():
|
|
45
|
+
texture = rl.load_texture(str(atlas_png))
|
|
46
|
+
elif atlas_tga.is_file():
|
|
47
|
+
texture = rl.load_texture(str(atlas_tga))
|
|
48
|
+
else:
|
|
49
|
+
missing_assets.append("load/default_font_courier.tga")
|
|
50
|
+
raise FileNotFoundError(
|
|
51
|
+
"Missing grim mono font (expected load/default_font_courier.tga in crimson.paq "
|
|
52
|
+
"or extracted crimson/load/default_font_courier.(png|tga))"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
rl.set_texture_filter(texture, GRIM_MONO_TEXTURE_FILTER)
|
|
56
|
+
grid = 16
|
|
57
|
+
cell_width = texture.width / grid
|
|
58
|
+
cell_height = texture.height / grid
|
|
59
|
+
return GrimMonoFont(
|
|
60
|
+
texture=texture,
|
|
61
|
+
grid=grid,
|
|
62
|
+
cell_width=cell_width,
|
|
63
|
+
cell_height=cell_height,
|
|
64
|
+
advance=GRIM_MONO_ADVANCE,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def draw_grim_mono_text(font: GrimMonoFont, text: str, x: float, y: float, scale: float, color: rl.Color) -> None:
|
|
69
|
+
x_pos = x
|
|
70
|
+
y_pos = y
|
|
71
|
+
advance = font.advance * scale
|
|
72
|
+
draw_size = GRIM_MONO_DRAW_SIZE * scale
|
|
73
|
+
line_height = GRIM_MONO_LINE_HEIGHT * scale
|
|
74
|
+
origin = rl.Vector2(0.0, 0.0)
|
|
75
|
+
skip_advance = False
|
|
76
|
+
for value in text.encode("latin-1", errors="replace"):
|
|
77
|
+
if value == 0x0A:
|
|
78
|
+
x_pos = x
|
|
79
|
+
y_pos += line_height
|
|
80
|
+
continue
|
|
81
|
+
if value == 0x0D:
|
|
82
|
+
continue
|
|
83
|
+
if value == 0xA7:
|
|
84
|
+
skip_advance = True
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if skip_advance:
|
|
88
|
+
skip_advance = False
|
|
89
|
+
else:
|
|
90
|
+
x_pos += advance
|
|
91
|
+
|
|
92
|
+
col = value % font.grid
|
|
93
|
+
row = value // font.grid
|
|
94
|
+
src = rl.Rectangle(
|
|
95
|
+
float(col * font.cell_width),
|
|
96
|
+
float(row * font.cell_height),
|
|
97
|
+
float(font.cell_width),
|
|
98
|
+
float(font.cell_height),
|
|
99
|
+
)
|
|
100
|
+
dst = rl.Rectangle(
|
|
101
|
+
float(x_pos),
|
|
102
|
+
float(y_pos + 1.0),
|
|
103
|
+
float(draw_size),
|
|
104
|
+
float(draw_size),
|
|
105
|
+
)
|
|
106
|
+
rl.draw_texture_pro(font.texture, src, dst, origin, 0.0, color)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def measure_grim_mono_text_height(font: GrimMonoFont, text: str, scale: float) -> float:
|
|
110
|
+
line_count = text.count("\n") + 1
|
|
111
|
+
return GRIM_MONO_LINE_HEIGHT * scale * line_count
|
grim/fonts/small.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pyray as rl
|
|
7
|
+
|
|
8
|
+
from grim.assets import PaqTextureCache, find_paq_path, load_paq_entries_from_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class SmallFontData:
|
|
13
|
+
widths: list[int]
|
|
14
|
+
texture: rl.Texture
|
|
15
|
+
cell_size: int = 16
|
|
16
|
+
grid: int = 16
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SMALL_FONT_UV_BIAS_PX = 0.5
|
|
20
|
+
SMALL_FONT_FILTER = rl.TEXTURE_FILTER_POINT
|
|
21
|
+
SMALL_FONT_RENDER_SCALE = 1.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_small_font(assets_root: Path, missing_assets: list[str]) -> SmallFontData:
|
|
25
|
+
# Prefer crimson.paq (runtime source-of-truth), but fall back to extracted
|
|
26
|
+
# assets when present for development convenience.
|
|
27
|
+
paq_path = find_paq_path(assets_root)
|
|
28
|
+
if paq_path is not None:
|
|
29
|
+
try:
|
|
30
|
+
entries = load_paq_entries_from_path(paq_path)
|
|
31
|
+
widths_data = entries.get("load/smallFnt.dat")
|
|
32
|
+
if widths_data is not None:
|
|
33
|
+
cache = PaqTextureCache(entries=entries, textures={})
|
|
34
|
+
texture_asset = cache.get_or_load("smallWhite", "load/smallWhite.tga")
|
|
35
|
+
if texture_asset.texture is not None:
|
|
36
|
+
rl.set_texture_filter(texture_asset.texture, SMALL_FONT_FILTER)
|
|
37
|
+
return SmallFontData(widths=list(widths_data), texture=texture_asset.texture)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
widths_path = assets_root / "crimson" / "load" / "smallFnt.dat"
|
|
42
|
+
atlas_png = assets_root / "crimson" / "load" / "smallWhite.png"
|
|
43
|
+
atlas_tga = assets_root / "crimson" / "load" / "smallWhite.tga"
|
|
44
|
+
if not widths_path.is_file() or (not atlas_png.is_file() and not atlas_tga.is_file()):
|
|
45
|
+
missing_assets.append("small font assets")
|
|
46
|
+
raise FileNotFoundError(f"Missing small font assets: {widths_path} and {atlas_png} or {atlas_tga}")
|
|
47
|
+
widths = list(widths_path.read_bytes())
|
|
48
|
+
texture = rl.load_texture(str(atlas_png if atlas_png.is_file() else atlas_tga))
|
|
49
|
+
rl.set_texture_filter(texture, SMALL_FONT_FILTER)
|
|
50
|
+
return SmallFontData(widths=widths, texture=texture)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def draw_small_text(font: SmallFontData, text: str, x: float, y: float, scale: float, color: rl.Color) -> None:
|
|
54
|
+
x_pos = x
|
|
55
|
+
y_pos = y
|
|
56
|
+
scale_px = scale * SMALL_FONT_RENDER_SCALE
|
|
57
|
+
line_height = font.cell_size * scale_px
|
|
58
|
+
snap = abs(scale_px - round(scale_px)) < 0.001
|
|
59
|
+
if snap:
|
|
60
|
+
scale_px = float(round(scale_px))
|
|
61
|
+
origin = rl.Vector2(0.0, 0.0)
|
|
62
|
+
bias = 0.0 if SMALL_FONT_FILTER == rl.TEXTURE_FILTER_POINT else SMALL_FONT_UV_BIAS_PX
|
|
63
|
+
for value in text.encode("latin-1", errors="replace"):
|
|
64
|
+
if value == 0x0A:
|
|
65
|
+
x_pos = x
|
|
66
|
+
y_pos += line_height
|
|
67
|
+
continue
|
|
68
|
+
if value == 0x0D:
|
|
69
|
+
continue
|
|
70
|
+
width = font.widths[value]
|
|
71
|
+
if width <= 0:
|
|
72
|
+
continue
|
|
73
|
+
col = value % font.grid
|
|
74
|
+
row = value // font.grid
|
|
75
|
+
src_w = max(float(width) - bias, 0.5)
|
|
76
|
+
src_h = max(float(font.cell_size) - bias, 0.5)
|
|
77
|
+
src = rl.Rectangle(
|
|
78
|
+
float(col * font.cell_size) + bias,
|
|
79
|
+
float(row * font.cell_size) + bias,
|
|
80
|
+
src_w,
|
|
81
|
+
src_h,
|
|
82
|
+
)
|
|
83
|
+
dst_x = float(round(x_pos)) if snap else float(x_pos)
|
|
84
|
+
dst_y = float(round(y_pos)) if snap else float(y_pos)
|
|
85
|
+
dst_w = float(round(width * scale_px)) if snap else float(width * scale_px)
|
|
86
|
+
dst_h = float(round(font.cell_size * scale_px)) if snap else float(font.cell_size * scale_px)
|
|
87
|
+
dst = rl.Rectangle(
|
|
88
|
+
dst_x,
|
|
89
|
+
dst_y,
|
|
90
|
+
dst_w,
|
|
91
|
+
dst_h,
|
|
92
|
+
)
|
|
93
|
+
rl.draw_texture_pro(font.texture, src, dst, origin, 0.0, color)
|
|
94
|
+
x_pos += width * scale_px
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def measure_small_text_height(font: SmallFontData, text: str, scale: float) -> float:
|
|
98
|
+
line_count = text.count("\n") + 1
|
|
99
|
+
scale_px = scale * SMALL_FONT_RENDER_SCALE
|
|
100
|
+
return font.cell_size * scale_px * line_count
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def measure_small_text_width(font: SmallFontData, text: str, scale: float) -> float:
|
|
104
|
+
"""Return the maximum line width for `text` when rendered with `draw_small_text`."""
|
|
105
|
+
scale_px = scale * SMALL_FONT_RENDER_SCALE
|
|
106
|
+
x = 0.0
|
|
107
|
+
best = 0.0
|
|
108
|
+
for value in text.encode("latin-1", errors="replace"):
|
|
109
|
+
if value == 0x0A:
|
|
110
|
+
best = max(best, x)
|
|
111
|
+
x = 0.0
|
|
112
|
+
continue
|
|
113
|
+
if value == 0x0D:
|
|
114
|
+
continue
|
|
115
|
+
width = font.widths[value]
|
|
116
|
+
if width <= 0:
|
|
117
|
+
continue
|
|
118
|
+
x += float(width) * scale_px
|
|
119
|
+
best = max(best, x)
|
|
120
|
+
return best
|
grim/input.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import pyray as rl
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class ActionMap:
|
|
10
|
+
bindings: dict[str, tuple[int, ...]] = field(default_factory=dict)
|
|
11
|
+
|
|
12
|
+
def bind(self, action: str, *keys: int) -> None:
|
|
13
|
+
if not keys:
|
|
14
|
+
raise ValueError("bind requires at least one key")
|
|
15
|
+
self.bindings[action] = tuple(int(key) for key in keys)
|
|
16
|
+
|
|
17
|
+
def is_down(self, action: str) -> bool:
|
|
18
|
+
keys = self.bindings.get(action, ())
|
|
19
|
+
return any(rl.is_key_down(key) for key in keys)
|
|
20
|
+
|
|
21
|
+
def was_pressed(self, action: str) -> bool:
|
|
22
|
+
keys = self.bindings.get(action, ())
|
|
23
|
+
return any(rl.is_key_pressed(key) for key in keys)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_key_down(key: int) -> bool:
|
|
27
|
+
return rl.is_key_down(key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def was_key_pressed(key: int) -> bool:
|
|
31
|
+
return rl.is_key_pressed(key)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_mouse_button_down(button: int) -> bool:
|
|
35
|
+
return rl.is_mouse_button_down(button)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def was_mouse_button_pressed(button: int) -> bool:
|
|
39
|
+
return rl.is_mouse_button_pressed(button)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mouse_position() -> tuple[int, int]:
|
|
43
|
+
pos = rl.get_mouse_position()
|
|
44
|
+
return int(pos.x), int(pos.y)
|
grim/jaz.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
JAZ texture format (Crimsonland).
|
|
5
|
+
|
|
6
|
+
File layout:
|
|
7
|
+
- u8 method: compression method (1 = zlib)
|
|
8
|
+
- u32 comp_size: compressed payload size (bytes)
|
|
9
|
+
- u32 raw_size: uncompressed payload size (bytes)
|
|
10
|
+
- zlib stream (length = comp_size)
|
|
11
|
+
|
|
12
|
+
Decompressed payload:
|
|
13
|
+
- u32 jpeg_len
|
|
14
|
+
- jpeg bytes (length = jpeg_len)
|
|
15
|
+
- alpha_rle: (count, value) byte pairs for alpha channel
|
|
16
|
+
|
|
17
|
+
Notes from assets:
|
|
18
|
+
- alpha runs expand to width*height for most files; one file is short by 1 pixel.
|
|
19
|
+
We pad any remaining pixels with 0 (transparent).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import io
|
|
23
|
+
import zlib
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from PIL import Image
|
|
27
|
+
from construct import Bytes, Int8ul, Int32ul, Struct, this
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
JAZ_HEADER = Struct(
|
|
31
|
+
"method" / Int8ul,
|
|
32
|
+
"comp_size" / Int32ul,
|
|
33
|
+
"raw_size" / Int32ul,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
JAZ_FILE = Struct(
|
|
37
|
+
"header" / JAZ_HEADER,
|
|
38
|
+
"compressed" / Bytes(this.header.comp_size),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def jaz_payload(raw_size: int) -> Struct:
|
|
43
|
+
return Struct(
|
|
44
|
+
"jpeg_len" / Int32ul,
|
|
45
|
+
"jpeg" / Bytes(this.jpeg_len),
|
|
46
|
+
"alpha_rle" / Bytes(raw_size - 4 - this.jpeg_len),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class JazImage:
|
|
51
|
+
def __init__(self, width: int, height: int, jpeg: bytes, alpha: bytes) -> None:
|
|
52
|
+
self.width = width
|
|
53
|
+
self.height = height
|
|
54
|
+
self.jpeg = jpeg
|
|
55
|
+
self.alpha = alpha
|
|
56
|
+
|
|
57
|
+
def rgb_image(self) -> Image.Image:
|
|
58
|
+
img = Image.open(io.BytesIO(self.jpeg))
|
|
59
|
+
return img.convert("RGB")
|
|
60
|
+
|
|
61
|
+
def alpha_image(self) -> Image.Image:
|
|
62
|
+
return Image.frombytes("L", (self.width, self.height), self.alpha)
|
|
63
|
+
|
|
64
|
+
def composite_image(self) -> Image.Image:
|
|
65
|
+
rgb = self.rgb_image()
|
|
66
|
+
alpha = self.alpha_image()
|
|
67
|
+
rgb.putalpha(alpha)
|
|
68
|
+
return rgb
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def decode_alpha_rle(data: bytes, expected: int) -> bytes:
|
|
72
|
+
out = bytearray(expected)
|
|
73
|
+
filled = 0
|
|
74
|
+
for i in range(0, len(data) - 1, 2):
|
|
75
|
+
count = data[i]
|
|
76
|
+
value = data[i + 1]
|
|
77
|
+
if count == 0:
|
|
78
|
+
continue
|
|
79
|
+
if filled >= expected:
|
|
80
|
+
break
|
|
81
|
+
end = min(filled + count, expected)
|
|
82
|
+
out[filled:end] = bytes([value]) * (end - filled)
|
|
83
|
+
filled = end
|
|
84
|
+
return bytes(out)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def decode_jaz_bytes(data: bytes) -> JazImage:
|
|
88
|
+
parsed = JAZ_FILE.parse(data)
|
|
89
|
+
header = parsed.header
|
|
90
|
+
if header.method != 1:
|
|
91
|
+
raise ValueError(f"unsupported compression method: {header.method}")
|
|
92
|
+
raw = zlib.decompress(parsed.compressed)
|
|
93
|
+
if len(raw) != header.raw_size:
|
|
94
|
+
raise ValueError(f"raw size mismatch: {len(raw)} != {header.raw_size}")
|
|
95
|
+
payload = jaz_payload(header.raw_size).parse(raw)
|
|
96
|
+
img = Image.open(io.BytesIO(payload.jpeg))
|
|
97
|
+
width, height = img.size
|
|
98
|
+
alpha = decode_alpha_rle(payload.alpha_rle, width * height)
|
|
99
|
+
return JazImage(width, height, payload.jpeg, alpha)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def decode_jaz(path: str | Path) -> JazImage:
|
|
103
|
+
return decode_jaz_bytes(Path(path).read_bytes())
|
grim/math.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def clamp(value: float, low: float, high: float) -> float:
|
|
5
|
+
if value < low:
|
|
6
|
+
return low
|
|
7
|
+
if value > high:
|
|
8
|
+
return high
|
|
9
|
+
return value
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def clamp01(value: float) -> float:
|
|
13
|
+
return clamp(value, 0.0, 1.0)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def lerp(a: float, b: float, t: float) -> float:
|
|
17
|
+
return a + (b - a) * t
|