Release v0.1.2 2026-07-11

This commit is contained in:
Warren H
2026-07-11 00:12:57 -04:00
parent 6726c600e4
commit 076f6cf97c
16 changed files with 556 additions and 21 deletions
+48 -12
View File
@@ -17,6 +17,7 @@ const SPIDER_TEXTURE_URLS: Record<string, string> = {
"haar_detail_NRM.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/haar_detail_NRM.jpg", import.meta.url).href,
};
const DRAGON_URL = new URL("../../game_assets/models/claudecraft/creatures/dragonevolved.glb", import.meta.url).href;
const EMBER_MANTIS_URL = new URL("../../game_assets/models/original/bosses/ember-mantis-duelist/ember_mantis_duelist.glb", import.meta.url).href;
const PARTY_MODEL_URLS: Record<MemberId, string> = {
aelia: new URL("../../game_assets/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
brann: new URL("../../game_assets/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
@@ -491,7 +492,7 @@ function BossFallback({ bossIndex }: { bossIndex: number }) {
return (
<mesh castShadow position={[position[0], 1.1, position[1]]}>
<dodecahedronGeometry args={[1.1, 0]} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "ember-mantis-duelist" ? "#a42d18" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
</mesh>
);
}
@@ -580,7 +581,7 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
);
}
type AlternateBossKind = "vexa" | "cindermaw";
type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist";
const ALTERNATE_BOSS_CONFIG = {
vexa: {
@@ -605,6 +606,17 @@ const ALTERNATE_BOSS_CONFIG = {
light: "#ff8742",
rotationOffset: 0,
},
"ember-mantis-duelist": {
url: EMBER_MANTIS_URL,
scale: 0.78,
idle: "Idle",
move: "Sidestep",
attack: "LineSlash",
special: "CrossSlash",
death: "Death",
light: "#ff5a24",
rotationOffset: 0,
},
} as const;
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
@@ -634,27 +646,43 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
const clipName = phase === "victory" || defeated
? config.death
: motionMode === "skyfall"
? config.move
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
? config.special
: motionMode === "tethering" || motionMode === "venom_cast"
: kind === "ember-mantis-duelist"
? motionMode === "mantis_sidestep"
? config.move
: motionMode === "mantis_line_telegraph"
? config.attack
: config.idle;
: motionMode === "mantis_cross_telegraph"
? config.special
: motionMode === "mantis_recover"
? "Recover"
: config.idle
: motionMode === "skyfall"
? config.move
: motionMode === "breath_telegraph" || motionMode === "breath_sweeping"
? config.special
: motionMode === "tethering" || motionMode === "venom_cast"
? config.attack
: config.idle;
useEffect(() => {
const next = actions[clipName];
if (!next) return;
for (const action of Object.values(actions)) action?.fadeOut(0.16);
next.reset().setEffectiveWeight(1).fadeIn(0.16).play();
if (phase === "victory" || defeated) {
const timeScale = kind === "ember-mantis-duelist" && motionMode === "mantis_line_telegraph"
? 0.55
: kind === "ember-mantis-duelist" && motionMode === "mantis_cross_telegraph"
? 0.6
: 1;
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
const emberOneShot = kind === "ember-mantis-duelist" && clipName !== config.idle;
if (phase === "victory" || defeated || emberOneShot) {
next.setLoop(THREE.LoopOnce, 1);
next.clampWhenFinished = true;
} else {
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
}
return () => { next.fadeOut(0.16); };
}, [actions, clipName, defeated, phase]);
}, [actions, clipName, config.idle, defeated, kind, motionMode, phase]);
useFrame((_, delta) => {
if (!group.current) return;
@@ -672,6 +700,13 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
);
if (kind === "cindermaw" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
targetAngle = motion.breathAngle;
} else if (kind === "ember-mantis-duelist" && (
motion.mode === "mantis_sidestep"
|| motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
)) {
const target = state.partyPositions[motion.chargeTargetId];
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
}
const difference = Math.atan2(
Math.sin(targetAngle - group.current.rotation.y),
@@ -877,7 +912,7 @@ function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
const state = useGameStore.getState();
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
useFrame((_, delta) => {
age.current += delta;
if (!ring.current || !material.current) return;
@@ -926,6 +961,7 @@ export function GameScene() {
}
useGLTF.preload(BULL_URL, false, true);
useGLTF.preload(EMBER_MANTIS_URL, false, true);
for (const modelUrl of Object.values(PARTY_MODEL_URLS)) useGLTF.preload(modelUrl, false, true);
for (const loadout of Object.values(PARTY_WEAPON_URLS)) {
useGLTF.preload(loadout.right, false, true);
@@ -8,6 +8,7 @@ import { useGameStore } from "../../game/store";
const CHARGE_MARKERS = [0, 1, 2, 3, 4, 5, 6] as const;
const STACK_DIRECTIONS = Array.from({ length: 8 }, (_, index) => (index / 8) * Math.PI * 2);
const EMPTY_HAZARDS: never[] = [];
const EMPTY_SLASH_LANES: never[] = [];
type GameStoreState = ReturnType<typeof useGameStore.getState>;
function motionAt(state: GameStoreState, bossIndex: number) {
@@ -68,6 +69,63 @@ export function ChargeLaneIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
);
}
function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: number }) {
const phase = useGameStore((state) => state.phase);
const motion = useGameStore((state) => motionAt(state, bossIndex));
const material = useRef<THREE.MeshBasicMaterial>(null);
const edgeMaterial = useRef<THREE.MeshBasicMaterial>(null);
useFrame(({ clock }) => {
if (!material.current || !edgeMaterial.current) return;
const current = motionAt(useGameStore.getState(), bossIndex);
const active = current?.mode === "mantis_recover";
material.current.opacity = active ? 0.5 : 0.14 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.09;
edgeMaterial.current.opacity = active ? 1 : 0.62 + (Math.sin(clock.elapsedTime * 12) + 1) * 0.15;
});
const lane = motion?.slashLanes.find((candidate) => candidate.id === laneId);
if (!lane || phase !== "combat") return null;
const visible = motion.mode === "mantis_line_telegraph"
|| motion.mode === "mantis_cross_telegraph"
|| motion.mode === "mantis_recover";
if (!visible) return null;
const dx = lane.end[0] - lane.start[0];
const dz = lane.end[1] - lane.start[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(dx, dz);
const midpoint: [number, number, number] = [
(lane.start[0] + lane.end[0]) * 0.5,
0.052,
(lane.start[1] + lane.end[1]) * 0.5,
];
const active = motion.mode === "mantis_recover";
const color = active ? "#ffd36a" : "#ff5128";
return (
<group position={midpoint} rotation={[0, angle, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[lane.width, length]} />
<meshBasicMaterial ref={material} color={color} transparent opacity={0.22} depthWrite={false} />
</mesh>
{[-1, 1].map((side) => (
<mesh key={side} position={[side * lane.width * 0.5, 0.018, 0]}>
<boxGeometry args={[0.07, 0.03, length]} />
<meshBasicMaterial ref={side < 0 ? edgeMaterial : undefined} color={color} transparent opacity={0.8} depthWrite={false} />
</mesh>
))}
{active && (
<mesh position={[0, 0.055, 0]}>
<boxGeometry args={[0.16, 0.08, length]} />
<meshBasicMaterial color="#fff0a8" transparent opacity={0.92} depthWrite={false} />
</mesh>
)}
</group>
);
}
export function SlashLaneIndicators({ bossIndex = 0 }: { bossIndex?: number }) {
const lanes = useGameStore((state) => motionAt(state, bossIndex)?.slashLanes ?? EMPTY_SLASH_LANES);
return <>{lanes.map((lane) => <SlashLaneIndicator key={lane.id} laneId={lane.id} bossIndex={bossIndex} />)}</>;
}
export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
const phase = useGameStore((state) => state.phase);
const motionMode = useGameStore((state) => motionAt(state, bossIndex)?.mode);
@@ -205,6 +263,7 @@ export function CircleHazardIndicators({ bossIndex = 0 }: { bossIndex?: number }
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
ChargeLaneIndicator,
SlashLaneIndicators,
PounceStackIndicator,
BindingWebIndicator,
BreathConeIndicator,