Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18c416d4d2 |
+1
-1
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 (
|
||||
|
||||
+165
-51
@@ -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>
|
||||
{(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="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 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>
|
||||
}
|
||||
/>
|
||||
|
||||
+117
-22
@@ -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]]}>
|
||||
<group ref={group} position={[position[0], 1.1, position[1]]}>
|
||||
<mesh castShadow>
|
||||
<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} />
|
||||
<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +974,7 @@ 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);
|
||||
|
||||
@@ -970,9 +984,9 @@ const venomPurge = instantTimedDefinition("venom-purge", 4, (runtime) => {
|
||||
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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user