Release v0.1.12 2026-07-13
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "pnpm assets:sync-basis-transcoder",
|
"predev": "pnpm assets:sync-basis-transcoder",
|
||||||
|
|||||||
+260
-19
@@ -25,6 +25,8 @@ import {
|
|||||||
type ActorAnimationState,
|
type ActorAnimationState,
|
||||||
} from "../game/actorAnimation";
|
} from "../game/actorAnimation";
|
||||||
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
|
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
|
||||||
|
import type { PartyAbilityId } from "../game/partyCombat";
|
||||||
|
import { partyAttackVfxProfile } from "../game/partyAttackVisuals";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import type { BossId, MemberId, PulseKind } from "../game/types";
|
import type { BossId, MemberId, PulseKind } from "../game/types";
|
||||||
import { BossRoom } from "./BossRoom";
|
import { BossRoom } from "./BossRoom";
|
||||||
@@ -999,7 +1001,15 @@ function TankAuraField() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
||||||
const group = useRef<THREE.Group>(null);
|
const projectile = useRef<THREE.Group>(null);
|
||||||
|
const impact = useRef<THREE.Group>(null);
|
||||||
|
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const accentMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const trail = useRef<THREE.Mesh>(null);
|
||||||
|
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const impactCoreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const lastAbilityId = useRef<PartyAbilityId | null>(null);
|
||||||
const start = useMemo(() => new THREE.Vector3(), []);
|
const start = useMemo(() => new THREE.Vector3(), []);
|
||||||
const end = useMemo(() => new THREE.Vector3(), []);
|
const end = useMemo(() => new THREE.Vector3(), []);
|
||||||
const current = useMemo(() => new THREE.Vector3(), []);
|
const current = useMemo(() => new THREE.Vector3(), []);
|
||||||
@@ -1007,7 +1017,7 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
|||||||
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
|
const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
useFrame(({ clock }) => {
|
||||||
if (!group.current) return;
|
if (!projectile.current || !impact.current) return;
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
const action = state.partyCombat.combatants[memberId].visualAction;
|
const action = state.partyCombat.combatants[memberId].visualAction;
|
||||||
const member = state.party.find((entry) => entry.id === memberId)!;
|
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||||
@@ -1018,8 +1028,19 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
|||||||
&& action.abilityId !== "overcharge"
|
&& action.abilityId !== "overcharge"
|
||||||
&& state.time >= action.startedAt
|
&& state.time >= action.startedAt
|
||||||
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
|
&& (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
|
||||||
group.current.visible = active;
|
projectile.current.visible = active;
|
||||||
if (!active || !action) return;
|
impact.current.visible = false;
|
||||||
|
if (!action || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return;
|
||||||
|
|
||||||
|
const profile = partyAttackVfxProfile(action.abilityId);
|
||||||
|
if (lastAbilityId.current !== action.abilityId) {
|
||||||
|
lastAbilityId.current = action.abilityId;
|
||||||
|
coreMaterial.current?.color.set(profile.primary);
|
||||||
|
accentMaterial.current?.color.set(profile.accent);
|
||||||
|
trailMaterial.current?.color.set(profile.primary);
|
||||||
|
impactMaterial.current?.color.set(profile.accent);
|
||||||
|
impactCoreMaterial.current?.color.set(profile.primary);
|
||||||
|
}
|
||||||
|
|
||||||
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
|
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
|
||||||
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
|
const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
|
||||||
@@ -1030,44 +1051,81 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
|
|||||||
const target = targetMotion.position;
|
const target = targetMotion.position;
|
||||||
start.set(source[0], 1.18, source[1]);
|
start.set(source[0], 1.18, source[1]);
|
||||||
end.set(target[0], 1.12, target[1]);
|
end.set(target[0], 1.12, target[1]);
|
||||||
|
if (active) {
|
||||||
current.copy(start).lerp(end, progress);
|
current.copy(start).lerp(end, progress);
|
||||||
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
|
current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
|
||||||
group.current.position.copy(current);
|
projectile.current.position.copy(current);
|
||||||
direction.subVectors(end, start).normalize();
|
direction.subVectors(end, start).normalize();
|
||||||
group.current.quaternion.setFromUnitVectors(up, direction);
|
projectile.current.quaternion.setFromUnitVectors(up, direction);
|
||||||
if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12);
|
const pulseScale = memberId === "orin" ? 1 + Math.sin(clock.elapsedTime * 14) * 0.12 : 1;
|
||||||
|
projectile.current.scale.setScalar(profile.scale * pulseScale);
|
||||||
|
trail.current?.scale.set(1, profile.trail, 1);
|
||||||
|
if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
const impactProgress = rapid
|
||||||
|
? progress > 0.72 ? (progress - 0.72) / 0.28 : -1
|
||||||
|
: (state.time - action.impactAt) / 0.3;
|
||||||
|
const impactVisible = impactProgress >= 0 && impactProgress <= 1 && state.time <= action.endsAt + 0.3;
|
||||||
|
impact.current.visible = impactVisible;
|
||||||
|
if (impactVisible) {
|
||||||
|
impact.current.position.copy(end);
|
||||||
|
impact.current.scale.setScalar(profile.scale * (0.45 + impactProgress * 2.15));
|
||||||
|
if (impactMaterial.current) impactMaterial.current.opacity = (1 - impactProgress) * 0.9;
|
||||||
|
if (impactCoreMaterial.current) impactCoreMaterial.current.opacity = (1 - impactProgress) * 0.72;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={group} visible={false}>
|
<>
|
||||||
|
<group ref={projectile} visible={false}>
|
||||||
{memberId === "nia" ? (
|
{memberId === "nia" ? (
|
||||||
<>
|
<>
|
||||||
<mesh>
|
<mesh>
|
||||||
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
|
<cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
|
||||||
<meshBasicMaterial color="#d7b477" />
|
<meshBasicMaterial ref={coreMaterial} color="#77d596" />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 0.5, 0]}>
|
<mesh position={[0, 0.5, 0]}>
|
||||||
<coneGeometry args={[0.085, 0.2, 6]} />
|
<coneGeometry args={[0.085, 0.2, 6]} />
|
||||||
<meshBasicMaterial color="#f1ddaa" />
|
<meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, -0.4, 0]}>
|
<mesh position={[0, -0.4, 0]}>
|
||||||
<coneGeometry args={[0.1, 0.18, 4]} />
|
<coneGeometry args={[0.1, 0.18, 4]} />
|
||||||
<meshBasicMaterial color="#70cf8e" />
|
<meshBasicMaterial color="#d7b477" />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={trail} position={[0, -0.62, 0]}>
|
||||||
|
<coneGeometry args={[0.12, 0.72, 6, 1, true]} />
|
||||||
|
<meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<mesh>
|
<mesh>
|
||||||
<sphereGeometry args={[0.19, 12, 10]} />
|
<sphereGeometry args={[0.19, 12, 10]} />
|
||||||
<meshBasicMaterial color="#bc8cff" />
|
<meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||||
<torusGeometry args={[0.25, 0.025, 6, 20]} />
|
<torusGeometry args={[0.25, 0.025, 6, 20]} />
|
||||||
<meshBasicMaterial color="#ead8ff" transparent opacity={0.8} />
|
<meshBasicMaterial ref={accentMaterial} color="#ead9ff" transparent opacity={0.8} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={trail} position={[0, -0.48, 0]}>
|
||||||
|
<coneGeometry args={[0.18, 0.95, 8, 1, true]} />
|
||||||
|
<meshBasicMaterial ref={trailMaterial} color="#a87cff" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</group>
|
</group>
|
||||||
|
<group ref={impact} visible={false}>
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.28, 0.45, 24]} />
|
||||||
|
<meshBasicMaterial ref={impactMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh>
|
||||||
|
<octahedronGeometry args={[0.24, 0]} />
|
||||||
|
<meshBasicMaterial ref={impactCoreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,6 +1138,144 @@ function RangedProjectiles() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const firstArc = useRef<THREE.Mesh>(null);
|
||||||
|
const secondArc = useRef<THREE.Mesh>(null);
|
||||||
|
const groundRing = useRef<THREE.Mesh>(null);
|
||||||
|
const core = useRef<THREE.Mesh>(null);
|
||||||
|
const primaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const secondaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const ringMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const lastAbilityId = useRef<PartyAbilityId | null>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!group.current || !firstArc.current || !secondArc.current || !groundRing.current || !core.current) return;
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
const actor = state.partyCombat.combatants[memberId];
|
||||||
|
const action = actor.visualAction;
|
||||||
|
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||||
|
const visible = action !== null
|
||||||
|
&& state.phase === "combat"
|
||||||
|
&& member.hp > 0
|
||||||
|
&& state.time >= action.startedAt
|
||||||
|
&& state.time <= action.endsAt + 0.18
|
||||||
|
&& partyAttackVfxProfile(action.abilityId).style !== "projectile";
|
||||||
|
group.current.visible = visible;
|
||||||
|
if (!visible || !action) return;
|
||||||
|
|
||||||
|
const profile = partyAttackVfxProfile(action.abilityId);
|
||||||
|
if (lastAbilityId.current !== action.abilityId) {
|
||||||
|
lastAbilityId.current = action.abilityId;
|
||||||
|
primaryMaterial.current?.color.set(profile.primary);
|
||||||
|
secondaryMaterial.current?.color.set(profile.accent);
|
||||||
|
ringMaterial.current?.color.set(profile.primary);
|
||||||
|
coreMaterial.current?.color.set(profile.accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Math.max(0.2, action.endsAt - action.startedAt);
|
||||||
|
const progress = THREE.MathUtils.clamp((state.time - action.startedAt) / duration, 0, 1);
|
||||||
|
const impactProgress = THREE.MathUtils.clamp((state.time - action.impactAt + 0.08) / 0.3, 0, 1);
|
||||||
|
const source = state.partyPositions[memberId];
|
||||||
|
const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
|
||||||
|
const target = targetMotion.position;
|
||||||
|
const sourceStyle = profile.style === "buff" || profile.style === "spin";
|
||||||
|
const effectHeight = sourceStyle ? 0.22 : profile.style === "slam" ? 0.18 : 1.05;
|
||||||
|
group.current.position.set(sourceStyle ? source[0] : target[0], effectHeight, sourceStyle ? source[1] : target[1]);
|
||||||
|
group.current.rotation.y = sourceStyle
|
||||||
|
? clock.elapsedTime * 0.8
|
||||||
|
: Math.atan2(target[0] - source[0], target[1] - source[1]);
|
||||||
|
|
||||||
|
const slashStyle = profile.style === "slash" || profile.style === "double-slash";
|
||||||
|
firstArc.current.visible = slashStyle;
|
||||||
|
secondArc.current.visible = profile.style === "double-slash";
|
||||||
|
groundRing.current.visible = profile.style === "slam" || profile.style === "spin" || profile.style === "buff";
|
||||||
|
core.current.visible = profile.style === "slam" || profile.style === "buff";
|
||||||
|
const actionScale = profile.scale * (0.65 + Math.sin(progress * Math.PI) * 0.75);
|
||||||
|
firstArc.current.scale.setScalar(actionScale);
|
||||||
|
firstArc.current.rotation.z = -Math.PI * (0.82 - progress * 0.34);
|
||||||
|
secondArc.current.scale.setScalar(actionScale * 0.92);
|
||||||
|
secondArc.current.rotation.z = -Math.PI * (0.15 + progress * 0.36);
|
||||||
|
const burstScale = profile.scale * (0.5 + impactProgress * 2.2);
|
||||||
|
groundRing.current.scale.setScalar(burstScale);
|
||||||
|
groundRing.current.rotation.z = clock.elapsedTime * (memberId === "vale" ? -1.6 : 0.85);
|
||||||
|
core.current.scale.setScalar(profile.scale * (0.6 + Math.sin(progress * Math.PI) * 1.1));
|
||||||
|
core.current.rotation.set(clock.elapsedTime, clock.elapsedTime * 1.4, 0);
|
||||||
|
if (primaryMaterial.current) primaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.9;
|
||||||
|
if (secondaryMaterial.current) secondaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.84;
|
||||||
|
if (ringMaterial.current) ringMaterial.current.opacity = (1 - impactProgress * 0.7) * 0.76;
|
||||||
|
if (coreMaterial.current) coreMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.72;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group} visible={false}>
|
||||||
|
<mesh ref={firstArc} rotation={[0, 0, -Math.PI * 0.72]}>
|
||||||
|
<torusGeometry args={[0.72, 0.055, 6, 28, Math.PI * 1.28]} />
|
||||||
|
<meshBasicMaterial ref={primaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={secondArc} position={[0.16, 0.02, 0.05]} rotation={[0, 0, -Math.PI * 0.2]}>
|
||||||
|
<torusGeometry args={[0.64, 0.045, 6, 26, Math.PI * 1.18]} />
|
||||||
|
<meshBasicMaterial ref={secondaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={groundRing} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.38, 0.54, 28]} />
|
||||||
|
<meshBasicMaterial ref={ringMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={core} position={[0, 0.3, 0]}>
|
||||||
|
<octahedronGeometry args={[0.26, 0]} />
|
||||||
|
<meshBasicMaterial ref={coreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!group.current) return;
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
const actor = state.partyCombat.combatants[memberId];
|
||||||
|
const member = state.party.find((entry) => entry.id === memberId)!;
|
||||||
|
const active = state.phase === "combat" && member.hp > 0 && (memberId === "orin" ? actor.overchargeStacks > 0 : actor.bladeFlurryUntil > state.time);
|
||||||
|
group.current.visible = active;
|
||||||
|
if (!active) return;
|
||||||
|
const position = state.partyPositions[memberId];
|
||||||
|
group.current.position.set(position[0], 0.16, position[1]);
|
||||||
|
group.current.rotation.y = clock.elapsedTime * (memberId === "orin" ? 1.4 : -1.8);
|
||||||
|
const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12;
|
||||||
|
group.current.scale.setScalar(pulse);
|
||||||
|
if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1;
|
||||||
|
});
|
||||||
|
const color = memberId === "orin" ? "#bc72ff" : "#9a86ff";
|
||||||
|
return (
|
||||||
|
<group ref={group} visible={false}>
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[0.7, 0.035, 6, 28]} />
|
||||||
|
<meshBasicMaterial ref={material} color={color} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.72, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||||
|
<torusGeometry args={[0.46, 0.025, 6, 24]} />
|
||||||
|
<meshBasicMaterial color={color} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartyCombatVfx() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RangedProjectiles />
|
||||||
|
<CloseAttackVfx memberId="brann" />
|
||||||
|
<CloseAttackVfx memberId="orin" />
|
||||||
|
<CloseAttackVfx memberId="vale" />
|
||||||
|
<PartyPowerAuraVfx memberId="orin" />
|
||||||
|
<PartyPowerAuraVfx memberId="vale" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BossActor() {
|
function BossActor() {
|
||||||
const phase = useGameStore((state) => state.phase);
|
const phase = useGameStore((state) => state.phase);
|
||||||
const primaryBossId = useGameStore((state) => state.boss.id);
|
const primaryBossId = useGameStore((state) => state.boss.id);
|
||||||
@@ -1185,27 +1381,72 @@ function PerformanceProbe() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FX_BURST_PARTICLE_COUNT = 8;
|
||||||
|
|
||||||
|
function scenePulseColor(kind: PulseKind) {
|
||||||
|
if (kind === "shield") return "#62bdff";
|
||||||
|
if (kind === "purify") return "#c39bff";
|
||||||
|
if (kind === "renew") return "#72e0a1";
|
||||||
|
if (kind === "breath") return "#66dcff";
|
||||||
|
if (kind === "venom") return "#8fdb4f";
|
||||||
|
if (kind === "tether") return "#d482ff";
|
||||||
|
if (kind === "skyfall") return "#ffd36b";
|
||||||
|
if (kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash") return "#ff643c";
|
||||||
|
return "#ffe087";
|
||||||
|
}
|
||||||
|
|
||||||
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
const ring = useRef<THREE.Mesh>(null);
|
const ring = useRef<THREE.Mesh>(null);
|
||||||
const material = useRef<THREE.MeshBasicMaterial>(null);
|
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const particles = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const core = useRef<THREE.Mesh>(null);
|
||||||
|
const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||||
const age = useRef(0);
|
const age = useRef(0);
|
||||||
const isBossFx = kind === "boss";
|
const isBossFx = kind === "boss";
|
||||||
const state = useGameStore.getState();
|
const state = useGameStore.getState();
|
||||||
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
|
const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
|
||||||
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
|
const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
|
||||||
const color = kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087";
|
const color = scenePulseColor(kind);
|
||||||
useFrame((_, delta) => {
|
useFrame(({ clock }, delta) => {
|
||||||
age.current += delta;
|
age.current += delta;
|
||||||
if (!ring.current || !material.current) return;
|
if (!group.current || !ring.current || !material.current || !particles.current || !core.current) return;
|
||||||
const progress = Math.min(1, age.current / 0.7);
|
const progress = Math.min(1, age.current / 0.7);
|
||||||
ring.current.scale.setScalar(0.5 + progress * 3.6);
|
ring.current.scale.setScalar(0.5 + progress * 3.6);
|
||||||
material.current.opacity = (1 - progress) * 0.85;
|
material.current.opacity = (1 - progress) * 0.85;
|
||||||
|
core.current.scale.setScalar(0.45 + Math.sin(progress * Math.PI) * 1.8);
|
||||||
|
core.current.rotation.set(clock.elapsedTime * 1.5, clock.elapsedTime * 2, 0);
|
||||||
|
if (coreMaterial.current) coreMaterial.current.opacity = (1 - progress) * 0.72;
|
||||||
|
for (let index = 0; index < FX_BURST_PARTICLE_COUNT; index += 1) {
|
||||||
|
const angle = index / FX_BURST_PARTICLE_COUNT * Math.PI * 2 + clock.elapsedTime * 0.6;
|
||||||
|
const radial = progress * (kind === "boss" ? 3.2 : 1.65);
|
||||||
|
const scale = (1 - progress) * (kind === "boss" ? 1.4 : 0.9);
|
||||||
|
transform.position.set(Math.sin(angle) * radial, 0.18 + Math.sin(progress * Math.PI) * (0.7 + (index % 2) * 0.45), Math.cos(angle) * radial);
|
||||||
|
transform.rotation.set(angle, progress * Math.PI * 2 + index, clock.elapsedTime);
|
||||||
|
transform.scale.setScalar(scale);
|
||||||
|
transform.updateMatrix();
|
||||||
|
particles.current.setMatrixAt(index, transform.matrix);
|
||||||
|
}
|
||||||
|
particles.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (particleMaterial.current) particleMaterial.current.opacity = (1 - progress) * 0.82;
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<mesh ref={ring} position={position} rotation={[-Math.PI / 2, 0, 0]}>
|
<group ref={group} position={position}>
|
||||||
|
<mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<ringGeometry args={[0.38, 0.5, 32]} />
|
<ringGeometry args={[0.38, 0.5, 32]} />
|
||||||
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} />
|
<meshBasicMaterial ref={material} color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
<mesh ref={core} position={[0, 0.42, 0]}>
|
||||||
|
<octahedronGeometry args={[0.24, 0]} />
|
||||||
|
<meshBasicMaterial ref={coreMaterial} color={color} transparent opacity={0.72} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</mesh>
|
||||||
|
<instancedMesh ref={particles} args={[undefined, undefined, FX_BURST_PARTICLE_COUNT]} frustumCulled={false}>
|
||||||
|
<tetrahedronGeometry args={[0.18, 0]} />
|
||||||
|
<meshBasicMaterial ref={particleMaterial} color={color} transparent opacity={0.82} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
|
||||||
|
</instancedMesh>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,7 +1478,7 @@ export function GameScene() {
|
|||||||
<TankAuraField />
|
<TankAuraField />
|
||||||
<Party />
|
<Party />
|
||||||
<BossActor />
|
<BossActor />
|
||||||
<RangedProjectiles />
|
<PartyCombatVfx />
|
||||||
<CombatFx />
|
<CombatFx />
|
||||||
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
{PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
|
||||||
</GameAssetProvider>
|
</GameAssetProvider>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { PoolTelegraphKind, WorldPosition } from "../../game/types";
|
||||||
|
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const LANE_PARTICLE_COUNT = 7;
|
||||||
|
const AREA_PARTICLE_COUNT = 8;
|
||||||
|
const BREATH_PARTICLE_COUNT = 12;
|
||||||
|
|
||||||
|
function useReducedMotion() {
|
||||||
|
return useMemo(() => (
|
||||||
|
document.documentElement.classList.contains("force-reduced-motion")
|
||||||
|
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LaneVfxVariant = "beam" | "charge" | "slash" | "web";
|
||||||
|
|
||||||
|
function LaneParticleGeometry({ variant }: { variant: LaneVfxVariant }) {
|
||||||
|
if (variant === "beam" || variant === "web") return <sphereGeometry args={[0.11, 7, 5]} />;
|
||||||
|
if (variant === "slash") return <octahedronGeometry args={[0.15, 0]} />;
|
||||||
|
return <tetrahedronGeometry args={[0.13, 0]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One instanced draw for motion/readability layered over an existing lane telegraph. */
|
||||||
|
export function LaneEnergyVfx({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
width,
|
||||||
|
color,
|
||||||
|
active,
|
||||||
|
variant,
|
||||||
|
height = 0.13,
|
||||||
|
}: {
|
||||||
|
start: WorldPosition;
|
||||||
|
end: WorldPosition;
|
||||||
|
width: number;
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
variant: LaneVfxVariant;
|
||||||
|
height?: number;
|
||||||
|
}) {
|
||||||
|
const particles = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const dx = end[0] - start[0];
|
||||||
|
const dz = end[1] - start[1];
|
||||||
|
const length = Math.hypot(dx, dz);
|
||||||
|
const angle = Math.atan2(dx, dz);
|
||||||
|
const midpoint: [number, number, number] = [(start[0] + end[0]) * 0.5, height, (start[1] + end[1]) * 0.5];
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!particles.current) return;
|
||||||
|
const time = reducedMotion ? 0.35 : clock.elapsedTime;
|
||||||
|
const speed = active ? 2.6 : variant === "web" ? 0.5 : 0.85;
|
||||||
|
for (let index = 0; index < LANE_PARTICLE_COUNT; index += 1) {
|
||||||
|
const offset = index / LANE_PARTICLE_COUNT;
|
||||||
|
const cycle = (time * speed + offset) % 1;
|
||||||
|
const cross = Math.sin(time * 2.4 + index * 1.7) * width * (variant === "web" ? 0.08 : 0.28);
|
||||||
|
const rise = variant === "slash"
|
||||||
|
? Math.sin(cycle * Math.PI) * (active ? 0.65 : 0.28)
|
||||||
|
: variant === "web" ? Math.sin(time * 3 + index) * 0.07 : Math.sin(cycle * Math.PI) * 0.18;
|
||||||
|
transform.position.set(cross, rise, -length * 0.5 + cycle * length);
|
||||||
|
transform.rotation.set(time * 0.8 + index, index * 0.63, active ? time * 1.5 : 0);
|
||||||
|
const scale = (active ? 1.25 : 0.78) * (0.75 + Math.sin(cycle * Math.PI) * 0.4);
|
||||||
|
transform.scale.set(
|
||||||
|
variant === "slash" ? scale * 0.7 : scale,
|
||||||
|
variant === "charge" ? scale * 0.55 : scale,
|
||||||
|
variant === "charge" ? scale * 2.1 : scale,
|
||||||
|
);
|
||||||
|
transform.updateMatrix();
|
||||||
|
particles.current.setMatrixAt(index, transform.matrix);
|
||||||
|
}
|
||||||
|
particles.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (material.current) {
|
||||||
|
const pulse = reducedMotion ? 0.65 : 0.58 + Math.sin(time * 6.2) * 0.14;
|
||||||
|
material.current.opacity = active ? pulse + 0.22 : pulse * 0.62;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||||
|
<instancedMesh ref={particles} args={[undefined, undefined, LANE_PARTICLE_COUNT]} frustumCulled={false}>
|
||||||
|
<LaneParticleGeometry variant={variant} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={material}
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={active ? 0.82 : 0.38}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BreathParticleVfx({
|
||||||
|
position,
|
||||||
|
angle,
|
||||||
|
range,
|
||||||
|
halfAngle,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
position: WorldPosition;
|
||||||
|
angle: number;
|
||||||
|
range: number;
|
||||||
|
halfAngle: number;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
const particles = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!particles.current) return;
|
||||||
|
const time = reducedMotion ? 0.4 : clock.elapsedTime;
|
||||||
|
for (let index = 0; index < BREATH_PARTICLE_COUNT; index += 1) {
|
||||||
|
const row = index % 4;
|
||||||
|
const lane = Math.floor(index / 4);
|
||||||
|
const offsetAngle = -halfAngle + (lane / 2) * halfAngle * 2;
|
||||||
|
const cycle = (time * (active ? 1.7 : 0.52) + row * 0.24 + lane * 0.08) % 1;
|
||||||
|
const distance = range * (0.08 + cycle * 0.9);
|
||||||
|
const scale = (active ? 1 : 0.55) * (1.15 - cycle * 0.55);
|
||||||
|
transform.position.set(
|
||||||
|
Math.sin(offsetAngle) * distance,
|
||||||
|
0.18 + Math.sin(cycle * Math.PI) * (active ? 0.72 : 0.32),
|
||||||
|
Math.cos(offsetAngle) * distance,
|
||||||
|
);
|
||||||
|
transform.rotation.set(time * 1.2 + index, -offsetAngle, cycle * TAU);
|
||||||
|
transform.scale.set(scale * 0.72, scale * 1.45, scale * 0.72);
|
||||||
|
transform.updateMatrix();
|
||||||
|
particles.current.setMatrixAt(index, transform.matrix);
|
||||||
|
}
|
||||||
|
particles.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (material.current) material.current.opacity = active ? 0.74 : 0.26;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[position[0], 0.1, position[1]]} rotation={[0, angle, 0]}>
|
||||||
|
<instancedMesh ref={particles} args={[undefined, undefined, BREATH_PARTICLE_COUNT]} frustumCulled={false}>
|
||||||
|
<tetrahedronGeometry args={[0.2, 0]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={material}
|
||||||
|
color={active ? "#7ceaff" : "#b8f4ff"}
|
||||||
|
transparent
|
||||||
|
opacity={active ? 0.74 : 0.26}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CircleMechanicVfx({
|
||||||
|
radius,
|
||||||
|
innerRadius = 0,
|
||||||
|
kind,
|
||||||
|
color,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
radius: number;
|
||||||
|
innerRadius?: number;
|
||||||
|
kind: Extract<PoolTelegraphKind, "donut" | "soak" | "spread"> | "pounce";
|
||||||
|
color: string;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
const particles = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const material = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const transform = useMemo(() => new THREE.Object3D(), []);
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!particles.current) return;
|
||||||
|
const time = reducedMotion ? 0.3 : clock.elapsedTime;
|
||||||
|
for (let index = 0; index < AREA_PARTICLE_COUNT; index += 1) {
|
||||||
|
const offset = index / AREA_PARTICLE_COUNT;
|
||||||
|
const cycle = (time * (active ? 1.2 : 0.55) + offset) % 1;
|
||||||
|
const orbit = offset * TAU + time * (kind === "soak" ? -0.55 : 0.7);
|
||||||
|
let radial = radius * 0.82;
|
||||||
|
if (kind === "spread") radial = radius * (0.18 + cycle * 0.75);
|
||||||
|
if (kind === "soak" || kind === "pounce") radial = radius * (0.92 - cycle * 0.68);
|
||||||
|
if (kind === "donut") radial = index % 2 === 0 ? Math.max(0.2, innerRadius + 0.18) : radius - 0.18;
|
||||||
|
const scale = (active ? 1 : 0.64) * (0.72 + Math.sin(cycle * Math.PI) * 0.48);
|
||||||
|
transform.position.set(Math.sin(orbit) * radial, 0.12 + Math.sin(cycle * Math.PI) * 0.52, Math.cos(orbit) * radial);
|
||||||
|
transform.rotation.set(time + index, orbit, cycle * TAU);
|
||||||
|
transform.scale.setScalar(scale);
|
||||||
|
transform.updateMatrix();
|
||||||
|
particles.current.setMatrixAt(index, transform.matrix);
|
||||||
|
}
|
||||||
|
particles.current.instanceMatrix.needsUpdate = true;
|
||||||
|
if (material.current) material.current.opacity = active ? 0.84 : 0.42;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<instancedMesh ref={particles} args={[undefined, undefined, AREA_PARTICLE_COUNT]} frustumCulled={false}>
|
||||||
|
<octahedronGeometry args={[0.16, 0]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={material}
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={active ? 0.84 : 0.42}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</instancedMesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { bossHazardVfxProfile } from "../../game/bossHazardVisuals";
|
|||||||
import { angleTo } from "../../game/geometry";
|
import { angleTo } from "../../game/geometry";
|
||||||
import { useGameStore } from "../../game/store";
|
import { useGameStore } from "../../game/store";
|
||||||
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
|
import type { BossMotionMode, BossMotionState, MemorySymbolId, MemoryTile, PoolTelegraph, SoulSiphonState, WorldPosition } from "../../game/types";
|
||||||
|
import { BreathParticleVfx, CircleMechanicVfx, LaneEnergyVfx } from "./BossAttackVfx";
|
||||||
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
|
import { BOSS_INDICATOR_DEATH_FADE_MS, advanceBossIndicatorOpacity } from "./bossDeathVisuals";
|
||||||
import { CircleHazardVfx } from "./CircleHazardVfx";
|
import { CircleHazardVfx } from "./CircleHazardVfx";
|
||||||
|
|
||||||
@@ -43,6 +44,10 @@ function bossHpAt(state: GameStoreState, bossIndex: number) {
|
|||||||
return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0;
|
return bossIndex === 0 ? state.boss.hp : state.additionalBosses[bossIndex - 1]?.boss.hp ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bossAt(state: GameStoreState, bossIndex: number) {
|
||||||
|
return bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss;
|
||||||
|
}
|
||||||
|
|
||||||
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
|
function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) {
|
||||||
if (!motion) return current;
|
if (!motion) return current;
|
||||||
let earliest = current;
|
let earliest = current;
|
||||||
@@ -194,8 +199,10 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
|
|||||||
(lane.start[1] + lane.end[1]) * 0.5,
|
(lane.start[1] + lane.end[1]) * 0.5,
|
||||||
];
|
];
|
||||||
const active = ACTIVE_LANE_MODES.has(motion.mode);
|
const active = ACTIVE_LANE_MODES.has(motion.mode);
|
||||||
|
const chargeEffect = motion.mode === "telegraph" || motion.mode === "charging";
|
||||||
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
const color = active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<group position={midpoint} rotation={[0, angle, 0]}>
|
<group position={midpoint} rotation={[0, angle, 0]}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<planeGeometry args={[lane.width, length]} />
|
<planeGeometry args={[lane.width, length]} />
|
||||||
@@ -214,6 +221,15 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex:
|
|||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
</group>
|
</group>
|
||||||
|
<LaneEnergyVfx
|
||||||
|
start={lane.start}
|
||||||
|
end={lane.end}
|
||||||
|
width={lane.width}
|
||||||
|
color={active ? DANGER_HIGHLIGHT_COLOR : color}
|
||||||
|
active={active}
|
||||||
|
variant={chargeEffect ? "charge" : "slash"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +250,10 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
|
|||||||
if (!warningMaterial.current) return;
|
if (!warningMaterial.current) return;
|
||||||
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
|
warningMaterial.current.opacity = 0.14 + (Math.sin(clock.elapsedTime * 8) + 1) * 0.08;
|
||||||
});
|
});
|
||||||
if (phase !== "combat" || motionMode !== "stacking") return null;
|
if (phase !== "combat" || (motionMode !== "stacking" && motionMode !== "pouncing")) return null;
|
||||||
|
|
||||||
const radius = BULL_POUNCE.stackRadius;
|
const radius = BULL_POUNCE.stackRadius;
|
||||||
|
const active = motionMode === "pouncing";
|
||||||
return (
|
return (
|
||||||
<group ref={group}>
|
<group ref={group}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
@@ -263,6 +280,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number })
|
|||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
<CircleMechanicVfx radius={radius} kind="pounce" color={active ? "#ffe0a8" : "#ff7278"} active={active} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,6 +311,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
|||||||
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
<meshBasicMaterial color={color} transparent opacity={0.92} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
|
<LaneEnergyVfx start={first} end={second} width={0.34} color={color} active={stretched} variant="web" height={0.2} />
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -307,6 +326,7 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
|||||||
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
|
if (!motion || phase !== "combat" || (motion.mode !== "breath_telegraph" && motion.mode !== "breath_sweeping")) return null;
|
||||||
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
const color = motion.mode === "breath_sweeping" ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
<group position={[motion.position[0], 0.07, motion.position[1]]} rotation={[0, motion.breathAngle, 0]}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
<mesh rotation={[-Math.PI / 2, 0, -Math.PI / 2 - SKY_SWEEPER_BREATH.halfAngle]}>
|
||||||
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
<circleGeometry args={[SKY_SWEEPER_BREATH.range, 64, 0, SKY_SWEEPER_BREATH.halfAngle * 2]} />
|
||||||
@@ -317,6 +337,14 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) {
|
|||||||
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
<meshBasicMaterial color={color} transparent opacity={0.85} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
<BreathParticleVfx
|
||||||
|
position={motion.position}
|
||||||
|
angle={motion.breathAngle}
|
||||||
|
range={SKY_SWEEPER_BREATH.range}
|
||||||
|
halfAngle={SKY_SWEEPER_BREATH.halfAngle}
|
||||||
|
active={motion.mode === "breath_sweeping"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,12 +659,14 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
|
|||||||
: telegraph.kind === "spread" ? "#e869ff"
|
: telegraph.kind === "spread" ? "#e869ff"
|
||||||
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
: active ? DANGER_ACTIVE_COLOR : DANGER_WARNING_COLOR;
|
||||||
|
|
||||||
if (telegraph.kind === "beam" && telegraph.start && telegraph.end) {
|
if (telegraph.kind === "beam") {
|
||||||
|
if (!telegraph.start || !telegraph.end) return null;
|
||||||
const dx = telegraph.end[0] - telegraph.start[0];
|
const dx = telegraph.end[0] - telegraph.start[0];
|
||||||
const dz = telegraph.end[1] - telegraph.start[1];
|
const dz = telegraph.end[1] - telegraph.start[1];
|
||||||
const length = Math.hypot(dx, dz);
|
const length = Math.hypot(dx, dz);
|
||||||
const angle = Math.atan2(dx, dz);
|
const angle = Math.atan2(dx, dz);
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
<group position={[(telegraph.start[0] + telegraph.end[0]) * 0.5, 0.07, (telegraph.start[1] + telegraph.end[1]) * 0.5]} rotation={[0, angle, 0]}>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
<planeGeometry args={[telegraph.width ?? 1, length]} />
|
||||||
@@ -655,6 +685,15 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
|
|||||||
</mesh>
|
</mesh>
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
<LaneEnergyVfx
|
||||||
|
start={telegraph.start}
|
||||||
|
end={telegraph.end}
|
||||||
|
width={telegraph.width ?? 1}
|
||||||
|
color={active ? "#ffe1b3" : color}
|
||||||
|
active={active}
|
||||||
|
variant="beam"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +736,13 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str
|
|||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
))}
|
))}
|
||||||
|
<CircleMechanicVfx
|
||||||
|
radius={telegraph.radius}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
kind={telegraph.kind}
|
||||||
|
color={color}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -706,7 +752,60 @@ export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number
|
|||||||
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
|
return <>{telegraphs.map((telegraph) => <PooledTelegraphIndicator key={telegraph.id} telegraphId={telegraph.id} bossIndex={bossIndex} />)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BasicMeleeVfx({ bossIndex = 0 }: { bossIndex?: number }) {
|
||||||
|
const group = useRef<THREE.Group>(null);
|
||||||
|
const slashMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
|
||||||
|
const initialBoss = bossAt(useGameStore.getState(), bossIndex);
|
||||||
|
const previousNextMeleeAt = useRef(initialBoss?.nextMeleeAt ?? 0);
|
||||||
|
const age = useRef(Number.POSITIVE_INFINITY);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
const state = useGameStore.getState();
|
||||||
|
const boss = bossAt(state, bossIndex);
|
||||||
|
const motion = motionAt(state, bossIndex);
|
||||||
|
if (!group.current || !boss || !motion) return;
|
||||||
|
if (boss.nextMeleeAt !== previousNextMeleeAt.current) {
|
||||||
|
if (state.phase === "combat" && motion.mode === "holding") age.current = 0;
|
||||||
|
previousNextMeleeAt.current = boss.nextMeleeAt;
|
||||||
|
}
|
||||||
|
age.current += delta;
|
||||||
|
const visible = state.phase === "combat" && age.current < 0.42 && state.party[1].hp > 0;
|
||||||
|
group.current.visible = visible;
|
||||||
|
if (!visible) return;
|
||||||
|
const target = state.partyPositions.brann;
|
||||||
|
const progress = THREE.MathUtils.clamp(age.current / 0.42, 0, 1);
|
||||||
|
group.current.position.set(target[0], 1.02, target[1]);
|
||||||
|
group.current.rotation.y = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
|
||||||
|
group.current.scale.setScalar(0.68 + progress * 0.72);
|
||||||
|
if (slashMaterial.current) slashMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.92;
|
||||||
|
if (impactMaterial.current) impactMaterial.current.opacity = (1 - progress) * 0.68;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group} visible={false}>
|
||||||
|
<mesh rotation={[0, 0, -Math.PI * 0.72]}>
|
||||||
|
<torusGeometry args={[0.7, 0.055, 6, 28, Math.PI * 1.35]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
ref={slashMaterial}
|
||||||
|
color="#ffd58a"
|
||||||
|
transparent
|
||||||
|
opacity={0}
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
toneMapped={false}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.94, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.32, 0.5, 24]} />
|
||||||
|
<meshBasicMaterial ref={impactMaterial} color="#ff8d5c" transparent opacity={0} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [
|
||||||
|
BasicMeleeVfx,
|
||||||
ChargeLaneIndicator,
|
ChargeLaneIndicator,
|
||||||
SlashLaneIndicators,
|
SlashLaneIndicators,
|
||||||
PounceStackIndicator,
|
PounceStackIndicator,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PARTY_ABILITY_LOADOUTS, PARTY_ABILITY_NAMES } from "./partyCombat";
|
||||||
|
import { PARTY_ATTACK_VFX, partyAttackVfxProfile } from "./partyAttackVisuals";
|
||||||
|
|
||||||
|
describe("party attack visuals", () => {
|
||||||
|
it("covers every party combat ability", () => {
|
||||||
|
expect(Object.keys(PARTY_ATTACK_VFX).sort()).toEqual(Object.keys(PARTY_ABILITY_NAMES).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every profile attached to its loadout owner", () => {
|
||||||
|
for (const [ownerId, abilityIds] of Object.entries(PARTY_ABILITY_LOADOUTS)) {
|
||||||
|
for (const abilityId of abilityIds) {
|
||||||
|
expect(partyAttackVfxProfile(abilityId).ownerId).toBe(ownerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every ranged attack a visible trail and every melee loadout a non-projectile style", () => {
|
||||||
|
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.nia, ...PARTY_ABILITY_LOADOUTS.orin]) {
|
||||||
|
const profile = partyAttackVfxProfile(abilityId);
|
||||||
|
if (profile.style === "buff") continue;
|
||||||
|
expect(profile.style).toBe("projectile");
|
||||||
|
expect(profile.trail).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
for (const abilityId of [...PARTY_ABILITY_LOADOUTS.brann, ...PARTY_ABILITY_LOADOUTS.vale]) {
|
||||||
|
expect(partyAttackVfxProfile(abilityId).style).not.toBe("projectile");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { AiCombatantId, PartyAbilityId } from "./partyCombat";
|
||||||
|
|
||||||
|
export type PartyAttackVfxStyle = "buff" | "double-slash" | "projectile" | "slam" | "slash" | "spin";
|
||||||
|
|
||||||
|
export interface PartyAttackVfxProfile {
|
||||||
|
readonly ownerId: AiCombatantId;
|
||||||
|
readonly style: PartyAttackVfxStyle;
|
||||||
|
readonly primary: string;
|
||||||
|
readonly accent: string;
|
||||||
|
readonly scale: number;
|
||||||
|
readonly trail: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visual identity only. Damage, timing, targeting, and resources stay in partyCombat. */
|
||||||
|
export const PARTY_ATTACK_VFX: Record<PartyAbilityId, PartyAttackVfxProfile> = {
|
||||||
|
sword_slash: { ownerId: "brann", style: "slash", primary: "#f0c56b", accent: "#fff0b0", scale: 0.9, trail: 0 },
|
||||||
|
shield_slam: { ownerId: "brann", style: "slam", primary: "#62b9ff", accent: "#d9f2ff", scale: 1.05, trail: 0 },
|
||||||
|
revenge: { ownerId: "brann", style: "double-slash", primary: "#ef6c4d", accent: "#ffd0a5", scale: 1.05, trail: 0 },
|
||||||
|
sweeping_guard: { ownerId: "brann", style: "spin", primary: "#77d7ff", accent: "#e5f8ff", scale: 1.15, trail: 0 },
|
||||||
|
bulwark_march: { ownerId: "brann", style: "buff", primary: "#4da8ff", accent: "#d5efff", scale: 1.25, trail: 0 },
|
||||||
|
|
||||||
|
quick_shot: { ownerId: "nia", style: "projectile", primary: "#77d596", accent: "#e5ffb8", scale: 0.78, trail: 0.45 },
|
||||||
|
aimed_shot: { ownerId: "nia", style: "projectile", primary: "#f0c961", accent: "#fff4be", scale: 1, trail: 0.7 },
|
||||||
|
rapid_fire: { ownerId: "nia", style: "projectile", primary: "#65dcba", accent: "#d8fff1", scale: 0.72, trail: 0.38 },
|
||||||
|
kill_shot: { ownerId: "nia", style: "projectile", primary: "#f05d57", accent: "#ffd3a3", scale: 1.18, trail: 0.9 },
|
||||||
|
deadeye: { ownerId: "nia", style: "projectile", primary: "#fff0a8", accent: "#ffffff", scale: 1.35, trail: 1.15 },
|
||||||
|
|
||||||
|
arcane_bolt: { ownerId: "orin", style: "projectile", primary: "#a87cff", accent: "#ead9ff", scale: 0.9, trail: 0.75 },
|
||||||
|
ember_lance: { ownerId: "orin", style: "projectile", primary: "#ff713d", accent: "#ffd080", scale: 0.92, trail: 0.9 },
|
||||||
|
arcane_burst: { ownerId: "orin", style: "projectile", primary: "#5d9fff", accent: "#dcf3ff", scale: 1.25, trail: 1.05 },
|
||||||
|
comet: { ownerId: "orin", style: "projectile", primary: "#7ce5ff", accent: "#f0fdff", scale: 1.55, trail: 1.35 },
|
||||||
|
overcharge: { ownerId: "orin", style: "buff", primary: "#b15cff", accent: "#f0d6ff", scale: 1.15, trail: 0 },
|
||||||
|
|
||||||
|
quick_cut: { ownerId: "vale", style: "slash", primary: "#c68aff", accent: "#f1ddff", scale: 0.78, trail: 0 },
|
||||||
|
twin_fang: { ownerId: "vale", style: "double-slash", primary: "#e36cff", accent: "#ffd5ff", scale: 0.92, trail: 0 },
|
||||||
|
backstab: { ownerId: "vale", style: "slash", primary: "#f15b7a", accent: "#ffd4df", scale: 1.12, trail: 0 },
|
||||||
|
fan_of_blades: { ownerId: "vale", style: "spin", primary: "#c9d5df", accent: "#ffffff", scale: 1.2, trail: 0 },
|
||||||
|
blade_flurry: { ownerId: "vale", style: "buff", primary: "#8a7cff", accent: "#e8e3ff", scale: 1.15, trail: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function partyAttackVfxProfile(abilityId: PartyAbilityId) {
|
||||||
|
return PARTY_ATTACK_VFX[abilityId];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user