Release v0.1.8 2026-07-13

This commit is contained in:
Warren H
2026-07-13 17:21:10 -04:00
parent 77fd434226
commit 6125d10c36
29 changed files with 1120 additions and 159 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "i-want-to-heal",
"private": true,
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
@@ -0,0 +1,352 @@
"""Build two original low-poly creature bosses as animated runtime GLBs.
Replaces weak chicken and frog visuals while keeping stable boss IDs in game data.
Run with:
/Applications/Blender.app/Contents/MacOS/Blender --background --factory-startup \
--python scripts/blender/build_replacement_creature_bosses.py
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import bpy
sys.path.insert(0, str(Path(__file__).parent))
from build_iwt2_boss_trio import ( # noqa: E402
OUT_ROOT,
actions,
armature,
cone,
ellipsoid,
export_asset,
finish,
join_parts,
plate,
prepare_materials,
reset_scene,
)
def add_metadata(asset_id: str, concept: str) -> None:
metadata_path = OUT_ROOT / asset_id / f"{asset_id}.asset.json"
metadata = json.loads(metadata_path.read_text())
metadata["sourceConcept"] = concept
metadata["license"] = "Original project-owned asset"
metadata["runtime"]["forward"] = "-Y"
metadata["runtime"]["unit"] = "meters"
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
def build_brassbeak_basilisk() -> None:
"""Six-legged forge basilisk replacing Cluckhorn's chicken-cow model."""
asset_id = "brassbeak-basilisk"
reset_scene()
mats = prepare_materials({
"Scale": {"color": (0.035, 0.105, 0.12, 1), "metallic": 0.14, "roughness": 0.62},
"Underbelly": {"color": (0.12, 0.19, 0.18, 1), "metallic": 0.05, "roughness": 0.72},
"Copper": {"color": (0.45, 0.16, 0.055, 1), "metallic": 0.48, "roughness": 0.33},
"Brass": {"color": (0.78, 0.48, 0.09, 1), "metallic": 0.62, "roughness": 0.25},
"Blade": {"color": (0.50, 0.58, 0.55, 1), "metallic": 0.72, "roughness": 0.2},
"Furnace": {"color": (0.02, 0.82, 0.72, 1), "roughness": 0.18, "emission": (0.01, 0.72, 0.64, 1), "strength": 5.5},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.45), None),
("Body", (0, 0.06, 1.18), (0, 0.05, 2.05), "Root"),
("Head", (0, -1.0, 1.48), (0, -1.82, 1.38), "Body"),
("Jaw", (0, -1.42, 1.28), (0, -2.05, 1.12), "Head"),
("Wing.L", (-0.62, -0.08, 1.72), (-1.55, -0.28, 1.5), "Body"),
("Wing.R", (0.62, -0.08, 1.72), (1.55, -0.28, 1.5), "Body"),
("Leg.FL", (-0.62, -0.72, 1.12), (-0.88, -0.84, 0.24), "Body"),
("Leg.FR", (0.62, -0.72, 1.12), (0.88, -0.84, 0.24), "Body"),
("Leg.ML", (-0.78, 0.03, 1.06), (-1.0, 0.02, 0.22), "Body"),
("Leg.MR", (0.78, 0.03, 1.06), (1.0, 0.02, 0.22), "Body"),
("Leg.BL", (-0.66, 0.76, 1.12), (-0.9, 0.88, 0.24), "Body"),
("Leg.BR", (0.66, 0.76, 1.12), (0.9, 0.88, 0.24), "Body"),
("Tail.1", (0, 1.0, 1.3), (0, 1.85, 1.12), "Body"),
("Tail.2", (0, 1.8, 1.12), (0, 2.7, 0.92), "Tail.1"),
]
rig = armature("BrassbeakBasilisk", specs)
# Broad armored silhouette with glowing furnace seams.
ellipsoid("BasiliskBody", (0, 0.08, 1.36), (1.08, 1.43, 0.72), mats["Scale"], "Body", 2)
ellipsoid("FurnaceBelly", (0, -0.15, 1.08), (0.78, 1.08, 0.46), mats["Underbelly"], "Body", 2)
for index, y in enumerate((-0.7, -0.22, 0.28, 0.76)):
width = 0.82 + (0.12 if index in (1, 2) else 0)
plate(
f"BackPlate{index}", (0, y, 1.92 + 0.08 * math.sin(index)),
(width, 0.55, 0.27), (math.radians(84), 0, 0),
mats["Copper"] if index % 2 == 0 else mats["Brass"], "Body",
)
cone(
f"ChimneySpine{index}", (0, y, 2.02), (0, y + 0.03, 2.52 - index * 0.04),
0.13, 0, mats["Blade"], "Body", 5,
)
for side in (-1, 1):
cone(
f"FurnaceSeam{side:+d}", (side * 0.58, -0.76, 1.34), (side * 0.72, 0.72, 1.34),
0.035, 0.022, mats["Furnace"], "Body", 5,
)
# Hammerhead, brass beak, split jaw, and crown blades.
ellipsoid("HammerHead", (0, -1.28, 1.5), (0.78, 0.74, 0.56), mats["Copper"], "Head", 2)
ellipsoid("FaceMask", (0, -1.72, 1.5), (0.58, 0.34, 0.42), mats["Brass"], "Head", 1)
cone("UpperBeak", (0, -1.68, 1.51), (0, -2.58, 1.34), 0.42, 0.035, mats["Brass"], "Head", 6)
cone("LowerBeak", (0, -1.64, 1.3), (0, -2.32, 1.18), 0.3, 0.025, mats["Blade"], "Jaw", 6)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.43, -1.72, 1.66), (0.09, 0.055, 0.09), mats["Furnace"], "Head", 1)
cone(
f"BrowHorn{suffix}", (side * 0.42, -1.38, 1.78), (side * 0.88, -1.7, 2.03),
0.13, 0, mats["Blade"], "Head", 5,
)
for index, (x, z) in enumerate(((-0.34, 2.0), (0, 2.14), (0.34, 2.0))):
cone(f"CrownBlade{index}", (x, -1.2, 1.8), (x * 1.35, -1.15, z + 0.54), 0.12, 0, mats["Brass"], "Head", 5)
# Blade-like vestigial wings make lateral cleaves readable from camera.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Wing.{suffix}"
plate(
f"WingShield{suffix}", (side * 1.05, -0.08, 1.62), (0.7, 0.56, 0.13),
(math.radians(78), math.radians(side * 18), math.radians(side * 8)), mats["Copper"], bone,
)
cone(
f"WingBlade{suffix}", (side * 0.72, -0.24, 1.7), (side * 1.95, -0.58, 1.42),
0.19, 0.015, mats["Blade"], bone, 6,
)
cone(
f"WingGlow{suffix}", (side * 0.88, -0.3, 1.69), (side * 1.7, -0.52, 1.5),
0.05, 0.008, mats["Furnace"], bone, 5,
)
# Six short piston legs: stable, strange, easy to read while scuttling.
leg_rows = (("F", -0.72), ("M", 0.03), ("B", 0.76))
for row_index, (row, y) in enumerate(leg_rows):
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{row}{suffix}"
hip_x = side * (0.64 if row != "M" else 0.78)
foot_x = side * (0.98 if row != "M" else 1.1)
ellipsoid(f"Hip{row}{suffix}", (hip_x, y, 1.05), (0.3, 0.34, 0.3), mats["Copper"], bone, 1)
cone(f"Shin{row}{suffix}", (hip_x, y, 1.0), (foot_x, y - 0.04, 0.28), 0.22, 0.14, mats["Scale"], bone, 6)
ellipsoid(f"Foot{row}{suffix}", (foot_x, y - 0.22, 0.2), (0.31, 0.48, 0.19), mats["Brass"], bone, 1)
for toe_index, toe_x in enumerate((-0.13, 0.13)):
cone(
f"Toe{row}{suffix}{toe_index}", (foot_x + toe_x, y - 0.42, 0.2),
(foot_x + toe_x * 1.4, y - 0.75, 0.1), 0.055, 0.004, mats["Blade"], bone, 5,
)
cone("TailCore1", (0, 0.95, 1.3), (0, 1.85, 1.1), 0.48, 0.3, mats["Scale"], "Tail.1", 7)
cone("TailCore2", (0, 1.78, 1.1), (0, 2.72, 0.88), 0.31, 0.07, mats["Copper"], "Tail.2", 7)
cone("TailBladeTop", (0, 2.45, 0.9), (0, 3.18, 1.45), 0.2, 0.015, mats["Blade"], "Tail.2", 5)
cone("TailBladeBottom", (0, 2.45, 0.9), (0, 3.15, 0.48), 0.18, 0.015, mats["Brass"], "Tail.2", 5)
ellipsoid("TailCoreGlow", (0, 2.54, 0.91), (0.14, 0.17, 0.14), mats["Furnace"], "Tail.2", 1)
body = join_parts("BrassbeakBasilisk", rig)
clips = actions(rig, brassbeak_actions())
export_asset(
asset_id, "Brassbeak Basilisk", rig, body, clips,
[
("Body", (0, 0, 1.25), (1.25, 1.55, 0.9)),
("Head", (0, -1.65, 1.42), (0.95, 1.05, 0.75)),
("Tail", (0, 2.08, 1.0), (0.55, 1.25, 0.78)),
],
(0, 0, 1.25), 8.8, "FurnaceBurst", 20,
)
add_metadata(asset_id, "Original six-legged forge basilisk designed for I Want to Heal")
def brassbeak_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 30, "rotations": {"Head": (0, 0, 3), "Jaw": (0, 0, 0), "Tail.2": (0, 0, -7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "rotations": {"Head": (3, 0, -3), "Jaw": (7, 0, 0), "Tail.2": (0, 0, 7), "Wing.L": (0, 0, -4), "Wing.R": (0, 0, 4)}},
{"frame": 60},
]),
("Scuttle", 30, True, [
{"frame": 1, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
{"frame": 8, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (-3, 0, 0)}},
{"frame": 16, "rotations": {"Leg.FL": (18, 0, 5), "Leg.MR": (18, 0, -4), "Leg.BL": (18, 0, 4), "Leg.FR": (-18, 0, -5), "Leg.ML": (-18, 0, 4), "Leg.BR": (-18, 0, -4), "Tail.2": (0, 0, 9)}},
{"frame": 23, "locations": {"Root": (0, 0, 0.08)}, "rotations": {"Body": (3, 0, 0)}},
{"frame": 30, "rotations": {"Leg.FL": (-18, 0, -5), "Leg.MR": (-18, 0, 4), "Leg.BL": (-18, 0, -4), "Leg.FR": (18, 0, 5), "Leg.ML": (18, 0, -4), "Leg.BR": (18, 0, 4), "Tail.2": (0, 0, -9)}},
]),
("BeakRend", 34, False, [
{"frame": 1},
{"frame": 9, "locations": {"Root": (0, 0.12, -0.05)}, "rotations": {"Body": (-9, 0, 0), "Head": (-24, 0, 0), "Jaw": (24, 0, 0), "Wing.L": (0, -16, -10), "Wing.R": (0, 16, 10)}},
{"frame": 15, "locations": {"Root": (0, -0.2, 0.05)}, "rotations": {"Body": (15, 0, 0), "Head": (28, 0, 0), "Jaw": (-6, 0, 0)}},
{"frame": 23, "rotations": {"Head": (-8, 0, 0), "Jaw": (12, 0, 0)}},
{"frame": 34},
]),
("FurnaceBurst", 46, False, [
{"frame": 1},
{"frame": 12, "locations": {"Root": (0, 0, 0.1)}, "scales": {"Body": (0.94, 0.94, 0.94)}, "rotations": {"Wing.L": (-18, 18, -22), "Wing.R": (-18, -18, 22), "Head": (-12, 0, 0), "Jaw": (18, 0, 0), "Tail.1": (-12, 0, 0)}},
{"frame": 20, "locations": {"Root": (0, -0.08, 0.22)}, "scales": {"Body": (1.1, 1.1, 1.1)}, "rotations": {"Wing.L": (18, -62, -64), "Wing.R": (18, 62, 64), "Head": (20, 0, 0), "Jaw": (30, 0, 0), "Tail.1": (18, 0, 0), "Tail.2": (-22, 0, 0)}},
{"frame": 30, "scales": {"Body": (0.97, 0.97, 0.97)}, "rotations": {"Wing.L": (4, -18, -20), "Wing.R": (4, 18, 20), "Jaw": (4, 0, 0), "Tail.2": (8, 0, 0)}},
{"frame": 46},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.12, 0.12, -0.09)}, "rotations": {"Body": (-14, 0, 13), "Head": (22, 0, -12), "Wing.L": (24, 0, -18), "Wing.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Head": (-8, 0, 5)}},
{"frame": 28},
]),
("Death", 72, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.15, 0.08, -0.25)}, "rotations": {"Root": (0, 25, 32), "Body": (18, 0, 12), "Head": (24, 0, -10), "Jaw": (20, 0, 0), "Wing.L": (32, 0, -26), "Wing.R": (16, 0, 20)}},
{"frame": 46, "locations": {"Root": (0.28, 0.08, -0.78)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (42, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
{"frame": 72, "locations": {"Root": (0.28, 0.08, -0.82)}, "rotations": {"Root": (0, 52, 82), "Body": (30, 0, 20), "Head": (44, 0, -20), "Leg.FL": (30, 0, 0), "Leg.ML": (-24, 0, 0), "Leg.BL": (20, 0, 0), "Tail.1": (-32, 0, 0), "Tail.2": (-25, 0, 0)}},
]),
]
def build_bogbell_myconid() -> None:
"""Bell-capped fungal brute replacing Mirelord's frog model."""
asset_id = "bogbell-myconid"
reset_scene()
mats = prepare_materials({
"Bark": {"color": (0.12, 0.18, 0.095, 1), "roughness": 0.88},
"Root": {"color": (0.25, 0.31, 0.16, 1), "roughness": 0.8},
"Cap": {"color": (0.29, 0.055, 0.31, 1), "roughness": 0.6},
"CapEdge": {"color": (0.52, 0.15, 0.42, 1), "roughness": 0.52},
"Gill": {"color": (0.62, 0.55, 0.31, 1), "roughness": 0.7},
"Spore": {"color": (0.48, 1.0, 0.32, 1), "roughness": 0.16, "emission": (0.22, 0.92, 0.16, 1), "strength": 4.8},
})
specs = [
("Root", (0, 0, 0), (0, 0, 0.5), None),
("Body", (0, 0, 1.15), (0, 0, 2.25), "Root"),
("Cap", (0, -0.05, 2.18), (0, -0.05, 3.18), "Body"),
("Arm.L", (-0.58, -0.15, 1.72), (-1.28, -0.62, 0.7), "Body"),
("Arm.R", (0.58, -0.15, 1.72), (1.28, -0.62, 0.7), "Body"),
("Leg.L", (-0.38, 0.08, 1.08), (-0.58, -0.1, 0.2), "Body"),
("Leg.R", (0.38, 0.08, 1.08), (0.58, -0.1, 0.2), "Body"),
("Tendril.L", (-0.42, 0.58, 1.45), (-1.05, 1.45, 0.82), "Body"),
("Tendril.R", (0.42, 0.58, 1.45), (1.05, 1.45, 0.82), "Body"),
]
rig = armature("BogbellMyconid", specs)
# Gnarled trunk and hanging bell cap.
ellipsoid("Trunk", (0, 0.05, 1.48), (0.82, 0.68, 1.05), mats["Bark"], "Body", 2)
ellipsoid("ChestKnot", (0, -0.5, 1.62), (0.58, 0.28, 0.62), mats["Root"], "Body", 1)
cone("NeckStalk", (0, -0.02, 1.95), (0, -0.04, 2.65), 0.48, 0.36, mats["Gill"], "Cap", 8)
plate("BellCap", (0, -0.04, 2.78), (1.55, 1.38, 0.55), (0, 0, 0), mats["Cap"], "Cap", 9)
ellipsoid("CapCrown", (0, 0.02, 3.04), (1.25, 1.08, 0.42), mats["CapEdge"], "Cap", 2)
plate("GillBell", (0, -0.03, 2.58), (1.28, 1.12, 0.28), (0, 0, math.radians(180)), mats["Gill"], "Cap", 9)
for index, angle in enumerate(range(0, 360, 45)):
radians = math.radians(angle)
x, y = math.cos(radians) * 1.02, math.sin(radians) * 0.87
cone(
f"CapHorn{index}", (x * 0.9, y * 0.9, 3.12), (x * 1.38, y * 1.35, 3.34 + 0.08 * (index % 2)),
0.11, 0, mats["CapEdge"], "Cap", 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
ellipsoid(f"Eye{suffix}", (side * 0.29, -0.65, 2.18), (0.095, 0.055, 0.11), mats["Spore"], "Cap", 1)
cone(
f"FaceRoot{suffix}", (side * 0.25, -0.52, 2.03), (side * 0.42, -0.78, 1.72),
0.07, 0.015, mats["Root"], "Cap", 5,
)
ellipsoid("MouthHollow", (0, -0.68, 1.94), (0.22, 0.055, 0.13), mats["Cap"], "Cap", 1)
# Root arms end in broad knuckles for readable pummel animation.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Arm.{suffix}"
cone(f"UpperArm{suffix}", (side * 0.55, -0.12, 1.78), (side * 1.04, -0.45, 1.05), 0.3, 0.22, mats["Bark"], bone, 7)
cone(f"Forearm{suffix}", (side * 1.02, -0.44, 1.06), (side * 1.34, -0.84, 0.58), 0.24, 0.18, mats["Root"], bone, 7)
ellipsoid(f"Knuckle{suffix}", (side * 1.38, -0.91, 0.48), (0.43, 0.38, 0.32), mats["Bark"], bone, 1)
for finger in (-0.16, 0, 0.16):
cone(
f"Finger{suffix}{finger}", (side * 1.36 + finger, -1.02, 0.43),
(side * 1.46 + finger, -1.35, 0.22), 0.065, 0.012, mats["Root"], bone, 5,
)
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Leg.{suffix}"
ellipsoid(f"Hip{suffix}", (side * 0.4, 0.08, 1.0), (0.42, 0.46, 0.5), mats["Bark"], bone, 1)
cone(f"RootLeg{suffix}", (side * 0.4, 0.06, 0.95), (side * 0.62, -0.12, 0.25), 0.34, 0.22, mats["Root"], bone, 7)
for toe_index, toe_x in enumerate((-0.22, 0, 0.22)):
cone(
f"RootToe{suffix}{toe_index}", (side * 0.62 + toe_x, -0.2, 0.25),
(side * 0.72 + toe_x * 1.25, -0.78 - abs(toe_x), 0.08), 0.095, 0.015, mats["Bark"], bone, 6,
)
# Rear tendrils drag through mire. Spore sacs pulse during eruption.
for side, suffix in ((-1, "L"), (1, "R")):
bone = f"Tendril.{suffix}"
cone(f"TendrilBase{suffix}", (side * 0.4, 0.5, 1.38), (side * 0.78, 1.22, 0.82), 0.22, 0.12, mats["Root"], bone, 7)
cone(f"TendrilTip{suffix}", (side * 0.76, 1.18, 0.84), (side * 1.28, 1.85, 0.3), 0.13, 0.018, mats["Bark"], bone, 6)
ellipsoid(f"SporeSac{suffix}", (side * 0.82, 0.72, 1.25), (0.24, 0.31, 0.3), mats["Spore"], bone, 1)
for index, (x, y, z, size) in enumerate(((-0.5, 0.45, 1.88, 0.16), (0.48, 0.5, 1.72, 0.2), (-0.28, 0.62, 1.35, 0.13))):
ellipsoid(f"BodySpore{index}", (x, y, z), (size, size * 0.82, size * 1.1), mats["Spore"], "Body", 1)
body = join_parts("BogbellMyconid", rig)
clips = actions(rig, bogbell_actions())
export_asset(
asset_id, "Bogbell Myconid", rig, body, clips,
[
("Body", (0, 0, 1.45), (1.05, 0.95, 1.35)),
("Cap", (0, 0, 2.82), (1.68, 1.48, 0.72)),
("Roots", (0, 0.38, 0.62), (1.58, 1.75, 0.72)),
],
(0, 0, 1.65), 8.5, "SporeEruption", 21,
)
add_metadata(asset_id, "Original bell-capped fungal mire creature designed for I Want to Heal")
def bogbell_actions():
return [
("Idle", 60, True, [
{"frame": 1},
{"frame": 15, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 30, "scales": {"Cap": (0.98, 0.98, 1.03)}, "rotations": {"Cap": (-1, 0, 3), "Tendril.L": (0, 0, -7), "Tendril.R": (0, 0, 7)}},
{"frame": 45, "locations": {"Root": (0, 0, 0.04)}, "scales": {"Cap": (1.03, 1.03, 0.98)}, "rotations": {"Cap": (2, 0, -3), "Arm.L": (0, 0, -3), "Arm.R": (0, 0, 3), "Tendril.L": (0, 0, 7), "Tendril.R": (0, 0, -7)}},
{"frame": 60},
]),
("BurrowRush", 32, True, [
{"frame": 1, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 9, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 17, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
{"frame": 25, "locations": {"Root": (0, 0, -0.26)}, "rotations": {"Cap": (-9, 0, 0), "Leg.L": (16, 0, 0), "Leg.R": (-16, 0, 0), "Tendril.L": (14, 0, 10), "Tendril.R": (14, 0, -10)}},
{"frame": 32, "locations": {"Root": (0, 0, -0.12)}, "rotations": {"Body": (10, 0, 0), "Arm.L": (26, 0, -12), "Arm.R": (26, 0, 12), "Leg.L": (-16, 0, 0), "Leg.R": (16, 0, 0), "Tendril.L": (-18, 0, -12), "Tendril.R": (-18, 0, 12)}},
]),
("RootPummel", 36, False, [
{"frame": 1},
{"frame": 10, "locations": {"Root": (0, 0.1, 0.06)}, "rotations": {"Body": (-12, 0, 0), "Cap": (-8, 0, 0), "Arm.L": (-42, 0, -30), "Arm.R": (-42, 0, 30)}},
{"frame": 17, "locations": {"Root": (0, -0.12, -0.14)}, "rotations": {"Body": (22, 0, 0), "Cap": (18, 0, 0), "Arm.L": (58, 0, 16), "Arm.R": (58, 0, -16)}},
{"frame": 25, "rotations": {"Body": (-5, 0, 0), "Arm.L": (12, 0, -6), "Arm.R": (12, 0, 6)}},
{"frame": 36},
]),
("SporeEruption", 48, False, [
{"frame": 1},
{"frame": 13, "locations": {"Root": (0, 0, -0.12)}, "scales": {"Cap": (0.88, 0.88, 1.16)}, "rotations": {"Body": (-13, 0, 0), "Cap": (-15, 0, 0), "Arm.L": (-26, 0, -28), "Arm.R": (-26, 0, 28), "Tendril.L": (-28, 0, -24), "Tendril.R": (-28, 0, 24)}},
{"frame": 21, "locations": {"Root": (0, -0.04, 0.24)}, "scales": {"Cap": (1.18, 1.18, 0.9), "Body": (1.08, 1.08, 1.08)}, "rotations": {"Body": (18, 0, 0), "Cap": (19, 0, 0), "Arm.L": (18, 0, 62), "Arm.R": (18, 0, -62), "Tendril.L": (32, 0, 48), "Tendril.R": (32, 0, -48)}},
{"frame": 32, "scales": {"Cap": (0.97, 0.97, 1.04), "Body": (0.97, 0.97, 0.97)}, "rotations": {"Cap": (-5, 0, 0), "Arm.L": (4, 0, 12), "Arm.R": (4, 0, -12)}},
{"frame": 48},
]),
("Stagger", 28, False, [
{"frame": 1},
{"frame": 6, "locations": {"Root": (0.14, 0.1, -0.08)}, "rotations": {"Body": (-15, 0, 13), "Cap": (24, 0, -18), "Arm.L": (20, 0, -18), "Arm.R": (-8, 0, 12)}},
{"frame": 15, "rotations": {"Body": (7, 0, -6), "Cap": (-8, 0, 7)}},
{"frame": 28},
]),
("Death", 74, False, [
{"frame": 1},
{"frame": 20, "locations": {"Root": (0.14, 0.1, -0.3)}, "rotations": {"Root": (0, 24, 30), "Body": (20, 0, 12), "Cap": (28, 0, -18), "Arm.L": (30, 0, -26), "Arm.R": (16, 0, 20), "Tendril.L": (-24, 0, -14), "Tendril.R": (-16, 0, 18)}},
{"frame": 48, "locations": {"Root": (0.28, 0.1, -0.86)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (48, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
{"frame": 74, "locations": {"Root": (0.28, 0.1, -0.9)}, "rotations": {"Root": (0, 54, 84), "Body": (34, 0, 24), "Cap": (50, 0, -30), "Arm.L": (52, 0, -42), "Arm.R": (28, 0, 34), "Leg.L": (24, 0, 0), "Leg.R": (-18, 0, 0), "Tendril.L": (-40, 0, -24), "Tendril.R": (-34, 0, 26)}},
]),
]
def main() -> None:
bpy.context.preferences.filepaths.save_version = 0
OUT_ROOT.mkdir(parents=True, exist_ok=True)
build_brassbeak_basilisk()
build_bogbell_myconid()
if __name__ == "__main__":
main()
+2 -1
View File
@@ -84,6 +84,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const globalCooldownUntil = useGameStore((state) => state.globalCooldownUntil);
const mana = useGameStore((state) => state.mana);
const phase = useGameStore((state) => state.phase);
const healerAlive = useGameStore((state) => state.party.some((member) => member.id === "aelia" && member.hp > 0));
const selected = useGameStore((state) => state.party.find((member) => member.id === state.selectedMemberId)!);
const activeCast = useGameStore((state) => state.activeCast);
const castAbility = useGameStore((state) => state.castAbility);
@@ -95,7 +96,7 @@ function AbilityButton({ abilityId }: { abilityId: (typeof ABILITY_ORDER)[number
const globalRemaining = Math.max(0, globalCooldownUntil - time);
const noDispel = abilityId === "purify" && selected.debuffs.length === 0;
const invalidTarget = ability.targeting === "ally" && selected.hp <= 0;
const disabled = phase !== "combat" || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const disabled = phase !== "combat" || !healerAlive || activeCast !== null || remaining > 0 || globalRemaining > 0 || mana < manaCost || noDispel || invalidTarget;
const resourceCopy = `${manaCost ? `${manaCost} mana` : "free"}${castTime ? ` · ${castTime.toFixed(1)}s` : ""}`;
return (
+167 -53
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { buildCollections, MAX_HUNTER_NAME_LENGTH, MODE_COPY, normalizeHunterName } from "../frontend/data";
import { formatPlayTime, formatSaveTimestamp } from "../frontend/saveRepository";
import { resolveSaveContinuation, saveVersionsMatch } from "../frontend/saveContinuation";
import { useActiveHunter, useFrontendStore } from "../frontend/store";
import type { GameModeId, SaveSlotId, SaveSlotState } from "../frontend/types";
import { useMenuController, type MenuAction } from "../input/useMenuController";
@@ -80,11 +81,46 @@ function ControllerLegend({ back = false }: { back?: boolean }) {
return <div className="controller-legend"><span><b>{DEFAULT_CONTROLLER_GLYPHS.confirm}</b> Select</span>{back && <span><b>{DEFAULT_CONTROLLER_GLYPHS.back}</b> Back</span>}<span><b></b> Navigate</span></div>;
}
function SaveLibraryContext({ slots, accountId }: { slots: readonly SaveSlotState[]; accountId: string | null }) {
return (
<FrontSurface className="login-save-context" bottom ariaLabel="Save slot information">
<header className="context-header"><span>Device saves</span><b>{accountId ? "SERVER LINKED" : "OFFLINE READY"}</b></header>
<div className="login-save-list">
{slots.map((slot) => {
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
return (
<article key={slot.id} className={save ? "has-save" : "is-empty"}>
<b>{String(slot.id).padStart(2, "0")}</b>
{save ? (
<>
<div className="login-save-avatar">{save.hunterName[0]}</div>
<span>
<small>{slot.local ? "On this Thor" : "Online copy"}</small>
<strong>{save.hunterName}</strong>
<em>Level {save.healers[save.activeClassId].level} {healer?.name} · {save.location}</em>
</span>
<time><strong>{formatPlayTime(save.playSeconds)}</strong><small>{formatSaveTimestamp(save.updatedAt)}</small></time>
</>
) : (
<span className="login-empty-copy"><small>Available slot</small><strong>New hunter</strong><em>Continue offline to create</em></span>
)}
</article>
);
})}
</div>
<footer className="login-save-footer"><span>Save details update from upper-screen selection</span><b>LOWER DISPLAY · INFORMATION ONLY</b></footer>
</FrontSurface>
);
}
function LoginScreen() {
const restoreSession = useFrontendStore((state) => state.restoreSession);
const signIn = useFrontendStore((state) => state.signIn);
const createAccount = useFrontendStore((state) => state.createAccount);
const continueOffline = useFrontendStore((state) => state.continueOffline);
const slots = useFrontendStore((state) => state.slots);
const accountId = useFrontendStore((state) => state.accountId);
const notice = useFrontendStore((state) => state.notice);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -101,7 +137,7 @@ function LoginScreen() {
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "sign-in", run: () => { void signIn(username, password); } },
{ id: "create-account", run: () => { void createAccount(username, password); } },
{ id: "offline", run: continueOffline },
{ id: "continue", run: continueOffline },
], [continueOffline, createAccount, password, signIn, username]);
const controller = useMenuController(actions);
@@ -116,7 +152,7 @@ function LoginScreen() {
<div className="login-copy">
<span>Offline-first hunter records</span>
<h1>Keep everyone standing.</h1>
<p>Your save always lives on this device. Sign in only when you want a second copy for PC AYN Thor handoff.</p>
<p>Continue to your saved hunters. Sign in when you want online copies for PC AYN Thor handoff.</p>
</div>
<form className="login-panel" onSubmit={(event) => { event.preventDefault(); submitSignIn(); }}>
<label htmlFor="account-username">Username</label>
@@ -153,38 +189,27 @@ function LoginScreen() {
<FocusButton id="create-account" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={() => { void createAccount(username, password); }}>
<span>Create account</span><small>Required for first sync</small>
</FocusButton>
<FocusButton id="offline" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue with offline save</span><small>No account required</small>
<FocusButton id="continue" focusedId={controller.focusedId} focus={controller.focus} className="front-secondary" type="button" onClick={continueOffline}>
<span>Continue</span><small>Choose saved hunter</small>
</FocusButton>
</form>
{notice && <div className="front-notice" role="status" aria-live="polite">{notice}</div>}
<ControllerLegend />
</FrontSurface>
}
bottom={
<FrontSurface className="login-context" bottom ariaLabel="Offline save explanation">
<BrandMark compact />
<div className="offline-promise">
<span className="context-kicker">How saving works</span>
<ol>
<li><b>01</b><span><strong>Play offline</strong><small>Every change writes to device storage first.</small></span></li>
<li><b>02</b><span><strong>Create or sign in</strong><small>Account is secured by the TrueNAS game server.</small></span></li>
<li><b>03</b><span><strong>Move devices</strong><small>Upload or download any of your three server save slots.</small></span></li>
</ol>
</div>
<div className="device-route"><span>PC</span><i></i><b>ONLINE COPY</b><i></i><span>THOR</span></div>
</FrontSurface>
}
bottom={<SaveLibraryContext slots={slots} accountId={accountId} />}
/>
);
}
function SlotCard({ slot, selected, focused, onSelect, onFocus }: { slot: SaveSlotState; selected: boolean; focused: boolean; onSelect: () => void; onFocus: () => void }) {
const save = slot.local;
const continuation = resolveSaveContinuation(slot);
const save = slot.local ?? slot.online;
const healer = save ? HEALER_CLASSES[save.activeClassId] : null;
const copyStatus = continuation === "choose" ? "Newer online" : continuation === "online" ? "Online only" : null;
return (
<button className={`save-slot ${selected ? "is-selected" : ""} ${focused ? "is-controller-focused" : ""}`} onClick={onSelect} onFocus={onFocus} onPointerEnter={onFocus}>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}</span>
<span className="slot-number">Slot {String(slot.id).padStart(2, "0")}{copyStatus && <b>{copyStatus}</b>}</span>
{save ? (
<>
<div className="slot-portrait">{save.hunterName[0]}<i></i></div>
@@ -213,11 +238,15 @@ function SaveScreen() {
const copySlot = useFrontendStore((state) => state.copySlot);
const deleteSlot = useFrontendStore((state) => state.deleteSlot);
const navigate = useFrontendStore((state) => state.navigate);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | null>(null);
const [dialog, setDialog] = useState<"create" | "copy" | "delete" | "version" | null>(null);
const [hunterName, setHunterName] = useState("");
const [resolvingOnline, setResolvingOnline] = useState(false);
const [versionError, setVersionError] = useState("");
const selected = slots.find((slot) => slot.id === selectedSlotId)!;
const hasLocal = Boolean(selected.local);
const hasOnline = Boolean(selected.online);
const continuation = resolveSaveContinuation(selected);
const primaryActionId = continuation === "create" ? "create" : "play";
const finishCreation = () => {
if (createSlot(selectedSlotId, hunterName)) {
@@ -235,7 +264,41 @@ function SaveScreen() {
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "create"
const continueWithOnline = async () => {
if (resolvingOnline) return;
setResolvingOnline(true);
setVersionError("");
await downloadSlot(selectedSlotId);
const refreshed = useFrontendStore.getState().slots.find((slot) => slot.id === selectedSlotId);
if (refreshed && saveVersionsMatch(refreshed.local, refreshed.online)) {
setDialog(null);
setResolvingOnline(false);
playSlot(selectedSlotId);
return;
}
setVersionError(useFrontendStore.getState().notice || "Online save could not be loaded.");
setResolvingOnline(false);
};
const continueSelected = () => {
if (continuation === "create") return openCreation();
if (continuation === "local") return playSlot(selectedSlotId);
if (continuation === "online") {
void continueWithOnline();
return;
}
setVersionError("");
setDialog("version");
requestDisplaySurface("top");
};
const actions = useMemo<MenuAction[]>(() => dialog === "version"
? [
{ id: "version-online", run: () => { void continueWithOnline(); }, enabled: !resolvingOnline },
{ id: "version-local", run: () => { setDialog(null); playSlot(selectedSlotId); }, enabled: !resolvingOnline },
{ id: "cancel-version", run: () => setDialog(null), enabled: !resolvingOnline },
]
: dialog === "create"
? [
{ id: "confirm-create", run: finishCreation },
{ id: "cancel-create", run: () => setDialog(null) },
@@ -248,17 +311,25 @@ function SaveScreen() {
{ id: "cancel-delete", run: () => setDialog(null) },
]
: [
...slots.map((slot) => ({ id: `slot-${slot.id}`, run: () => selectSlot(slot.id) })),
{ id: hasLocal ? "play" : "create", run: () => hasLocal ? playSlot(selectedSlotId) : openCreation() },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId) },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId) },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal },
{ id: "back", run: () => navigate("login") },
], [accountId, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? setDialog(null) : navigate("login") });
...slots.map((slot, index) => ({
id: `slot-${slot.id}`,
run: () => selectSlot(slot.id),
neighbors: {
left: `slot-${slots[Math.max(0, index - 1)].id}`,
right: `slot-${slots[Math.min(slots.length - 1, index + 1)].id}`,
down: primaryActionId,
},
})),
{ id: primaryActionId, run: continueSelected, enabled: !resolvingOnline, neighbors: { up: `slot-${selectedSlotId}`, right: "upload" } },
{ id: "upload", run: () => uploadSlot(selectedSlotId), enabled: hasLocal && Boolean(accountId), neighbors: { left: primaryActionId, right: "download", up: "slot-1" } },
{ id: "download", run: () => downloadSlot(selectedSlotId), enabled: hasOnline && Boolean(accountId), neighbors: { left: "upload", right: "copy", up: "slot-2" } },
{ id: "copy", run: () => openSaveDialog("copy"), enabled: hasLocal, neighbors: { left: "download", right: "delete", up: "slot-2" } },
{ id: "delete", run: () => openSaveDialog("delete"), enabled: hasLocal, neighbors: { left: "copy", right: "back", up: "slot-3" } },
{ id: "back", run: () => navigate("login"), neighbors: { left: "delete", up: "slot-3" } },
], [accountId, continuation, copySlot, createSlot, deleteSlot, dialog, downloadSlot, hasLocal, hasOnline, hunterName, navigate, playSlot, primaryActionId, resolvingOnline, selectSlot, selectedSlotId, slots, uploadSlot]);
const controller = useMenuController(actions, { onBack: () => dialog ? resolvingOnline ? undefined : setDialog(null) : navigate("login") });
const cloudStatus = !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
const cloudStatus = continuation === "choose" ? "Newer online save" : !accountId ? "Offline mode" : selected.online ? "Online version available" : "No online version";
return (
<DualDisplayFrame
top={
@@ -276,10 +347,47 @@ function SaveScreen() {
/>
))}
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><ControllerLegend back /></div>
<div className="save-top-actions">
<FocusButton id={primaryActionId} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={continueSelected}>
<span>{continuation === "create" ? "Create hunter" : resolvingOnline ? "Loading online save…" : "Continue"}</span>
<small>{continuation === "create" ? `Use slot ${selectedSlotId}` : continuation === "online" ? "Download online copy" : continuation === "choose" ? "Choose online or device copy" : `Slot ${selectedSlotId} · ${selected.local?.hunterName}`}</small>
</FocusButton>
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}><strong>Upload</strong><small>Device server</small></FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}><strong>Download</strong><small>Server device</small></FocusButton>
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}><strong>Copy</strong><small>Duplicate save</small></FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}><strong>Delete</strong><small>Erase device copy</small></FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}><strong>Back</strong><small>Login screen</small></FocusButton>
</div>
<div className="save-footer"><span>Autosave <b>OFFLINE FIRST</b></span><div className="save-top-notice" role="status" aria-live="polite">{notice || "Lower display shows selected save details."}</div><ControllerLegend back /></div>
{dialog && (
<div className="front-dialog" role="dialog" aria-modal="true" aria-label={dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "create" ? (
<div className={`front-dialog ${dialog === "version" ? "version-dialog" : ""}`} role="dialog" aria-modal="true" aria-label={dialog === "version" ? "Choose save version" : dialog === "create" ? "Name new hunter" : dialog === "copy" ? "Copy save" : "Delete save"}>
{dialog === "version" && selected.local && selected.online ? (
<div className="version-choice-dialog">
<span>Newer online save found</span>
<h2>Which save do you want?</h2>
<p>Online choice replaces this device copy. Device choice keeps online copy unchanged.</p>
<div className="version-comparison">
<article className="is-newer">
<header><span>Online copy</span><b>NEWER</b></header>
<strong>{selected.online.hunterName}</strong>
<time>{formatSaveTimestamp(selected.online.updatedAt)}</time>
<small>{formatPlayTime(selected.online.playSeconds)} · Level {selected.online.healers[selected.online.activeClassId].level}</small>
</article>
<article>
<header><span>Device copy</span><b>OFFLINE</b></header>
<strong>{selected.local.hunterName}</strong>
<time>{formatSaveTimestamp(selected.local.updatedAt)}</time>
<small>{formatPlayTime(selected.local.playSeconds)} · Level {selected.local.healers[selected.local.activeClassId].level}</small>
</article>
</div>
<div className="dialog-actions version-actions">
<FocusButton id="version-online" focusedId={controller.focusedId} focus={controller.focus} className="front-primary" disabled={resolvingOnline} onClick={() => { void continueWithOnline(); }}><span>{resolvingOnline ? "Loading…" : "Continue online copy"}</span><small>{formatSaveTimestamp(selected.online.updatedAt)}</small></FocusButton>
<FocusButton id="version-local" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => { setDialog(null); playSlot(selectedSlotId); }}><span>Continue device copy</span><small>{formatSaveTimestamp(selected.local.updatedAt)}</small></FocusButton>
<FocusButton id="cancel-version" focusedId={controller.focusedId} focus={controller.focus} disabled={resolvingOnline} onClick={() => setDialog(null)}>Cancel</FocusButton>
</div>
{versionError && <div className="version-choice-error" role="alert">{versionError}</div>}
</div>
) : dialog === "create" ? (
<form onSubmit={(event) => { event.preventDefault(); finishCreation(); }}>
<span>New offline save</span><h2>Name your hunter</h2><p>This name identifies the character in local and online save lists.</p>
<label htmlFor="new-hunter-name">Hunter name</label>
@@ -324,31 +432,37 @@ function SaveScreen() {
</FrontSurface>
}
bottom={
<FrontSurface className="save-context" bottom ariaLabel="Selected save management">
<FrontSurface className="save-context" bottom ariaLabel="Selected save information">
<header className="context-header"><span>Slot {selectedSlotId}</span><b>{cloudStatus}</b></header>
<div className="selected-save-summary">
{selected.local ? (
<><div className="summary-avatar">{selected.local.hunterName[0]}</div><span><small>Local record</small><h2>{selected.local.hunterName}</h2><p>{selected.local.location} · {formatPlayTime(selected.local.playSeconds)}</p><time>{formatSaveTimestamp(selected.local.updatedAt)}</time></span></>
{selected.local ?? selected.online ? (
<><div className="summary-avatar">{(selected.local ?? selected.online)!.hunterName[0]}</div><span><small>{selected.local ? "Device save" : "Online copy only"}</small><h2>{(selected.local ?? selected.online)!.hunterName}</h2><p>{(selected.local ?? selected.online)!.location}</p><time>{formatSaveTimestamp((selected.local ?? selected.online)!.updatedAt)}</time></span></>
) : (
<><div className="summary-avatar is-empty"></div><span><small>Local record</small><h2>Empty slot</h2><p>Create a hunter or download an online version.</p></span></>
)}
</div>
{selected.online && <div className="online-record"><span><b>ONLINE</b>{selected.online.hunterName}</span><time>{formatSaveTimestamp(selected.online.updatedAt)}</time></div>}
<div className="save-actions">
<FocusButton id={hasLocal ? "play" : "create"} focusedId={controller.focusedId} focus={controller.focus} className="front-primary" onClick={() => hasLocal ? playSlot(selectedSlotId) : openCreation()}>
{hasLocal ? "Continue offline save" : "Create new hunter"}<small>{DEFAULT_CONTROLLER_GLYPHS.confirm}</small>
</FocusButton>
<div className="sync-actions">
<FocusButton id="upload" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal || !accountId} onClick={() => uploadSlot(selectedSlotId)}> Sync offline to server</FocusButton>
<FocusButton id="download" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasOnline || !accountId} onClick={() => downloadSlot(selectedSlotId)}> Overwrite with online</FocusButton>
</div>
<div className="record-actions">
<FocusButton id="copy" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} onClick={() => openSaveDialog("copy")}>Copy save</FocusButton>
<FocusButton id="delete" focusedId={controller.focusedId} focus={controller.focus} disabled={!hasLocal} className="danger-link" onClick={() => openSaveDialog("delete")}>Delete save</FocusButton>
<FocusButton id="back" focusedId={controller.focusedId} focus={controller.focus} onClick={() => navigate("login")}>Back</FocusButton>
</div>
{(selected.local ?? selected.online) && (() => {
const save = (selected.local ?? selected.online)!;
const healer = HEALER_CLASSES[save.activeClassId];
return (
<>
<div className="save-dossier-stats">
<span><small>Active healer</small><strong>Lv {save.healers[save.activeClassId].level}</strong><em>{healer.name}</em></span>
<span><small>Play time</small><strong>{formatPlayTime(save.playSeconds)}</strong><em>Local activity</em></span>
<span><small>Boss kills</small><strong>{save.stats.totalBossKills}</strong><em>{save.stats.flawlessClears} flawless</em></span>
</div>
<div className="save-dossier-records">
<span><small>Roguelike best</small><b>Round {save.stats.highestRoguelikeRound}</b></span>
<span><small>Endless best</small><b>{save.stats.highestRogueTrialsEndlessKills} kills</b></span>
</div>
</>
);
})()}
<div className="save-copy-state">
<span><i className={selected.local ? "is-present" : ""} />Device copy<b>{selected.local ? formatSaveTimestamp(selected.local.updatedAt) : "Not present"}</b></span>
<span><i className={selected.online ? "is-present" : ""} />Online copy<b>{selected.online ? formatSaveTimestamp(selected.online.updatedAt) : accountId ? "Not uploaded" : "Sign-in required"}</b></span>
</div>
<div className="front-notice is-lower">{notice || "All gameplay changes save to local storage automatically."}</div>
<div className="front-notice is-lower">{notice || "Use upper display for every save action. Details here follow selected slot."}</div>
</FrontSurface>
}
/>
+119 -24
View File
@@ -1,6 +1,6 @@
import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
@@ -29,7 +29,7 @@ import { useGameStore } from "../game/store";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
import { bossCanTrackTarget } from "./boss/bossDeathVisuals";
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
@@ -66,6 +66,85 @@ const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
interface BossFadeMaterial {
material: THREE.Material;
baseOpacity: number;
baseTransparent: boolean;
baseDepthWrite: boolean;
}
function createBossRenderModel(source: THREE.Object3D) {
const model = cloneSkeleton(source);
const materialClones = new Map<THREE.Material, THREE.Material>();
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.castShadow = true;
object.receiveShadow = true;
const cloneMaterial = (material: THREE.Material) => {
const existing = materialClones.get(material);
if (existing) return existing;
const clone = material.clone();
materialClones.set(material, clone);
return clone;
};
object.material = Array.isArray(object.material)
? object.material.map(cloneMaterial)
: cloneMaterial(object.material);
});
return {
model,
fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
material,
baseOpacity: material.opacity,
baseTransparent: material.transparent,
baseDepthWrite: material.depthWrite,
})),
};
}
function applyBossOpacity(materials: readonly BossFadeMaterial[], opacity: number) {
const fading = opacity < 0.999;
for (const entry of materials) {
const transparent = entry.baseTransparent || fading;
if (entry.material.transparent !== transparent) {
entry.material.transparent = transparent;
entry.material.needsUpdate = true;
}
entry.material.opacity = entry.baseOpacity * opacity;
entry.material.depthWrite = fading ? false : entry.baseDepthWrite;
}
}
function useBossDeathFade(
group: RefObject<THREE.Group | null>,
light: RefObject<THREE.PointLight | null>,
materials: readonly BossFadeMaterial[],
defeated: boolean,
baseLightIntensity: number,
) {
const elapsed = useRef(0);
const lastOpacity = useRef(1);
useFrame((_, delta) => {
if (!defeated) {
elapsed.current = 0;
if (lastOpacity.current !== 1) {
lastOpacity.current = 1;
if (group.current) group.current.visible = true;
if (light.current) light.current.intensity = baseLightIntensity;
applyBossOpacity(materials, 1);
}
return;
}
elapsed.current += delta;
const opacity = bossDeathOpacity(elapsed.current);
if (opacity === lastOpacity.current) return;
lastOpacity.current = opacity;
if (group.current) group.current.visible = opacity > 0;
if (light.current) light.current.intensity = baseLightIntensity * opacity;
applyBossOpacity(materials, opacity);
});
}
function encounterBossAt(state: GameStoreState, bossIndex: number) {
return bossIndex === 0
? { boss: state.boss, motion: state.bossMotion }
@@ -587,14 +666,34 @@ function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
function BossFallback({ bossIndex }: { bossIndex: number }) {
const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
const group = useRef<THREE.Group>(null);
const material = useRef<THREE.MeshStandardMaterial>(null);
const deathElapsed = useRef(0);
useFrame((_, delta) => {
const defeated = (boss?.hp ?? 1) <= 0;
deathElapsed.current = defeated ? deathElapsed.current + delta : 0;
const opacity = bossDeathOpacity(deathElapsed.current);
if (group.current) group.current.visible = opacity > 0;
if (material.current) {
const transparent = opacity < 0.999;
if (material.current.transparent !== transparent) {
material.current.transparent = transparent;
material.current.needsUpdate = true;
}
material.current.opacity = opacity;
material.current.depthWrite = opacity >= 0.999;
}
});
if (!boss || !motion) return null;
const position = motion.position;
const bossId = boss.id;
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
<group ref={group} position={[position[0], 1.1, position[1]]}>
<mesh castShadow>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial ref={material} color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
</group>
);
}
@@ -606,19 +705,17 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(null);
const gltf = useGLTF(BULL_URL, false, true);
const bullScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, bullScene);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
bullScene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [bullScene]);
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
}, [fadeMaterials]);
useBossDeathFade(group, light, fadeMaterials, defeated, 2.8);
const clipName = phase === "victory" || defeated
? "Death"
@@ -675,7 +772,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
return (
<group ref={group}>
<primitive object={bullScene} scale={0.81} />
<pointLight color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
<pointLight ref={light} color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
</group>
);
}
@@ -695,19 +792,17 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
const defeated = bossHp <= 0;
const group = useRef<THREE.Group>(null);
const light = useRef<THREE.PointLight>(null);
const gltf = useGLTF(config.url, false, true);
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
const { model, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
const { actions } = useAnimations(gltf.animations, model);
const targetPosition = useMemo(() => new THREE.Vector3(), []);
useEffect(() => {
model.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.castShadow = true;
object.receiveShadow = true;
}
});
}, [kind, model]);
return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
}, [fadeMaterials]);
useBossDeathFade(group, light, fadeMaterials, defeated, 2.5);
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);
@@ -770,7 +865,7 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
return (
<group ref={group}>
<primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
<pointLight color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
<pointLight ref={light} color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
</group>
);
}
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import {
BOSS_INDICATOR_DEATH_FADE_MS,
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
advanceBossIndicatorOpacity,
bossCanTrackTarget,
bossDeathOpacity,
} from "./bossDeathVisuals";
describe("boss death visuals", () => {
@@ -18,4 +22,10 @@ describe("boss death visuals", () => {
expect(advanceBossIndicatorOpacity(halfway, true, BOSS_INDICATOR_DEATH_FADE_MS / 2_000)).toBe(0);
expect(advanceBossIndicatorOpacity(0.4, false, 1 / 60)).toBe(1);
});
it("holds the death pose for a few seconds, then fades the model", () => {
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS)).toBe(1);
expect(bossDeathOpacity(BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS / 2)).toBeCloseTo(0.5);
expect(bossDeathOpacity(BOSS_DEATH_DESPAWN_SECONDS)).toBe(0);
});
});
+7
View File
@@ -1,3 +1,10 @@
export {
BOSS_DEATH_DESPAWN_SECONDS,
BOSS_DEATH_FADE_SECONDS,
BOSS_DEATH_HOLD_SECONDS,
bossDeathOpacity,
} from "../../game/bossDeath";
export const BOSS_INDICATOR_DEATH_FADE_MS = 250;
export function bossCanTrackTarget(hp: number) {
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createHunterSave } from "./data";
import { resolveSaveContinuation, saveVersionsMatch } from "./saveContinuation";
function save(updatedAt: string) {
return createHunterSave(1, updatedAt, "Test Hunter");
}
describe("save continuation", () => {
it("creates only when neither copy exists", () => {
expect(resolveSaveContinuation({ local: null, online: null })).toBe("create");
});
it("uses whichever single copy exists", () => {
const local = save("2026-07-13T12:00:00.000Z");
const online = save("2026-07-13T13:00:00.000Z");
expect(resolveSaveContinuation({ local, online: null })).toBe("local");
expect(resolveSaveContinuation({ local: null, online })).toBe("online");
});
it("asks only when online copy is newer", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T13:00:00.000Z") })).toBe("choose");
expect(resolveSaveContinuation({ local, online: save("2026-07-13T11:00:00.000Z") })).toBe("local");
expect(resolveSaveContinuation({ local, online: save(local.updatedAt) })).toBe("local");
});
it("matches downloaded copies by slot and timestamp", () => {
const local = save("2026-07-13T12:00:00.000Z");
expect(saveVersionsMatch(local, save(local.updatedAt))).toBe(true);
expect(saveVersionsMatch(local, save("2026-07-13T13:00:00.000Z"))).toBe(false);
expect(saveVersionsMatch(local, null)).toBe(false);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { HunterSave, SaveSlotState } from "./types";
export type SaveContinuation = "create" | "local" | "online" | "choose";
function saveTimestamp(save: HunterSave): number | null {
const timestamp = Date.parse(save.updatedAt);
return Number.isFinite(timestamp) ? timestamp : null;
}
export function resolveSaveContinuation(slot: Pick<SaveSlotState, "local" | "online">): SaveContinuation {
if (!slot.local) return slot.online ? "online" : "create";
if (!slot.online) return "local";
const localTimestamp = saveTimestamp(slot.local);
const onlineTimestamp = saveTimestamp(slot.online);
if (localTimestamp !== null && onlineTimestamp !== null && onlineTimestamp > localTimestamp) return "choose";
return "local";
}
export function saveVersionsMatch(local: HunterSave | null, online: HunterSave | null): boolean {
return Boolean(local && online && local.slotId === online.slotId && local.updatedAt === online.updatedAt);
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createClassInventory } from "./healers";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { canAddBossToEncounter } from "./bossSelection";
import { useGameStore } from "./store";
import type { BossId } from "./types";
@@ -27,7 +28,9 @@ function simulateControlledBattle(bossIds: readonly [BossId, BossId], maxSeconds
describe("full-mechanics dual-boss battle simulations", () => {
const combinations: readonly (readonly [BossId, BossId])[] = AVAILABLE_BOSS_IDS.flatMap((first, index) =>
AVAILABLE_BOSS_IDS.slice(index + 1).map((second) => [first, second] as const),
AVAILABLE_BOSS_IDS.slice(index + 1)
.filter((second) => canAddBossToEncounter([first], second))
.map((second) => [first, second] as const),
);
it.each(combinations)("party rotations defeat %s + %s", (first, second) => {
+16
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "./bossCatalog";
import { createBossMotionState, createBossState } from "./bossMechanics";
import { BOSS_MECHANIC_POOL, BOSS_MECHANIC_REGISTRY, bossMechanicName } from "./bosses/mechanicPool";
import { ALTERNATE_BOSS_CONFIG } from "./bossVisuals";
describe("boss catalog", () => {
it("derives the available roster from every catalog definition", () => {
@@ -60,4 +61,19 @@ describe("boss catalog", () => {
const kits = Object.values(BOSS_DEFINITIONS).map((boss) => boss.mechanicIds.join(","));
expect(new Set(kits)).toHaveLength(AVAILABLE_BOSS_IDS.length);
});
it("uses the boar model's full authored death performance", () => {
expect(ALTERNATE_BOSS_CONFIG["bristlequake-boar"].death).toBe("Dying");
});
it("uses original animated creatures instead of the retired chicken and frog visuals", () => {
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"]).toMatchObject({
idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death",
});
expect(ALTERNATE_BOSS_CONFIG["cluckhorn-colossus"].url).toContain("brassbeak-basilisk");
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"]).toMatchObject({
idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death",
});
expect(ALTERNATE_BOSS_CONFIG["mirelord-frog"].url).toContain("bogbell-myconid");
});
});
+4 -4
View File
@@ -112,8 +112,8 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
summary: "Gallops through charged lanes before crashing onto the marked healer.", mechanicIds: ["bull-charge", "crushing-pounce", "stormfall"], maxHp: 480, archetype: "bull",
},
"cluckhorn-colossus": {
name: "Cluckhorn Colossus", title: "The Roostbreaker", icon: "✹", accent: "#f0b85d",
summary: "Stampedes sideways and drops cracking shell bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
name: "Brassbeak Basilisk", title: "The Furnace Nest", icon: "✹", accent: "#dba33e",
summary: "Scuttles through lateral lanes and vents furnace bursts on spread targets.", mechanicIds: ["sidewinder-rush", "crushing-tide", "meteor-spread"], maxHp: 475, archetype: "crab",
},
"ashwing-demon": {
name: "Ashwing", title: "The Cinder Choir", icon: "♠", accent: "#df665d",
@@ -132,8 +132,8 @@ const BOSS_SEEDS: Record<BossId, BossSeed> = {
summary: "Ricochets across the arena and leaves fire at every landing.", mechanicIds: ["ricochet-rush", "meteor-slam", "ember-brand"], maxHp: 465, archetype: "ricochet",
},
"mirelord-frog": {
name: "Mirelord", title: "The Drowned Bell", icon: "●", accent: "#73c96b",
summary: "Dives below the mire before erupting through timed bog zones.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
name: "Bogbell Myconid", title: "The Drowned Bell", icon: "●", accent: "#8fdc69",
summary: "Roots below the mire before erupting through timed spore blooms.", mechanicIds: ["burrow-rush", "hourglass-eruption", "hollow-collapse"], maxHp: 490, archetype: "burrower",
},
"stonebreaker-giant": {
name: "Stonebreaker", title: "The Walking Crag", icon: "▰", accent: "#c89563",
+8
View File
@@ -0,0 +1,8 @@
export const BOSS_DEATH_HOLD_SECONDS = 2.5;
export const BOSS_DEATH_FADE_SECONDS = 0.75;
export const BOSS_DEATH_DESPAWN_SECONDS = BOSS_DEATH_HOLD_SECONDS + BOSS_DEATH_FADE_SECONDS;
export function bossDeathOpacity(elapsedSeconds: number) {
if (elapsedSeconds <= BOSS_DEATH_HOLD_SECONDS) return 1;
return Math.max(0, 1 - (elapsedSeconds - BOSS_DEATH_HOLD_SECONDS) / BOSS_DEATH_FADE_SECONDS);
}
+7 -4
View File
@@ -9,7 +9,7 @@ import {
} from "./bosses/mechanicPool";
import { createBaseMotion } from "./bosses/shared";
import type { BossMechanicContext, BossMechanicResult } from "./bosses/types";
import type { BossId, BossMotionState, BossState, MemberId, WorldPosition } from "./types";
import type { BossId, BossMotionState, BossState, Debuff, MemberId, WorldPosition } from "./types";
export { BOSS_MECHANIC_REGISTRY, BULL_CHARGE, BULL_POUNCE };
@@ -38,14 +38,17 @@ export function advanceBossMechanics(context: BossMechanicContext): BossMechanic
}
export function handleBossDispel(
_bossId: BossId,
bossId: BossId,
motion: BossMotionState,
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
debuffs: readonly Debuff[],
) {
return handleMechanicDispel(motion, memberId, position, time, debuffNames);
if (!BOSS_DEFINITIONS[bossId].mechanicIds.includes("venom-purge")) {
return { motion, message: "Harmful magic removed." };
}
return handleMechanicDispel(motion, memberId, position, time, debuffs);
}
export function upcomingMechanic(boss: BossState, motion: BossMotionState, time: number) {
+2 -2
View File
@@ -73,8 +73,8 @@ export const BOSS_ROOMS = {
"stormwool-alpaca": room("thunder-fleece", "The Thunder Fleece", "Wind-scoured highland", "storm", {
background: "#071321", fog: "#274d71", sky: "#a4d5ff", ground: "#071019", floorColor: "#233f5d", wallColor: "#365676", accent: "#9bd3ff", accentSecondary: "#eef9ff", wallHeight: 2.8,
}),
"cluckhorn-colossus": room("roostbreaker-yard", "The Roostbreaker Yard", "Ruinous farmstead", "wilds", {
background: "#171308", fog: "#49542a", sky: "#d8c675", ground: "#0b1207", floorColor: "#37421e", wallColor: "#554626", accent: "#f1b95c", accentSecondary: "#b8d065", wallHeight: 2.6,
"cluckhorn-colossus": room("furnace-nest", "The Furnace Nest", "Overgrown brass hatchery", "junkyard", {
background: "#081615", fog: "#284c45", sky: "#6edccb", ground: "#07100d", floorColor: "#263b32", wallColor: "#5a4123", accent: "#dfaa43", accentSecondary: "#62e3d1", wallHeight: 3.2,
}),
"ashwing-demon": room("cinder-choir", "The Cinder Choir", "Ashen cathedral", "cinder", {
background: "#1b0608", fog: "#511721", sky: "#ee7566", ground: "#120407", floorColor: "#4b1720", wallColor: "#592029", accent: "#ef625c", accentSecondary: "#ffb16e", wallHeight: 5.2,
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { canAddBossToEncounter, normalizeEncounterBossIds } from "./bossSelection";
describe("boss encounter selection", () => {
it("allows only one Memory Sequence boss in an encounter", () => {
expect(canAddBossToEncounter(["sandglass-scorpion"], "crystal-bat-matriarch")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "rimeclaw-yeti")).toBe(false);
expect(canAddBossToEncounter(["sandglass-scorpion"], "bulldrome")).toBe(true);
const normalized = normalizeEncounterBossIds([
"sandglass-scorpion",
"crystal-bat-matriarch",
"bulldrome",
"rimeclaw-yeti",
]);
expect(normalized).toEqual(["sandglass-scorpion", "bulldrome"]);
expect(normalized.filter((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes("memory-sequence"))).toHaveLength(1);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { BOSS_DEFINITIONS } from "./bossCatalog";
import type { BossId, BossMechanicId } from "./types";
/** Mechanics that cannot be resolved safely when two bosses own them at once. */
export const ENCOUNTER_EXCLUSIVE_MECHANICS: ReadonlySet<BossMechanicId> = new Set(["memory-sequence"]);
export function canAddBossToEncounter(selectedBossIds: readonly BossId[], candidateBossId: BossId) {
if (selectedBossIds.includes(candidateBossId)) return false;
const candidateMechanics = BOSS_DEFINITIONS[candidateBossId].mechanicIds;
for (const mechanicId of candidateMechanics) {
if (!ENCOUNTER_EXCLUSIVE_MECHANICS.has(mechanicId)) continue;
if (selectedBossIds.some((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes(mechanicId))) return false;
}
return true;
}
export function normalizeEncounterBossIds(requestedBossIds: readonly BossId[], limit = 3): BossId[] {
const selected: BossId[] = [];
for (const bossId of requestedBossIds) {
if (selected.length >= limit) break;
if (canAddBossToEncounter(selected, bossId)) selected.push(bossId);
}
return selected.length ? selected : ["bulldrome"];
}
+8 -5
View File
@@ -19,6 +19,8 @@ export const BULL_URL = new URL("../assets/game/models/claudecraft/creatures/bul
const SANDGLASS_URL = new URL("../assets/game/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
const CRYSTAL_BAT_MATRIARCH_URL = new URL("../assets/game/models/original/bosses/crystal-bat-matriarch/crystal-bat-matriarch.glb", import.meta.url).href;
const BRASSBEAK_BASILISK_URL = new URL("../assets/game/models/original/bosses/brassbeak-basilisk/brassbeak-basilisk.glb", import.meta.url).href;
const BOGBELL_MYCONID_URL = new URL("../assets/game/models/original/bosses/bogbell-myconid/bogbell-myconid.glb", import.meta.url).href;
const CRAGCLAW_URL = new URL("../assets/game/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
const MOURNVEIL_URL = new URL("../assets/game/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
const CROWNSHARD_URL = new URL("../assets/game/models/claudecraft/creatures/golelingevolved.glb", import.meta.url).href;
@@ -30,14 +32,14 @@ const CLAUDE_BOSS_URLS: Record<Exclude<BossId,
| "mournveil-ghost"
| "crownshard-golem"
| "crystal-bat-matriarch"
| "cluckhorn-colossus"
| "mirelord-frog"
>, string> = {
"stormwool-alpaca": new URL("../assets/game/models/claudecraft/creatures/alpaca.glb", import.meta.url).href,
"cluckhorn-colossus": new URL("../assets/game/models/claudecraft/creatures/chicken_cow.glb", import.meta.url).href,
"ashwing-demon": new URL("../assets/game/models/claudecraft/creatures/demon.glb", import.meta.url).href,
"riftclaw-demon": new URL("../assets/game/models/claudecraft/creatures/demonalt.glb", import.meta.url).href,
"tempestscale-dragon": new URL("../assets/game/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href,
emberfox: new URL("../assets/game/models/claudecraft/creatures/fox.glb", import.meta.url).href,
"mirelord-frog": new URL("../assets/game/models/claudecraft/creatures/frog.glb", import.meta.url).href,
"stonebreaker-giant": new URL("../assets/game/models/claudecraft/creatures/giant.glb", import.meta.url).href,
"glub-sovereign": new URL("../assets/game/models/claudecraft/creatures/glubevolved.glb", import.meta.url).href,
"scrapking-goblin": new URL("../assets/game/models/claudecraft/creatures/goblin.glb", import.meta.url).href,
@@ -61,12 +63,13 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
"crownshard-golem": { url: CROWNSHARD_URL, scale: 1.15, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#e0bd45", rotationOffset: 0, floating: true },
"crystal-bat-matriarch": { url: CRYSTAL_BAT_MATRIARCH_URL, scale: 0.828, idle: "Idle", move: "Swoop", attack: "SonicPulse", special: "MirrorShatter", death: "Death", light: "#8eeaff", rotationOffset: 0, floating: true },
"stormwool-alpaca": { url: CLAUDE_BOSS_URLS["stormwool-alpaca"], scale: 0.72, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#8fc7ff", rotationOffset: 0 },
"cluckhorn-colossus": { url: CLAUDE_BOSS_URLS["cluckhorn-colossus"], scale: 2.2, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#f0b85d", rotationOffset: 0 },
// IDs remain stable so existing saves and trophies keep working after visual replacement.
"cluckhorn-colossus": { url: BRASSBEAK_BASILISK_URL, scale: 0.75, idle: "Idle", move: "Scuttle", attack: "BeakRend", special: "FurnaceBurst", death: "Death", light: "#5cebd7", rotationOffset: 0 },
"ashwing-demon": { url: CLAUDE_BOSS_URLS["ashwing-demon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#df665d", rotationOffset: 0, floating: true },
"riftclaw-demon": { url: CLAUDE_BOSS_URLS["riftclaw-demon"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#d45cff", rotationOffset: 0 },
"tempestscale-dragon": { url: CLAUDE_BOSS_URLS["tempestscale-dragon"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#5fc8e8", rotationOffset: 0, floating: true },
emberfox: { url: CLAUDE_BOSS_URLS.emberfox, scale: 1, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#ff7b45", rotationOffset: 0 },
"mirelord-frog": { url: CLAUDE_BOSS_URLS["mirelord-frog"], scale: 1.4, idle: "Idle", move: "Run", attack: "Punch", special: "Jump", death: "Death", light: "#73c96b", rotationOffset: 0 },
"mirelord-frog": { url: BOGBELL_MYCONID_URL, scale: 0.78, idle: "Idle", move: "BurrowRush", attack: "RootPummel", special: "SporeEruption", death: "Death", light: "#8df06b", rotationOffset: 0 },
"stonebreaker-giant": { url: CLAUDE_BOSS_URLS["stonebreaker-giant"], scale: 1, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#c89563", rotationOffset: 0 },
"glub-sovereign": { url: CLAUDE_BOSS_URLS["glub-sovereign"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#6ce0b8", rotationOffset: 0, floating: true },
"scrapking-goblin": { url: CLAUDE_BOSS_URLS["scrapking-goblin"], scale: 1.5, idle: "Idle", move: "Run", attack: "Attack", special: "Jump", death: "Death", light: "#d7a34b", rotationOffset: 0 },
@@ -77,7 +80,7 @@ export const ALTERNATE_BOSS_CONFIG: Record<AlternateBossKind, AlternateBossConfi
"thorncrown-stag": { url: CLAUDE_BOSS_URLS["thorncrown-stag"], scale: 0.85, idle: "Idle", move: "Gallop", attack: "Attack_Headbutt", special: "Attack_Kick", death: "Death", light: "#7fc46b", rotationOffset: 0 },
"sky-totem": { url: CLAUDE_BOSS_URLS["sky-totem"], scale: 1.2, idle: "Flying_Idle", move: "Fast_Flying", attack: "Punch", special: "Headbutt", death: "Death", light: "#69d4d1", rotationOffset: 0, floating: true },
"razorcrest-raptor": { url: CLAUDE_BOSS_URLS["razorcrest-raptor"], scale: 1.1, idle: "Velociraptor_Idle", move: "Velociraptor_Run", attack: "Velociraptor_Attack", special: "Velociraptor_Jump", death: "Velociraptor_Death", light: "#d9c45a", rotationOffset: 0 },
"bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Death_AnimalArmature", light: "#d47b45", rotationOffset: 0 },
"bristlequake-boar": { url: CLAUDE_BOSS_URLS["bristlequake-boar"], scale: 0.475, idle: "Idle_AnimalArmature", move: "Gallop_AnimalArmature", attack: "Attack_Headbutt_AnimalArmature", special: "Attack_Kick_AnimalArmature", death: "Dying", light: "#d47b45", rotationOffset: 0 },
"moonfang-wolf": { url: CLAUDE_BOSS_URLS["moonfang-wolf"], scale: 1.05, idle: "Idle", move: "Gallop", attack: "Attack", special: "Gallop_Jump", death: "Death", light: "#9db9e5", rotationOffset: 0 },
"frostmaw-yeti": { url: CLAUDE_BOSS_URLS["frostmaw-yeti"], scale: 1.35, idle: "Idle", move: "Walk", attack: "Bite_Front", special: "Jump", death: "Death", light: "#8ed8ef", rotationOffset: 0 },
"rimeclaw-yeti": { url: CLAUDE_BOSS_URLS["rimeclaw-yeti"], scale: 1.35, idle: "Idle", move: "Run", attack: "Punch", special: "Weapon", death: "Death", light: "#75bfe8", rotationOffset: 0 },
+18
View File
@@ -141,6 +141,24 @@ describe("shared boss mechanic pool", () => {
expect(bossAnimationCue(started.motion)).toBe("attack");
});
it("moves a web caster sideways during Binding Web and animates its return home", () => {
const source = context(0);
source.motion.nextMechanicAt = 0;
const started = advanceMechanicLoadout(source, ["binding-web", "venom-purge"]);
expect(bossAnimationCue(started.motion)).toBe("move");
const next = context(0.1);
next.boss = started.boss;
next.motion = started.motion;
next.party = started.party;
const advanced = advanceMechanicLoadout(next, ["binding-web", "venom-purge"]);
expect(advanced.motion.position).not.toEqual(started.motion.position);
advanced.motion.activeMechanicId = null;
advanced.motion.mode = "holding";
expect(bossAnimationCue(advanced.motion)).toBe("move");
});
it("splits soak damage across allies inside its indicator", () => {
const source = context(0);
const activatesAt = 1.5;
+29 -12
View File
@@ -1,6 +1,6 @@
import { ARENA_CENTER, ARENA_RADIUS, clampToArena } from "../arena";
import { angleTo, distance, moveToward, pointToSegmentDistance } from "../geometry";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import type { BossAnimationCue, BossMechanicId, BossMotionState, CircleHazard, Debuff, MemberId, MemorySymbolId, PartyMember, PoolTelegraph, SlashLane, WorldPosition } from "../types";
import { applyMelee, chooseLivingTarget, cloneMotion, createCircleHazard, memberName, resolveCircleHazards, returnBossToArenaCenter } from "./shared";
import type { BossMechanicContext, BossMechanicResult, UpcomingMechanic } from "./types";
@@ -11,7 +11,7 @@ export const BOSS_MECHANIC_POOL = [
{ id: "cinder-nova", name: "Cinder Nova", instruction: "Heal the party through raidwide damage." },
{ id: "ember-brand", name: "Ember Brand", instruction: "Purify the marked ally." },
{ id: "binding-web", name: "Binding Web", instruction: "Separate the linked allies." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move away before cleansing Widow Venom." },
{ id: "venom-purge", name: "Venom Purge", instruction: "Move apart, then cleanse Widow Venom before it expires." },
{ id: "storm-breath", name: "Storm Breath", instruction: "Rotate behind the sweeping cone." },
{ id: "stormfall", name: "Stormfall", instruction: "Spread before the marked impacts." },
{ id: "elemental-beam", name: "Elemental Beam", instruction: "Clear the glowing lane." },
@@ -110,10 +110,13 @@ export const VENOM_PURGE = {
tickDamage: 5,
castDuration: 2.5,
poolRadius: 2,
poolDuration: 7,
poolDamage: 14,
poolArmDelay: 1.25,
poolDuration: 6,
poolDamage: 8,
} as const;
const BINDING_WEB_SCUTTLE_SPEED = 3.2;
export const MEMORY_SEQUENCE = {
sequenceLength: 4,
flashDuration: 0.9,
@@ -942,11 +945,22 @@ const bindingWeb: BossMechanicDefinition = {
runtime.motion.mode = "tethering";
runtime.motion.tetherIds = [first, second];
runtime.motion.tetherBreakDistance = 6.8;
runtime.motion.chargeStart = [...runtime.motion.position];
const scuttleDirection = runtime.motion.mechanicCount % 2 === 0 ? -1 : 1;
runtime.motion.chargeEnd = clampToArena([
ARENA_CENTER[0] + runtime.motion.formationOffsetX + scuttleDirection * 4.2,
ARENA_CENTER[1] - 1.25,
]);
runtime.motion.phaseStartedAt = runtime.context.time;
runtime.motion.phaseEndsAt = runtime.context.time + 4.5;
runtime.events.push({ at: runtime.context.time, message: `Binding Web links ${memberName(runtime.party, first)} and ${memberName(runtime.party, second)}. Spread apart.`, tone: "danger", pulseKind: "tether", targetId: first });
},
advance(runtime) {
runtime.motion.position = moveToward(
runtime.motion.position,
runtime.motion.chargeEnd,
BINDING_WEB_SCUTTLE_SPEED * runtime.context.delta,
);
const [first, second] = runtime.motion.tetherIds;
if (!first || !second || distance(runtime.context.partyPositions[first], runtime.context.partyPositions[second]) >= runtime.motion.tetherBreakDistance) {
runtime.events.push({ at: runtime.context.time, message: "Binding Web snaps. Formation is free.", pulseKind: "tether" });
@@ -960,19 +974,19 @@ const bindingWeb: BossMechanicDefinition = {
runtime.events.push({ at: runtime.context.time, message: "Binding Web constricts and roots its targets.", tone: "danger", pulseKind: "tether" });
finishMechanic(runtime, 4);
},
animationCue: () => "attack",
animationCue: (motion) => distance(motion.position, motion.chargeEnd) > 0.08 ? "move" : "attack",
};
bindingWeb.upcoming = (motion, time) => defaultUpcoming(bindingWeb, motion, time);
const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
const targets = [0, 1].map((offset) => chooseLivingTarget(runtime.party, BULL_TARGETS, runtime.motion.mechanicCount + offset));
runtime.motion.mode = "venom_cast";
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.motion.phaseEndsAt = runtime.context.time + VENOM_PURGE.castDuration;
runtime.party = runtime.party.map((member) => targets.includes(member.id) ? {
...member,
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage }],
debuffs: [...member.debuffs, { id: `widow-venom-${runtime.motion.bossId}-${runtime.motion.mechanicCount}-${member.id}`, name: "Widow Venom", expiresAt: runtime.context.time + VENOM_PURGE.duration, nextTickAt: runtime.context.time + 1, tickDamage: VENOM_PURGE.tickDamage, sourceBossId: runtime.motion.bossId }],
} : member);
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move away before cleansing.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
runtime.events.push({ at: runtime.context.time, message: "Venom Purge applies Widow Venom. Move apart, then cleanse.", tone: "danger", pulseKind: "venom", targetId: targets[0] });
});
const stormBreath: BossMechanicDefinition = {
@@ -1264,12 +1278,15 @@ export function upcomingLoadoutMechanic(
}
export function bossAnimationCue(motion: BossMotionState): BossAnimationCue {
return motion.activeMechanicId ? BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion) : "idle";
if (motion.activeMechanicId) return BOSS_MECHANIC_REGISTRY[motion.activeMechanicId].animationCue(motion);
const homeDx = motion.position[0] - (ARENA_CENTER[0] + motion.formationOffsetX);
const homeDz = motion.position[1] - ARENA_CENTER[1];
return Math.hypot(homeDx, homeDz) > 0.08 ? "move" : "idle";
}
export function dropVenomPool(motion: BossMotionState, memberId: MemberId, center: WorldPosition, time: number) {
const next = cloneMotion(motion);
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + 0.25, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
next.hazards.push(createCircleHazard({ id: `venom-pool-${memberId}-${time.toFixed(2)}`, kind: "venom_pool", center, radius: VENOM_PURGE.poolRadius, activatesAt: time + VENOM_PURGE.poolArmDelay, duration: VENOM_PURGE.poolDuration, damage: VENOM_PURGE.poolDamage, tickInterval: 1 }));
return next;
}
@@ -1278,9 +1295,9 @@ export function handleMechanicDispel(
memberId: MemberId,
position: WorldPosition,
time: number,
debuffNames: readonly string[],
debuffs: readonly Debuff[],
) {
if (debuffNames.includes("Widow Venom")) {
if (debuffs.some((debuff) => debuff.name === "Widow Venom" && debuff.sourceBossId === motion.bossId)) {
return {
motion: dropVenomPool(motion, memberId, position, time),
message: "Widow Venom purged. A venom pool forms where the target stood.",
+5
View File
@@ -0,0 +1,5 @@
import type { PartyMember } from "./types";
export function isPartyWiped(party: readonly PartyMember[]) {
return party.length > 0 && party.every((member) => member.hp <= 0);
}
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "./bossCatalog";
import {
RUN_BUFF_ORDER,
RUN_BUFFS,
@@ -97,6 +97,17 @@ describe("roguelike progression", () => {
expect(trio.every((bossId) => !seen.includes(bossId as typeof seen[number]))).toBe(true);
});
it("never selects two Memory Sequence bosses in one pair or trio", () => {
const pair = selectRandomBossPair([], () => 0.04);
const trio = selectUnseenBosses(3, [], () => 0);
const memoryBossCount = (bossIds: readonly (typeof AVAILABLE_BOSS_IDS)[number][]) => bossIds
.filter((bossId) => BOSS_DEFINITIONS[bossId].mechanicIds.includes("memory-sequence"))
.length;
expect(memoryBossCount(pair)).toBeLessThanOrEqual(1);
expect(memoryBossCount(trio)).toBeLessThanOrEqual(1);
});
it("fails instead of silently reusing seen bosses when unseen pool is too small", () => {
expect(() => selectUnseenBosses(3, AVAILABLE_BOSS_IDS.slice(0, -2))).toThrow(/Cannot select 3 unseen bosses/);
});
+14 -5
View File
@@ -1,4 +1,5 @@
import { AVAILABLE_BOSS_IDS } from "./bossCatalog";
import { canAddBossToEncounter } from "./bossSelection";
import type { AbilityId, BossId, RunBuffId, RunBuffRanks } from "./types";
export type RunBuffEffectKind =
@@ -210,6 +211,7 @@ export function selectUnseenBosses(
count: number,
seenBossIds: readonly BossId[] = [],
random: () => number = Math.random,
selectedBossIds: readonly BossId[] = [],
): BossId[] {
const seen = new Set(seenBossIds);
const pool = AVAILABLE_BOSS_IDS.filter((bossId) => !seen.has(bossId));
@@ -219,11 +221,16 @@ export function selectUnseenBosses(
}
const bosses: BossId[] = [];
while (bosses.length < requestedCount) {
const compatiblePool = pool.filter((bossId) => canAddBossToEncounter([...selectedBossIds, ...bosses], bossId));
if (!compatiblePool.length) {
throw new Error(`Cannot select ${requestedCount} unseen bosses without duplicating an encounter-exclusive mechanic.`);
}
const sample = random();
const randomValue = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999, sample)) : 0;
const index = Math.floor(randomValue * pool.length);
bosses.push(pool[index]);
pool.splice(index, 1);
const index = Math.floor(randomValue * compatiblePool.length);
const selected = compatiblePool[index];
bosses.push(selected);
pool.splice(pool.indexOf(selected), 1);
}
return bosses;
}
@@ -245,6 +252,8 @@ export function selectRandomBossPair(
const eligibleBosses = AVAILABLE_BOSS_IDS.filter((bossId) => !excluded.has(bossId));
const pool = eligibleBosses.length >= 2 ? eligibleBosses : AVAILABLE_BOSS_IDS;
const firstIndex = Math.floor(random() * pool.length) % pool.length;
const secondOffset = 1 + (Math.floor(random() * (pool.length - 1)) % (pool.length - 1));
return [pool[firstIndex], pool[(firstIndex + secondOffset) % pool.length]];
const first = pool[firstIndex];
const compatiblePool = pool.filter((bossId) => canAddBossToEncounter([first], bossId));
const secondIndex = Math.floor(random() * compatiblePool.length) % compatiblePool.length;
return [first, compatiblePool[secondIndex]];
}
+114 -3
View File
@@ -6,6 +6,7 @@ import { createClassInventory, HEALER_CLASSES } from "./healers";
import { dropVenomPool, VENOM_PURGE } from "./bosses/mechanicPool";
import { ARENA_CENTER, isInsideArena } from "./arena";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { RUN_BUFF_ORDER, RUN_BUFFS, compileRunModifiers } from "./roguelike";
import type { RunBuffRanks } from "./types";
@@ -192,6 +193,26 @@ describe("Disc Priest combat simulation", () => {
expect(paused.castAbility("renew")).toBe(false);
});
it("cancels an active cast and blocks new abilities when the healer falls", () => {
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
party: state.party.map((member) => member.id === "nia" ? { ...member, hp: 40 } : member),
}));
useGameStore.getState().selectMember("nia");
expect(useGameStore.getState().castAbility("mend")).toBe(true);
useGameStore.setState((state) => ({
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.6);
const state = useGameStore.getState();
expect(state.phase).toBe("combat");
expect(state.activeCast).toBeNull();
expect(state.party.find((member) => member.id === "nia")?.hp).toBe(40);
expect(state.castAbility("renew")).toBe(false);
});
it("does not publish unchanged player positions while idle", () => {
let updates = 0;
const unsubscribe = useGameStore.subscribe(() => { updates += 1; });
@@ -311,6 +332,64 @@ describe("Disc Priest combat simulation", () => {
});
});
describe("shared party wipe rules", () => {
it.each(["encounter", "roguelike", "rogue-trials"] as const)(
"keeps %s combat running after individual party deaths",
(runMode) => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
runMode,
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
bossMotion: { ...state.bossMotion, nextMechanicAt: 999 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
boss: { ...entry.boss, hp: 1_000_000, maxHp: 1_000_000, nextMeleeAt: 999 },
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
party: state.party.map((member) => member.id === "aelia" || member.id === "brann" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
},
);
it.each([
["encounter", "victory"],
["roguelike", "intermission"],
["rogue-trials", "intermission"],
] as const)("awards %s boss completion when both sides fall on the same tick", (runMode, expectedPhase) => {
useGameStore.getState().configureHealer(
"priest",
"Aelia",
createClassInventory("priest"),
["bulldrome", "broodfang-spider"],
runMode,
);
useGameStore.getState().startEncounter();
useGameStore.setState((state) => ({
boss: { ...state.boss, hp: 0 },
additionalBosses: state.additionalBosses.map((entry) => ({ ...entry, boss: { ...entry.boss, hp: 0 } })),
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe(expectedPhase);
});
});
describe("Broodfang encounter", () => {
beforeEach(() => {
useGameStore.getState().configureHealer("priest", "Aelia", createClassInventory("priest"), "broodfang-spider");
@@ -351,7 +430,7 @@ describe("Broodfang encounter", () => {
expect(state.bossMotion.hazards[0]).toMatchObject({ kind: "venom_pool", center: state.partyPositions[poisoned.id] });
});
it("repeatedly damages the player while they remain in a venom pool", () => {
it("arms a lower-damage venom pool after giving the party time to move", () => {
useGameStore.getState().setPlayerPosition([0, 0]);
useGameStore.setState((state) => ({
boss: { ...state.boss, nextMeleeAt: 999 },
@@ -362,7 +441,10 @@ describe("Broodfang encounter", () => {
}));
const startingHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(0.3);
useGameStore.getState().tick(VENOM_PURGE.poolArmDelay - 0.05);
expect(useGameStore.getState().party[0].hp).toBe(startingHp);
useGameStore.getState().tick(0.1);
const firstTickHp = useGameStore.getState().party[0].hp;
useGameStore.getState().tick(1);
const secondTickHp = useGameStore.getState().party[0].hp;
@@ -427,6 +509,23 @@ describe("PVE dual-boss encounter", () => {
useGameStore.getState().tick(1);
expect(useGameStore.getState().phase).toBe("victory");
});
it("drops a dispelled venom pool only for the spider that applied the debuff", () => {
useGameStore.setState((state) => ({
bossMotion: { ...state.bossMotion, nextMechanicAt: state.time, mechanicCount: 1 },
additionalBosses: state.additionalBosses.map((entry) => ({
...entry,
motion: { ...entry.motion, nextMechanicAt: 999 },
})),
}));
useGameStore.getState().tick(0.05);
const poisoned = useGameStore.getState().party.find((member) => member.debuffs.some((debuff) => debuff.name === "Widow Venom"))!;
useGameStore.getState().selectMember(poisoned.id);
expect(useGameStore.getState().castAbility("purify")).toBe(true);
expect(useGameStore.getState().bossMotion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(1);
expect(useGameStore.getState().additionalBosses[0].motion.hazards.filter((hazard) => hazard.kind === "venom_pool")).toHaveLength(0);
});
});
describe("Roguelike ability buffs", () => {
@@ -742,6 +841,11 @@ describe("Rogue Trials", () => {
const defeatedInstanceId = defeated.bossInstanceId;
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
const despawnAt = useGameStore.getState().boss.defeatedAt! + BOSS_DEATH_DESPAWN_SECONDS;
while (useGameStore.getState().time + 0.11 < despawnAt) useGameStore.getState().tick(0.1);
expect(useGameStore.getState().bossInstanceId).toBe(defeatedInstanceId);
useGameStore.getState().tick(0.12);
const replaced = useGameStore.getState();
expect(replaced.phase).toBe("combat");
expect(replaced.boss.hp).toBe(replaced.boss.maxHp);
@@ -750,7 +854,7 @@ describe("Rogue Trials", () => {
expect(new Set([replaced.boss.id, ...replaced.additionalBosses.map((entry) => entry.boss.id)])).toHaveLength(3);
});
it("ends endless mode when the party falls without losing its kill count", () => {
it("keeps endless mode running until the whole party falls", () => {
useGameStore.setState((state) => ({
round: 5,
phase: "victory",
@@ -763,6 +867,13 @@ describe("Rogue Trials", () => {
party: state.party.map((member) => member.id === "aelia" ? { ...member, hp: 0 } : member),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("combat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
useGameStore.setState((state) => ({
party: state.party.map((member) => ({ ...member, hp: 0 })),
}));
useGameStore.getState().tick(0.05);
expect(useGameStore.getState().phase).toBe("defeat");
expect(useGameStore.getState().endlessBossKills).toBe(7);
+24 -11
View File
@@ -7,6 +7,8 @@ import {
upcomingMechanic,
} from "./bossMechanics";
import { BOSS_DEFINITIONS } from "./bossCatalog";
import { BOSS_DEATH_DESPAWN_SECONDS } from "./bossDeath";
import { normalizeEncounterBossIds } from "./bossSelection";
import { clampToArena, constrainBossMotion } from "./arena";
import { cloneMotion } from "./bosses/shared";
import { freshParty } from "./data";
@@ -14,6 +16,7 @@ import { distance } from "./geometry";
import { createClassInventory, HEALER_CLASSES } from "./healers";
import { combatFormation, updatePartyPositions } from "./partyBehaviors";
import { advancePartyCombat, createPartyCombatState, tankAuraProtects, type PartyCombatState, type PartyDamageEvent } from "./partyCombat";
import { isPartyWiped } from "./partyState";
import {
RUN_BUFFS,
bossHealthMultiplier,
@@ -150,8 +153,7 @@ export const BARRIER_DAMAGE_REDUCTION = 0.3;
const normalizeBossIds = (bossIds: BossId | readonly BossId[] = "bulldrome"): BossId[] => {
const requested = typeof bossIds === "string" ? [bossIds] : [...bossIds];
const unique = requested.filter((bossId, index) => requested.indexOf(bossId) === index).slice(0, 3);
return unique.length ? unique : ["bulldrome"];
return normalizeEncounterBossIds(requested);
};
function createEncounterMotion(bossId: BossId, index: number, count: number): BossMotionState {
@@ -515,6 +517,8 @@ export const useGameStore = create<GameState>((set, get) => ({
if (state.phase !== "combat") return false;
if (state.paused) return false;
if (state.activeCast) return false;
const healer = state.party.find((member) => member.id === "aelia");
if (!healer || healer.hp <= 0) return false;
const ability = HEALER_CLASSES[state.healerClassId].abilities[abilityId];
const manaCost = runAbilityManaCost(abilityId, ability.mana, state.runModifiers);
@@ -578,11 +582,11 @@ export const useGameStore = create<GameState>((set, get) => ({
const primaryNames = party[selectedIndex].debuffs.map((debuff) => debuff.name);
for (const index of cleanseIndexes) {
const target = party[index];
const dispelledNames = target.debuffs.map((debuff) => debuff.name);
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
const dispelledDebuffs = target.debuffs;
const primaryDispel = handleBossDispel(state.boss.id, bossMotion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
bossMotion = primaryDispel.motion;
additionalBosses = additionalBosses.map((entry) => {
const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledNames);
const dispel = handleBossDispel(entry.boss.id, entry.motion, target.id, state.partyPositions[target.id], state.time, dispelledDebuffs);
return { ...entry, motion: dispel.motion };
});
party[index] = { ...party[index], debuffs: [] };
@@ -671,6 +675,8 @@ export const useGameStore = create<GameState>((set, get) => ({
let endlessBossKills = state.endlessBossKills;
let endlessSpawnSequence = state.endlessSpawnSequence;
if (!party.some((member) => member.id === "aelia" && member.hp > 0)) activeCast = null;
if (state.endlessMode) {
const slots: AdditionalBossState[] = [
{ instanceId: bossInstanceId, boss, motion: bossMotion },
@@ -678,10 +684,13 @@ export const useGameStore = create<GameState>((set, get) => ({
];
for (let index = 0; index < slots.length; index += 1) {
if (slots[index].boss.hp > 0) continue;
const defeatedAt = slots[index].boss.defeatedAt ?? oldTime;
slots[index].boss.defeatedAt = defeatedAt;
if (time < defeatedAt + BOSS_DEATH_DESPAWN_SECONDS) continue;
const activeBossIds = slots
.filter((entry, slotIndex) => slotIndex !== index && entry.boss.hp > 0)
.map((entry) => entry.boss.id);
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds])[0];
const replacementId = selectUnseenBosses(1, [slots[index].boss.id, ...activeBossIds], Math.random, activeBossIds)[0];
endlessSpawnSequence += 1;
const difficulty = DIFFICULTY_BY_SLUG[state.difficultySlug];
const replacement = createEncounterBoss(
@@ -816,12 +825,16 @@ export const useGameStore = create<GameState>((set, get) => ({
const target = encounterBosses.find((entry) => entry.instanceId === event.targetInstanceId);
if (target) target.boss.hp = Math.max(0, target.boss.hp - event.amount);
}
for (const entry of encounterBosses) {
if (entry.boss.hp <= 0 && entry.boss.defeatedAt === undefined) entry.boss.defeatedAt = time;
}
boss = encounterBosses[0].boss;
bossInstanceId = encounterBosses[0].instanceId;
bossMotion = encounterBosses[0].motion;
additionalBosses = encounterBosses.slice(1);
const tank = party.find((member) => member.id === "brann")!;
const healer = party.find((member) => member.id === "aelia")!;
if (healer.hp <= 0) activeCast = null;
const partyWiped = isPartyWiped(party);
let phase: GamePhase = state.phase;
let runBuffInputUnlockAt = state.runBuffInputUnlockAt;
let newlyDefeatedBossCount = 0;
@@ -837,9 +850,9 @@ export const useGameStore = create<GameState>((set, get) => ({
}
}
endlessBossKills += newlyDefeatedBossCount;
if (state.endlessMode && (tank.hp <= 0 || healer.hp <= 0)) {
if (state.endlessMode && partyWiped) {
phase = "defeat";
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the formation fell.`, "danger");
combatLog = addLog(combatLog, time, `${endlessBossKills} endless bosses defeated before the party fell.`, "danger");
} else if (state.endlessMode) {
phase = "combat";
} else if (encounterBosses.every((entry) => entry.boss.hp <= 0)) {
@@ -847,9 +860,9 @@ export const useGameStore = create<GameState>((set, get) => ({
phase = state.runMode !== "encounter" && !rogueTrialsComplete ? "intermission" : "victory";
if (phase === "intermission") runBuffInputUnlockAt = Date.now() + RUN_BUFF_INPUT_LOCK_MS;
combatLog = addLog(combatLog, time, `${encounterBosses.map((entry) => entry.boss.name).join(" and ")} fall. Party survives.`, "good");
} else if (tank.hp <= 0 || healer.hp <= 0) {
} else if (partyWiped) {
phase = "defeat";
combatLog = addLog(combatLog, time, tank.hp <= 0 ? `Brann falls. ${boss.name} breaks formation.` : `${healer.name} falls. Healing ends.`, "danger");
combatLog = addLog(combatLog, time, `The party falls. ${boss.name} claims the vault.`, "danger");
}
set({
+3
View File
@@ -198,6 +198,7 @@ export interface Debuff {
expiresAt: number;
nextTickAt: number;
tickDamage: number;
sourceBossId?: BossId;
}
export interface PartyMember {
@@ -221,6 +222,8 @@ export interface BossState {
maxHp: number;
hp: number;
nextMeleeAt: number;
/** Simulation time when health first reached zero. Used for delayed endless replacement. */
defeatedAt?: number;
}
export interface BossMotionState {
+94 -32
View File
@@ -20,6 +20,7 @@
/* Android lays out at logical CSS size, not AMOLED framebuffer resolution. */
--thor-main-css-width: 960px;
--thor-secondary-width-ratio: 64.583333%;
--thor-top-bottom-overscan: 8px;
}
* {
@@ -1362,6 +1363,13 @@ button:focus-visible {
background: #030706;
}
/* Thor top panel hides a thin lower edge in immersive mode. Keep content and
render surface above that measured strip, plus any Android-reported inset. */
.native-platform .surface-slot.top-slot,
.native-platform[data-display-surface="top"] .dedicated-display-surface {
padding-bottom: max(var(--thor-top-bottom-overscan), env(safe-area-inset-bottom, 0px));
}
.native-platform .surface-slot.is-active {
display: grid;
}
@@ -1668,32 +1676,36 @@ button:focus-visible {
.login-panel .front-secondary { min-height: 40px; padding-top: 6px; padding-bottom: 6px; }
.login-surface > .front-notice { position: absolute; right: 44px; bottom: 83px; width: 300px; }
.login-surface > .controller-legend { position: absolute; right: 44px; bottom: 47px; }
.login-context { padding: 0; }
.login-context > .front-brand { margin: 22px 5.5% 0; }
.offline-promise { margin: 32px 7% 0; }
.context-kicker { color: var(--gold); font-size: 9px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; }
.offline-promise ol { display: grid; gap: 13px; margin: 14px 0 0; padding: 0; list-style: none; }
.offline-promise li { display: grid; grid-template-columns: 35px 1fr; align-items: center; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.offline-promise li > b { color: #536a61; font-family: "Cinzel", serif; font-size: 17px; }
.offline-promise li > span { display: grid; }
.offline-promise li strong { font-size: clamp(11px, 2.35cqw, 14px); letter-spacing: 0.03em; }
.offline-promise li small { color: #748a81; font-size: clamp(8px, 1.7cqw, 10px); }
.device-route { position: absolute; right: 7%; bottom: 25px; left: 7%; display: flex; align-items: center; justify-content: center; gap: 13px; color: #70857c; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; }
.device-route i { color: var(--gold); font-style: normal; }
.device-route b { padding: 7px 10px; border: 1px solid #486159; color: #b8c9c2; background: #0b1b17; font-size: 8px; }
.login-save-context { padding: 0 5.5% 18px; }
.login-save-context .context-header { margin: 0 -5.8%; }
.login-save-list { display: grid; gap: 9px; margin-top: 16px; }
.login-save-list article { min-height: 105px; display: grid; grid-template-columns: 30px 52px minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 11px 13px; border: 1px solid rgba(150,190,175,.18); border-left: 2px solid rgba(232,200,114,.52); background: linear-gradient(100deg, rgba(23,48,40,.66), rgba(7,18,15,.78)); }
.login-save-list article.is-empty { grid-template-columns: 30px minmax(0, 1fr); border-left-color: #425850; background: rgba(6,16,13,.62); }
.login-save-list article > b { color: #61776e; font-family: "Cinzel", serif; font-size: 15px; font-weight: 500; }
.login-save-avatar { width: 48px; height: 48px; display: grid; place-items: center; border: 1px solid rgba(232,200,114,.48); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 28%, #2c493f, #0b1a17); font: 20px "Cinzel", serif; }
.login-save-list article > span { min-width: 0; display: grid; }
.login-save-list article small { color: #6d8279; font-size: clamp(7px, 1.45cqw, 9px); font-style: normal; letter-spacing: .08em; text-transform: uppercase; }
.login-save-list article span > strong { overflow: hidden; font: 500 clamp(13px, 2.8cqw, 17px) "Cinzel", serif; text-overflow: ellipsis; white-space: nowrap; }
.login-save-list article em { overflow: hidden; color: #8fa49b; font-size: clamp(8px, 1.7cqw, 10px); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.login-save-list time { min-width: 86px; display: grid; justify-items: end; }
.login-save-list time strong { color: var(--gold); font-size: clamp(9px, 1.9cqw, 11px); }
.login-empty-copy { grid-column: 2 / -1; }
.login-save-footer { position: absolute; right: 5.5%; bottom: 19px; left: 5.5%; display: flex; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--line); color: #60756d; font-size: clamp(6px, 1.3cqw, 8px); letter-spacing: .08em; text-transform: uppercase; }
.login-save-footer b { color: #78988c; }
/* Save management */
.save-surface { padding: 0 30px; }
.save-slot-grid { height: 385px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; align-items: center; }
.save-slot { position: relative; height: 300px; display: flex; flex-direction: column; align-items: center; padding: 18px 16px; overflow: hidden; text-align: center; transition: transform 140ms ease, border-color 140ms ease; }
.save-slot-grid { height: 326px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; align-items: center; }
.save-slot { position: relative; height: 265px; display: flex; flex-direction: column; align-items: center; padding: 14px 16px; overflow: hidden; text-align: center; transition: transform 140ms ease, border-color 140ms ease; }
.save-slot::before { position: absolute; inset: 0; content: ""; background: radial-gradient(circle at 50% 36%, rgba(95,177,154,0.13), transparent 42%), linear-gradient(180deg, rgba(25, 48, 41, 0.55), rgba(7, 18, 15, 0.86)); }
.save-slot > * { position: relative; }
.save-slot:hover,
.save-slot.is-selected { border-color: rgba(232,200,114,0.68); transform: translateY(-4px); }
.save-slot.is-selected::after { position: absolute; inset: 5px; border: 1px solid rgba(232,200,114,0.18); content: ""; pointer-events: none; }
.slot-number { align-self: stretch; padding-bottom: 10px; border-bottom: 1px solid var(--line); color: #83978f; font-size: 9px; font-weight: 700; letter-spacing: 0.15em; text-align: left; text-transform: uppercase; }
.slot-portrait { width: 75px; height: 75px; display: grid; place-items: center; margin-top: 19px; border: 1px solid rgba(232,200,114,0.55); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 30%, #304d42, #0c1c18); font-family: "Cinzel", serif; font-size: 31px; box-shadow: 0 0 25px rgba(81,176,151,0.13); }
.slot-number { align-self: stretch; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 10px; border-bottom: 1px solid var(--line); color: #83978f; font-size: 9px; font-weight: 700; letter-spacing: 0.15em; text-align: left; text-transform: uppercase; }
.slot-number b { padding: 2px 4px; color: #87d9b9; background: rgba(62,133,109,.17); font-size: 6px; letter-spacing: .08em; white-space: nowrap; }
.slot-portrait { width: 68px; height: 68px; display: grid; place-items: center; margin-top: 14px; border: 1px solid rgba(232,200,114,0.55); border-radius: 50%; color: var(--gold-strong); background: radial-gradient(circle at 50% 30%, #304d42, #0c1c18); font-family: "Cinzel", serif; font-size: 29px; box-shadow: 0 0 25px rgba(81,176,151,0.13); }
.slot-portrait i { position: absolute; right: -3px; bottom: 0; width: 23px; height: 23px; display: grid; place-items: center; border-radius: 50%; color: #192019; background: var(--gold); font-size: 10px; font-style: normal; }
.slot-name { display: grid; margin-top: 12px; }
.slot-name strong { font-family: "Cinzel", serif; font-size: 17px; font-weight: 500; }
@@ -1708,8 +1720,14 @@ button:focus-visible {
.empty-slot b { color: #6f8c81; font-size: 36px; font-weight: 300; }
.empty-slot strong { font-family: "Cinzel", serif; font-size: 14px; font-weight: 500; }
.empty-slot small { color: #5e736b; font-size: 8px; text-transform: uppercase; }
.save-footer { display: flex; align-items: center; justify-content: space-between; padding: 8px 2px; border-top: 1px solid var(--line); color: #6e847b; font-size: 8px; letter-spacing: 0.08em; text-transform: uppercase; }
.save-top-actions { display: grid; grid-template-columns: 1.75fr repeat(5, minmax(0, 1fr)); gap: 7px; }
.save-top-actions button { min-width: 0; min-height: 55px; display: grid; align-content: center; padding: 7px 9px; text-align: left; }
.save-top-actions button:not(.front-primary) strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
.save-top-actions button:not(.front-primary) small { overflow: hidden; color: #6d827a; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.save-top-actions .danger-link strong { color: #ed8c78; }
.save-footer { min-height: 31px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 7px 2px 5px; border-top: 1px solid var(--line); color: #6e847b; font-size: 8px; letter-spacing: 0.08em; text-transform: uppercase; }
.save-footer > span b { color: #72cba9; }
.save-top-notice { overflow: hidden; color: #82978f; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.front-dialog { position: absolute; inset: 0; z-index: 5; display: grid; place-content: center; padding: 0 calc(50% - 190px); background: rgba(2,8,7,0.85); backdrop-filter: blur(7px); text-align: center; }
.front-dialog::before { position: absolute; top: 80px; right: calc(50% - 210px); bottom: 70px; left: calc(50% - 210px); z-index: -1; border: 1px solid rgba(232,200,114,0.35); border-top: 2px solid var(--gold); content: ""; background: #0b1916; box-shadow: 0 22px 60px rgba(0,0,0,0.6); }
.front-dialog > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; }
@@ -1725,6 +1743,24 @@ button:focus-visible {
.dialog-actions button { min-width: 110px; padding: 10px 14px; font-weight: 700; }
.dialog-actions button small { display: block; color: #748a81; font-size: 7px; text-transform: uppercase; }
.dialog-actions button.is-danger { border-color: #d76551; color: #fff; background: #8e3025; }
.front-dialog.version-dialog { grid-template-columns: minmax(0, 630px); padding-right: calc(50% - 315px); padding-left: calc(50% - 315px); }
.front-dialog.version-dialog::before { top: 45px; right: calc(50% - 345px); bottom: 45px; left: calc(50% - 345px); }
.version-choice-dialog { width: 100%; }
.version-choice-dialog > span { color: var(--gold); font-size: 8px; font-weight: 700; letter-spacing: .15em; text-transform: uppercase; }
.version-choice-dialog h2 { margin: 5px 0; }
.version-choice-dialog > p { font-size: 10px; }
.version-comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 13px; text-align: left; }
.version-comparison article { display: grid; gap: 3px; padding: 11px 13px; border: 1px solid var(--line); background: rgba(5,16,13,.75); }
.version-comparison article.is-newer { border-color: rgba(111,208,168,.55); box-shadow: inset 3px 0 #6fd0a8; background: rgba(29,74,59,.2); }
.version-comparison header { display: flex; justify-content: space-between; color: #788d85; font-size: 7px; letter-spacing: .11em; text-transform: uppercase; }
.version-comparison header b { color: #70d0a8; }
.version-comparison article > strong { font: 500 13px "Cinzel", serif; }
.version-comparison time { color: var(--gold-strong); font-size: 11px; }
.version-comparison article > small { color: #6f847c; font-size: 8px; }
.version-actions { display: grid; grid-template-columns: 1.35fr 1.35fr .7fr; }
.version-actions button { min-width: 0; min-height: 47px; display: grid; align-content: center; text-align: left; }
.version-actions button > span { font-size: 10px; }
.version-choice-error { margin-top: 8px; padding: 6px 9px; border-left: 2px solid #e5634d; color: #f0a594; background: rgba(95,31,23,.2); font-size: 8px; text-align: left; }
.save-context { padding: 0 5.5% 18px; }
.save-context .context-header { margin: 0 -5.8%; }
.selected-save-summary { min-height: 105px; display: grid; grid-template-columns: 66px 1fr; align-items: center; gap: 15px; padding: 16px 0 11px; border-bottom: 1px solid var(--line); }
@@ -1735,18 +1771,21 @@ button:focus-visible {
.selected-save-summary h2 { margin: 2px 0 0; font-family: "Cinzel", serif; font-size: clamp(16px, 3.4cqw, 21px); font-weight: 500; }
.selected-save-summary p { margin: 2px 0; overflow: hidden; color: #9dafA8; font-size: clamp(8px, 1.9cqw, 11px); text-overflow: ellipsis; white-space: nowrap; }
.selected-save-summary time { color: #60756d; font-size: 8px; }
.online-record { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border: 1px solid rgba(89,181,151,0.26); color: #9eb1aa; background: rgba(39,96,78,0.13); font-size: clamp(8px, 1.7cqw, 10px); }
.online-record span { display: flex; gap: 7px; }
.online-record b { color: #6ad0a8; font-size: 7px; letter-spacing: 0.1em; }
.online-record time { color: #6f847c; font-size: 8px; }
.save-actions { display: grid; gap: 8px; margin-top: 10px; }
.save-actions .front-primary { min-height: 42px; }
.sync-actions,
.record-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.sync-actions button,
.record-actions button { min-height: 34px; padding: 6px 7px; font-size: clamp(8px, 1.7cqw, 10px); font-weight: 700; }
.record-actions { grid-template-columns: 1fr 1fr 0.7fr; }
.record-actions .danger-link { color: #ed8c78; }
.save-dossier-stats { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 13px; border: 1px solid var(--line); background: rgba(6,17,14,.68); }
.save-dossier-stats > span { min-width: 0; display: grid; padding: 11px 10px; border-right: 1px solid var(--line); }
.save-dossier-stats > span:last-child { border: 0; }
.save-dossier-stats small,
.save-dossier-records small { color: #62776f; font-size: clamp(6px, 1.35cqw, 8px); letter-spacing: .07em; text-transform: uppercase; }
.save-dossier-stats strong { color: var(--gold-strong); font: 500 clamp(15px, 3.1cqw, 19px) "Cinzel", serif; }
.save-dossier-stats em { overflow: hidden; color: #80958d; font-size: clamp(7px, 1.5cqw, 9px); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.save-dossier-records { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.save-dossier-records > span { display: grid; padding: 9px 11px; border-left: 2px solid rgba(232,200,114,.45); background: rgba(46,40,21,.24); }
.save-dossier-records b { color: #c7d6d0; font-size: clamp(9px, 1.9cqw, 11px); }
.save-copy-state { display: grid; gap: 6px; margin-top: 12px; }
.save-copy-state > span { display: grid; grid-template-columns: 8px auto 1fr; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid var(--line); color: #90a49c; font-size: clamp(7px, 1.55cqw, 9px); text-transform: uppercase; }
.save-copy-state i { width: 6px; height: 6px; border-radius: 50%; background: #455750; }
.save-copy-state i.is-present { background: #6fd0a8; box-shadow: 0 0 8px rgba(111,208,168,.4); }
.save-copy-state b { justify-self: end; color: #647970; font-size: clamp(6px, 1.35cqw, 8px); font-weight: 600; }
.front-notice.is-lower { margin-top: auto; font-size: clamp(7px, 1.55cqw, 9px); }
/* Main menu */
@@ -2067,7 +2106,7 @@ button:focus-visible {
.profile-header { grid-template-columns: 125px minmax(0, 1fr) auto auto; }
.profile-view-tabs { gap: 2px; }
.profile-view-tabs button { min-height: 22px; padding: 3px 5px; font-size: 5px; }
.save-slot-grid { height: calc(100% - 75px); gap: 5px; }
.save-slot-grid { height: calc(100% - 125px); gap: 5px; }
.save-slot { height: 84%; padding: 7px 5px; }
.slot-portrait { width: 36px; height: 36px; margin-top: 7px; font-size: 14px; }
.slot-portrait i { width: 13px; height: 13px; font-size: 5px; }
@@ -2077,8 +2116,31 @@ button:focus-visible {
.slot-meta b { font-size: 7px; }
.empty-slot b { font-size: 19px; }
.empty-slot strong { font-size: 8px; }
.save-footer { position: absolute; right: 12px; bottom: 3px; left: 12px; }
.save-top-actions { grid-template-columns: 1.5fr repeat(5, minmax(0, 1fr)); gap: 3px; }
.save-top-actions button { min-height: 38px; padding: 3px 4px; }
.save-top-actions .front-primary { min-height: 38px; }
.save-top-actions .front-primary span,
.save-top-actions button:not(.front-primary) strong { font-size: 6px; }
.save-top-actions .front-primary small,
.save-top-actions button:not(.front-primary) small { display: none; }
.save-footer { min-height: 18px; gap: 5px; padding: 3px 1px; font-size: 5px; }
.save-top-notice { display: none; }
.save-footer .controller-legend { display: none; }
.front-dialog.version-dialog { grid-template-columns: minmax(0, 1fr); padding-right: 8%; padding-left: 8%; }
.front-dialog.version-dialog::before { top: 16px; right: 6%; bottom: 16px; left: 6%; }
.version-choice-dialog > span { font-size: 7px; }
.version-choice-dialog h2 { margin: 3px 0; font-size: 18px; }
.version-choice-dialog > p { font-size: 8px; }
.version-comparison { gap: 6px; margin-top: 8px; }
.version-comparison article { gap: 2px; padding: 8px 9px; }
.version-comparison header { font-size: 6px; }
.version-comparison article > strong { font-size: 10px; }
.version-comparison time { font-size: 8px; }
.version-comparison article > small { font-size: 6px; }
.version-actions { gap: 4px; margin-top: 7px; }
.version-actions button { min-width: 0; min-height: 38px; padding: 5px 7px; }
.version-actions button > span { font-size: 8px; }
.version-actions button small { font-size: 6px; }
.home-header { height: 35px; }
.home-header > span, .home-header > i { font-size: 5px; }
.mode-grid { grid-template-rows: repeat(2, 43px); gap: 5px; margin-top: 6px; }