Release v0.1.3 2026-07-11
This commit is contained in:
+393
-92
@@ -4,20 +4,29 @@ import { Suspense, useEffect, useMemo, useRef, type MutableRefObject } from "rea
|
||||
import * as THREE from "three";
|
||||
import { getControllerMovement } from "../input/controller";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { ARENA_CENTER, ARENA_WALL_RADIUS, clampToArena } from "../game/arena";
|
||||
import {
|
||||
isActorAnimationOneShot,
|
||||
shouldStartActorAnimation,
|
||||
type ActorAnimationState,
|
||||
} from "../game/actorAnimation";
|
||||
import { PERFORMANCE_PROBE_ENABLED, simulationTickSnapshot } from "../game/performance";
|
||||
import { useGameStore } from "../game/store";
|
||||
import type { MemberId, PulseKind } from "../game/types";
|
||||
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
|
||||
|
||||
const BULL_URL = new URL("../../game_assets/models/claudecraft/creatures/bull.glb", import.meta.url).href;
|
||||
const SPIDER_URL = new URL("../../game_assets/models/downloaded/low-poly-spider/low-poly-spider.glb", import.meta.url).href;
|
||||
const SPIDER_TEXTURE_URLS: Record<string, string> = {
|
||||
"Spinnen_Bein_tex_2.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_2.jpg", import.meta.url).href,
|
||||
"SH3.png": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/SH3.png", import.meta.url).href,
|
||||
"Spinnen_Bein_tex_COLOR_.jpg": new URL("../../game_assets/models/downloaded/low-poly-spider/textures/Spinnen_Bein_tex_COLOR_.jpg", import.meta.url).href,
|
||||
"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 INSECT_QUEEN_URL = new URL("../../game_assets/models/downloaded/yugioh/insect-queen/insect-queen-animated.glb", import.meta.url).href;
|
||||
const BLUE_EYES_WHITE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-white-dragon/blue-eyes-white-dragon-animated.glb", import.meta.url).href;
|
||||
const GATE_GUARDIAN_URL = new URL("../../game_assets/models/downloaded/yugioh/gate-guardian/gate-guardian-animated.glb", import.meta.url).href;
|
||||
const GANDORA_URL = new URL("../../game_assets/models/downloaded/yugioh/gandora-the-dragon-of-destruction/gandora-the-dragon-of-destruction-animated.glb", import.meta.url).href;
|
||||
const RED_EYES_BLACK_URL = new URL("../../game_assets/models/downloaded/yugioh/red-eyes-black-dragon/red-eyes-black-dragon-animated.glb", import.meta.url).href;
|
||||
const PUMPKING_URL = new URL("../../game_assets/models/downloaded/yugioh/pumpking-the-king-of-ghosts/pumpking-the-king-of-ghosts-animated.glb", import.meta.url).href;
|
||||
const BLUE_EYES_ULTIMATE_URL = new URL("../../game_assets/models/downloaded/yugioh/blue-eyes-ultimate-dragon/blue-eyes-ultimate-dragon-animated.glb", import.meta.url).href;
|
||||
const SANDGLASS_URL = new URL("../../game_assets/models/original/bosses/sandglass-scorpion/sandglass-scorpion.glb", import.meta.url).href;
|
||||
const CRAGCLAW_URL = new URL("../../game_assets/models/claudecraft/creatures/crabenemy.glb", import.meta.url).href;
|
||||
const MOURNVEIL_URL = new URL("../../game_assets/models/claudecraft/creatures/ghost.glb", import.meta.url).href;
|
||||
const CROWNSHARD_URL = new URL("../../game_assets/models/claudecraft/creatures/golelingevolved.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,
|
||||
@@ -54,6 +63,14 @@ const ARENA_COLUMNS = Array.from({ length: 10 }, (_, index) => {
|
||||
return [Math.sin(angle) * 9.3, Math.cos(angle) * 9.3] as const;
|
||||
});
|
||||
const ARENA_TORCH_COLORS = [new THREE.Color("#ff9a4f"), new THREE.Color("#77ddce")] as const;
|
||||
const ARENA_WALL_SEGMENTS = Array.from({ length: 16 }, (_, index) => {
|
||||
const angle = (index / 16) * Math.PI * 2;
|
||||
return {
|
||||
angle,
|
||||
position: [Math.sin(angle) * ARENA_WALL_RADIUS, 1.15, ARENA_CENTER[1] + Math.cos(angle) * ARENA_WALL_RADIUS] as const,
|
||||
};
|
||||
});
|
||||
const PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia">[] = ["brann", "nia", "orin", "vale"];
|
||||
type GameStoreState = ReturnType<typeof useGameStore.getState>;
|
||||
|
||||
function encounterBossAt(state: GameStoreState, bossIndex: number) {
|
||||
@@ -72,15 +89,6 @@ function targetBossMotionByInstance(state: GameStoreState, instanceId?: string)
|
||||
return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
|
||||
}
|
||||
|
||||
const configureSpiderLoader: NonNullable<Parameters<typeof useGLTF>[3]> = (loader) => {
|
||||
loader.manager.setURLModifier((url) => {
|
||||
const fileName = url.slice(url.lastIndexOf("/") + 1);
|
||||
return SPIDER_TEXTURE_URLS[fileName] ?? url;
|
||||
});
|
||||
};
|
||||
|
||||
type ActorAnimationState = "idle" | "walk" | "run" | "attack" | "cast" | "hit" | "death";
|
||||
|
||||
type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";
|
||||
|
||||
const PARTY_WEAPON_GRIPS: Record<MemberId, { right: WeaponGrip; left?: WeaponGrip }> = {
|
||||
@@ -141,9 +149,11 @@ function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" |
|
||||
function PartyCharacterModel({
|
||||
memberId,
|
||||
animationState,
|
||||
animationTrigger,
|
||||
}: {
|
||||
memberId: MemberId;
|
||||
animationState: MutableRefObject<ActorAnimationState>;
|
||||
animationTrigger: MutableRefObject<number>;
|
||||
}) {
|
||||
const gltf = useGLTF(PARTY_MODEL_URLS[memberId], false, true);
|
||||
const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
@@ -163,6 +173,8 @@ function PartyCharacterModel({
|
||||
);
|
||||
const { actions } = useAnimations(gltf.animations, actorScene);
|
||||
const activeClip = useRef<string | undefined>(undefined);
|
||||
const activeState = useRef<ActorAnimationState | undefined>(undefined);
|
||||
const activeTrigger = useRef(Number.NaN);
|
||||
|
||||
useEffect(() => {
|
||||
actorScene.traverse((object) => {
|
||||
@@ -189,6 +201,7 @@ function PartyCharacterModel({
|
||||
|
||||
useFrame(() => {
|
||||
const state = animationState.current;
|
||||
const trigger = animationTrigger.current;
|
||||
const clipName = state === "death"
|
||||
? "Death_A"
|
||||
: state === "hit"
|
||||
@@ -202,20 +215,24 @@ function PartyCharacterModel({
|
||||
: state === "attack"
|
||||
? PARTY_ATTACK_CLIPS[memberId]
|
||||
: "Idle";
|
||||
if (activeClip.current === clipName) return;
|
||||
if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return;
|
||||
const next = actions[clipName];
|
||||
if (!next) return;
|
||||
if (activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1).fadeIn(0.16);
|
||||
if (state === "death" || state === "hit" || state === "attack" || state === "cast") {
|
||||
const clipChanged = activeClip.current !== clipName;
|
||||
if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
|
||||
next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1);
|
||||
if (clipChanged) next.fadeIn(0.16);
|
||||
if (isActorAnimationOneShot(state)) {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = state === "death";
|
||||
next.clampWhenFinished = true;
|
||||
} else {
|
||||
next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
|
||||
next.clampWhenFinished = false;
|
||||
}
|
||||
next.play();
|
||||
activeClip.current = clipName;
|
||||
activeState.current = state;
|
||||
activeTrigger.current = trigger;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -270,6 +287,7 @@ function Arena() {
|
||||
<octahedronGeometry args={[0.2, 0]} />
|
||||
<meshBasicMaterial />
|
||||
</instancedMesh>
|
||||
<ArenaWalls />
|
||||
<pointLight color="#dd7b38" intensity={2.2} distance={7} position={[-6, 2.7, -1]} />
|
||||
<pointLight color="#6fc9ba" intensity={2.2} distance={7} position={[6, 2.7, -1]} />
|
||||
<gridHelper args={[22, 22, "#2c4039", "#1b2925"]} position={[0, 0.01, -1]} />
|
||||
@@ -277,9 +295,32 @@ function Arena() {
|
||||
);
|
||||
}
|
||||
|
||||
function ArenaWalls() {
|
||||
const walls = useRef<THREE.Group>(null);
|
||||
useFrame(({ camera }) => {
|
||||
if (!walls.current) return;
|
||||
for (const child of walls.current.children) {
|
||||
const material = (child as THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>).material;
|
||||
const cameraDistance = Math.hypot(camera.position.x - child.position.x, camera.position.z - child.position.z);
|
||||
material.opacity = THREE.MathUtils.smoothstep(cameraDistance, 2.5, 7.5) * 0.52 + 0.06;
|
||||
}
|
||||
});
|
||||
return (
|
||||
<group ref={walls}>
|
||||
{ARENA_WALL_SEGMENTS.map(({ angle, position }, index) => (
|
||||
<mesh key={index} position={position} rotation={[0, angle, 0]} receiveShadow>
|
||||
<boxGeometry args={[3.86, 2.3, 0.18]} />
|
||||
<meshStandardMaterial color="#263a34" roughness={0.9} transparent opacity={0.58} depthWrite={false} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const animationState = useRef<ActorAnimationState>("idle");
|
||||
const animationTrigger = useRef(0);
|
||||
useEffect(() => {
|
||||
const start = useGameStore.getState().partyPositions[memberId];
|
||||
group.current?.position.set(start[0], 0.025, start[1]);
|
||||
@@ -303,6 +344,13 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
|
||||
const attacking = state.phase === "combat"
|
||||
&& visualAction !== null
|
||||
&& visualAction.endsAt > state.time;
|
||||
animationTrigger.current = member.hp <= 0
|
||||
? 0
|
||||
: knocked
|
||||
? member.knockedUntil
|
||||
: attacking
|
||||
? visualAction.startedAt
|
||||
: 0;
|
||||
animationState.current = member.hp <= 0
|
||||
? "death"
|
||||
: knocked
|
||||
@@ -328,7 +376,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
|
||||
|
||||
return (
|
||||
<group ref={group} rotation={[0, Math.PI, 0]}>
|
||||
<PartyCharacterModel memberId={memberId} animationState={animationState} />
|
||||
<PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
@@ -342,6 +390,7 @@ function Character({ memberId, selected = false }: { memberId: Exclude<MemberId,
|
||||
function PlayerCharacter() {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const animationState = useRef<ActorAnimationState>("idle");
|
||||
const animationTrigger = useRef(0);
|
||||
const keys = useRef(new Set<string>());
|
||||
const scenePulse = useGameStore((state) => state.scenePulse);
|
||||
const selected = useGameStore((state) => state.selectedMemberId === "aelia");
|
||||
@@ -349,6 +398,7 @@ function PlayerCharacter() {
|
||||
const { camera } = useThree();
|
||||
const broadcastTimer = useRef(0);
|
||||
const castingUntil = useRef(0);
|
||||
const instantCastTrigger = useRef(0);
|
||||
const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -359,6 +409,7 @@ function PlayerCharacter() {
|
||||
useEffect(() => {
|
||||
if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
|
||||
castingUntil.current = performance.now() + 700;
|
||||
instantCastTrigger.current = scenePulse.id;
|
||||
}
|
||||
}, [scenePulse]);
|
||||
|
||||
@@ -372,8 +423,9 @@ function PlayerCharacter() {
|
||||
const nudgeX = Number(key === "d") - Number(key === "a");
|
||||
const nudgeZ = Number(key === "s") - Number(key === "w");
|
||||
if (!nudgeX && !nudgeZ) return;
|
||||
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + nudgeX * 0.18, -7.2, 7.2);
|
||||
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + nudgeZ * 0.18, -4.8, 7.2);
|
||||
const next = clampToArena([group.current.position.x + nudgeX * 0.18, group.current.position.z + nudgeZ * 0.18]);
|
||||
group.current.position.x = next[0];
|
||||
group.current.position.z = next[1];
|
||||
setPlayerPosition([group.current.position.x, group.current.position.z]);
|
||||
};
|
||||
const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
|
||||
@@ -401,9 +453,10 @@ function PlayerCharacter() {
|
||||
}
|
||||
const length = Math.hypot(inputX, inputZ);
|
||||
if (length > 0.05) {
|
||||
const speed = 4.6 * delta / Math.max(1, length);
|
||||
group.current.position.x = THREE.MathUtils.clamp(group.current.position.x + inputX * speed, -7.2, 7.2);
|
||||
group.current.position.z = THREE.MathUtils.clamp(group.current.position.z + inputZ * speed, -4.8, 7.2);
|
||||
const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
|
||||
const next = clampToArena([group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]);
|
||||
group.current.position.x = next[0];
|
||||
group.current.position.z = next[1];
|
||||
group.current.rotation.y = Math.atan2(inputX, inputZ);
|
||||
} else if (state.phase === "combat" && player.hp > 0 && !knocked) {
|
||||
const boss = targetBossMotion(state).position;
|
||||
@@ -414,6 +467,16 @@ function PlayerCharacter() {
|
||||
);
|
||||
group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
|
||||
}
|
||||
const instantCasting = performance.now() < castingUntil.current;
|
||||
animationTrigger.current = player.hp <= 0
|
||||
? 0
|
||||
: knocked
|
||||
? player.knockedUntil
|
||||
: state.activeCast
|
||||
? state.activeCast.startedAt
|
||||
: instantCasting
|
||||
? instantCastTrigger.current
|
||||
: 0;
|
||||
animationState.current = player.hp <= 0
|
||||
? "death"
|
||||
: knocked
|
||||
@@ -422,7 +485,7 @@ function PlayerCharacter() {
|
||||
? "cast"
|
||||
: length > 0.05
|
||||
? "run"
|
||||
: performance.now() < castingUntil.current
|
||||
: instantCasting
|
||||
? "cast"
|
||||
: "idle";
|
||||
group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
|
||||
@@ -440,7 +503,7 @@ function PlayerCharacter() {
|
||||
|
||||
return (
|
||||
<group ref={group} rotation={[0, Math.PI, 0]}>
|
||||
<PartyCharacterModel memberId="aelia" animationState={animationState} />
|
||||
<PartyCharacterModel memberId="aelia" animationState={animationState} animationTrigger={animationTrigger} />
|
||||
{selected && (
|
||||
<mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.55, 0.66, 32]} />
|
||||
@@ -453,16 +516,15 @@ function PlayerCharacter() {
|
||||
}
|
||||
|
||||
function Party() {
|
||||
const party = useGameStore((state) => state.party);
|
||||
const selected = useGameStore((state) => state.selectedMemberId);
|
||||
return (
|
||||
<>
|
||||
<PlayerCharacter />
|
||||
{party.slice(1).map((member) => (
|
||||
{PARTY_MEMBER_IDS.map((memberId) => (
|
||||
<Character
|
||||
key={member.id}
|
||||
memberId={member.id as Exclude<MemberId, "aelia">}
|
||||
selected={selected === member.id}
|
||||
key={memberId}
|
||||
memberId={memberId}
|
||||
selected={selected === memberId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -492,7 +554,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" : bossId === "ember-mantis-duelist" ? "#a42d18" : "#7b3928"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
<meshStandardMaterial color={bossId === "vexa" ? "#56306f" : bossId === "cindermaw" ? "#9d4c24" : bossId === "sandglass-scorpion" ? "#b78b32" : bossId === "ember-mantis-duelist" || bossId === "cinderback-ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
@@ -581,44 +643,217 @@ function BullBoss({ bossIndex }: { bossIndex: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
type AlternateBossKind = "vexa" | "cindermaw" | "ember-mantis-duelist";
|
||||
type AlternateBossKind = Exclude<ReturnType<typeof useGameStore.getState>["boss"]["id"], "bulldrome">;
|
||||
|
||||
const ALTERNATE_BOSS_CONFIG = {
|
||||
vexa: {
|
||||
url: SPIDER_URL,
|
||||
scale: 0.022,
|
||||
idle: "Spider_Armature|warte_pose",
|
||||
move: "Spider_Armature|run_ani_vor",
|
||||
attack: "Spider_Armature|Attack",
|
||||
special: "Spider_Armature|Jump",
|
||||
death: "Spider_Armature|die",
|
||||
url: INSECT_QUEEN_URL,
|
||||
scale: 9,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#bb67ff",
|
||||
rotationOffset: Math.PI,
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
cindermaw: {
|
||||
url: DRAGON_URL,
|
||||
scale: 1.15,
|
||||
idle: "Flying_Idle",
|
||||
move: "Fast_Flying",
|
||||
attack: "Headbutt",
|
||||
special: "Punch",
|
||||
url: BLUE_EYES_WHITE_URL,
|
||||
scale: 10,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff8742",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"ember-mantis-duelist": {
|
||||
url: EMBER_MANTIS_URL,
|
||||
scale: 0.78,
|
||||
url: GATE_GUARDIAN_URL,
|
||||
scale: 4.3,
|
||||
idle: "Idle",
|
||||
move: "Sidestep",
|
||||
attack: "LineSlash",
|
||||
special: "CrossSlash",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff5a24",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"obsidian-ram-golem": {
|
||||
url: GANDORA_URL,
|
||||
scale: 6.2,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff7438",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"cinderback-ricochet": {
|
||||
url: RED_EYES_BLACK_URL,
|
||||
scale: 10,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#ff8b3d",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"sandglass-scorpion": {
|
||||
url: SANDGLASS_URL,
|
||||
scale: 0.7,
|
||||
idle: "Idle",
|
||||
move: "Burrow",
|
||||
attack: "Eruption",
|
||||
special: "Hourglass",
|
||||
death: "Death",
|
||||
light: "#e9b94f",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"cragclaw-crab": {
|
||||
url: CRAGCLAW_URL,
|
||||
scale: 1.2,
|
||||
idle: "Idle",
|
||||
move: "Walk",
|
||||
attack: "Bite_Front",
|
||||
special: "Bite_InPlace",
|
||||
death: "Death",
|
||||
light: "#49d5df",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"pumpking-king-of-ghosts": {
|
||||
url: PUMPKING_URL,
|
||||
scale: 1.5,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#d87842",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"blue-eyes-ultimate-dragon": {
|
||||
url: BLUE_EYES_ULTIMATE_URL,
|
||||
scale: 4.5,
|
||||
idle: "Idle",
|
||||
move: "Move",
|
||||
attack: "Attack",
|
||||
special: "Attack",
|
||||
death: "Death",
|
||||
light: "#8fc8ff",
|
||||
rotationOffset: 0,
|
||||
prototype: true,
|
||||
},
|
||||
"mournveil-ghost": {
|
||||
url: MOURNVEIL_URL,
|
||||
scale: 1.1,
|
||||
idle: "Flying_Idle",
|
||||
move: "Fast_Flying",
|
||||
attack: "Punch",
|
||||
special: "Headbutt",
|
||||
death: "Death",
|
||||
light: "#9d72ff",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
"crownshard-golem": {
|
||||
url: CROWNSHARD_URL,
|
||||
scale: 1.15,
|
||||
idle: "Flying_Idle",
|
||||
move: "Fast_Flying",
|
||||
attack: "Punch",
|
||||
special: "Headbutt",
|
||||
death: "Death",
|
||||
light: "#e0bd45",
|
||||
rotationOffset: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROTOTYPE_MOVE_MODES = [
|
||||
"skyfall",
|
||||
"mantis_sidestep",
|
||||
"ram_charging",
|
||||
"cinderback_ricochet",
|
||||
] as const;
|
||||
|
||||
const PROTOTYPE_ATTACK_MODES = [
|
||||
"tethering",
|
||||
"venom_cast",
|
||||
"breath_telegraph",
|
||||
"breath_sweeping",
|
||||
"mantis_line_telegraph",
|
||||
"mantis_cross_telegraph",
|
||||
"ram_charge_telegraph",
|
||||
"ram_quake",
|
||||
"ram_shatter",
|
||||
"cinderback_curl",
|
||||
"cinderback_slam",
|
||||
"ghost_soul_cross",
|
||||
"ghost_soul_cross_followup",
|
||||
"ghost_haunting",
|
||||
"golem_shockwave",
|
||||
"golem_crownfall",
|
||||
] as const;
|
||||
|
||||
function alternateBossClip(kind: AlternateBossKind, motionMode: ReturnType<typeof useGameStore.getState>["bossMotion"]["mode"]) {
|
||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||
if ("prototype" in config && config.prototype) {
|
||||
if ((PROTOTYPE_MOVE_MODES as readonly string[]).includes(motionMode)) return config.move;
|
||||
if ((PROTOTYPE_ATTACK_MODES as readonly string[]).includes(motionMode)) return config.attack;
|
||||
return config.idle;
|
||||
}
|
||||
if (kind === "ember-mantis-duelist") {
|
||||
if (motionMode === "mantis_sidestep") return config.move;
|
||||
if (motionMode === "mantis_line_telegraph") return config.attack;
|
||||
if (motionMode === "mantis_cross_telegraph") return config.special;
|
||||
if (motionMode === "mantis_recover") return "Recover";
|
||||
}
|
||||
if (kind === "obsidian-ram-golem") {
|
||||
if (motionMode === "ram_charge_telegraph" || motionMode === "ram_charging") return config.attack;
|
||||
if (motionMode === "ram_quake") return config.special;
|
||||
if (motionMode === "ram_shatter") return "ArmorShatter";
|
||||
if (motionMode === "ram_recover") return "Stagger";
|
||||
}
|
||||
if (kind === "cinderback-ricochet") {
|
||||
if (motionMode === "cinderback_curl") return config.attack;
|
||||
if (motionMode === "cinderback_ricochet") return config.move;
|
||||
if (motionMode === "cinderback_slam") return config.special;
|
||||
if (motionMode === "cinderback_recover") return "Recover";
|
||||
}
|
||||
if (kind === "sandglass-scorpion") {
|
||||
if (motionMode === "sandglass_burrow_telegraph" || motionMode === "sandglass_burrowing") return config.move;
|
||||
if (motionMode === "sandglass_eruption") return config.attack;
|
||||
if (motionMode === "sandglass_hourglass") return config.special;
|
||||
if (motionMode === "sandglass_recover") return "Stagger";
|
||||
}
|
||||
if (kind === "cragclaw-crab") {
|
||||
if (motionMode === "crab_scuttling") return config.move;
|
||||
if (motionMode === "crab_scuttle_telegraph") return config.attack;
|
||||
if (motionMode === "crab_tidal_burst") return config.special;
|
||||
}
|
||||
if (kind === "mournveil-ghost") {
|
||||
if (motionMode === "ghost_soul_cross" || motionMode === "ghost_soul_cross_followup") return config.attack;
|
||||
if (motionMode === "ghost_haunting") return config.special;
|
||||
}
|
||||
if (kind === "crownshard-golem") {
|
||||
if (motionMode === "golem_shockwave") return config.attack;
|
||||
if (motionMode === "golem_crownfall") return config.special;
|
||||
}
|
||||
if (kind === "cindermaw") {
|
||||
if (motionMode === "skyfall") return config.move;
|
||||
if (motionMode === "breath_telegraph" || motionMode === "breath_sweeping") return config.special;
|
||||
}
|
||||
if (kind === "vexa" && (motionMode === "tethering" || motionMode === "venom_cast")) return config.attack;
|
||||
return config.idle;
|
||||
}
|
||||
|
||||
function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
|
||||
const config = ALTERNATE_BOSS_CONFIG[kind];
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
@@ -626,7 +861,7 @@ 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 gltf = useGLTF(config.url, false, true, kind === "vexa" ? configureSpiderLoader : undefined);
|
||||
const gltf = useGLTF(config.url, false, true);
|
||||
const model = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
|
||||
const { actions } = useAnimations(gltf.animations, model);
|
||||
const targetPosition = useMemo(() => new THREE.Vector3(), []);
|
||||
@@ -638,31 +873,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
object.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
if (kind === "vexa") {
|
||||
const authoredHelperBox = model.getObjectByName("Box");
|
||||
if (authoredHelperBox) authoredHelperBox.visible = false;
|
||||
}
|
||||
}, [kind, model]);
|
||||
|
||||
const clipName = phase === "victory" || defeated
|
||||
? config.death
|
||||
: kind === "ember-mantis-duelist"
|
||||
? motionMode === "mantis_sidestep"
|
||||
? config.move
|
||||
: motionMode === "mantis_line_telegraph"
|
||||
? config.attack
|
||||
: 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;
|
||||
const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motionMode);
|
||||
|
||||
useEffect(() => {
|
||||
const next = actions[clipName];
|
||||
@@ -674,8 +887,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
? 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) {
|
||||
const authoredOneShot = ![config.idle, config.move].includes(clipName as never) || kind === "ember-mantis-duelist" && clipName !== config.idle;
|
||||
if (phase === "victory" || defeated || authoredOneShot) {
|
||||
next.setLoop(THREE.LoopOnce, 1);
|
||||
next.clampWhenFinished = true;
|
||||
} else {
|
||||
@@ -691,7 +904,9 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
if (!current) return;
|
||||
const motion = current.motion;
|
||||
const airborne = kind === "cindermaw" && motion.mode === "skyfall";
|
||||
targetPosition.set(motion.position[0], airborne ? 3.2 : 0.03, motion.position[1]);
|
||||
const burrowed = kind === "sandglass-scorpion" && motion.mode === "sandglass_burrowing";
|
||||
const floatingHeight = kind === "mournveil-ghost" || kind === "crownshard-golem" ? 0.2 : 0.03;
|
||||
targetPosition.set(motion.position[0], airborne ? 3.2 : burrowed ? -0.58 : floatingHeight, motion.position[1]);
|
||||
group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
|
||||
|
||||
let targetAngle = Math.atan2(
|
||||
@@ -707,6 +922,8 @@ function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex
|
||||
)) {
|
||||
const target = state.partyPositions[motion.chargeTargetId];
|
||||
targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
||||
} else if (["ram_charge_telegraph", "ram_charging", "cinderback_curl", "cinderback_ricochet", "sandglass_burrow_telegraph", "sandglass_burrowing", "crab_scuttle_telegraph", "crab_scuttling"].includes(motion.mode)) {
|
||||
targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
|
||||
}
|
||||
const difference = Math.atan2(
|
||||
Math.sin(targetAngle - group.current.rotation.y),
|
||||
@@ -891,19 +1108,104 @@ function RangedProjectiles() {
|
||||
|
||||
function BossActor() {
|
||||
const phase = useGameStore((state) => state.phase);
|
||||
const primaryBoss = useGameStore((state) => state.boss);
|
||||
const additionalBosses = useGameStore((state) => state.additionalBosses);
|
||||
const primaryBossId = useGameStore((state) => state.boss.id);
|
||||
const additionalBossIds = useGameStore((state) => state.additionalBosses.map((entry) => entry.boss.id).join("|"));
|
||||
if (phase === "briefing") return null;
|
||||
const bosses = [primaryBoss, ...additionalBosses.map((entry) => entry.boss)];
|
||||
const bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId];
|
||||
return (
|
||||
<>{bosses.map((boss, bossIndex) => (
|
||||
<Suspense key={`${boss.id}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
|
||||
{boss.id === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={boss.id} bossIndex={bossIndex} />}
|
||||
<>{bossIds.map((bossId, bossIndex) => (
|
||||
<Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
|
||||
{bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
|
||||
</Suspense>
|
||||
))}</>
|
||||
);
|
||||
}
|
||||
|
||||
type PerformanceMemory = Performance & {
|
||||
memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
|
||||
};
|
||||
|
||||
function percentile(sorted: readonly number[], ratio: number) {
|
||||
if (!sorted.length) return 0;
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
|
||||
}
|
||||
|
||||
function PerformanceProbe() {
|
||||
const { gl } = useThree();
|
||||
const frameSamples = useRef<number[]>([]);
|
||||
const longTaskCount = useRef(0);
|
||||
const longTaskDuration = useRef(0);
|
||||
const lastPublishAt = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
|
||||
let observer: PerformanceObserver | undefined;
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
longTaskCount.current += 1;
|
||||
longTaskDuration.current += entry.duration;
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// Long Tasks API is optional on Android WebView implementations.
|
||||
}
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
delete document.documentElement.dataset.gamePerf;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useFrame(({ clock }, delta) => {
|
||||
if (!PERFORMANCE_PROBE_ENABLED) return;
|
||||
const samples = frameSamples.current;
|
||||
if (samples.length === 300) samples.shift();
|
||||
samples.push(delta * 1000);
|
||||
if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
|
||||
lastPublishAt.current = clock.elapsedTime;
|
||||
const sorted = [...samples].sort((left, right) => left - right);
|
||||
let total = 0;
|
||||
let overBudget = 0;
|
||||
for (const duration of samples) {
|
||||
total += duration;
|
||||
if (duration > 16.67) overBudget += 1;
|
||||
}
|
||||
const memory = performance as PerformanceMemory;
|
||||
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
||||
let transferredBytes = 0;
|
||||
let decodedBytes = 0;
|
||||
for (const resource of resources) {
|
||||
transferredBytes += resource.transferSize;
|
||||
decodedBytes += resource.decodedBodySize;
|
||||
}
|
||||
document.documentElement.dataset.gamePerf = JSON.stringify({
|
||||
frame: {
|
||||
averageMs: total / samples.length,
|
||||
p95Ms: percentile(sorted, 0.95),
|
||||
p99Ms: percentile(sorted, 0.99),
|
||||
overBudget,
|
||||
samples: samples.length,
|
||||
},
|
||||
renderer: {
|
||||
calls: gl.info.render.calls,
|
||||
triangles: gl.info.render.triangles,
|
||||
geometries: gl.info.memory.geometries,
|
||||
textures: gl.info.memory.textures,
|
||||
},
|
||||
simulation: simulationTickSnapshot(),
|
||||
memory: memory.memory ? {
|
||||
usedJSHeapSize: memory.memory.usedJSHeapSize,
|
||||
totalJSHeapSize: memory.memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
|
||||
} : null,
|
||||
resources: { transferredBytes, decodedBytes, count: resources.length },
|
||||
longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
|
||||
});
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
||||
const ring = useRef<THREE.Mesh>(null);
|
||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||
@@ -956,12 +1258,11 @@ export function GameScene() {
|
||||
<BossActor />
|
||||
<RangedProjectiles />
|
||||
<CombatFx />
|
||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user