diff --git a/package.json b/package.json index cadcc34..e13feb4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.11", + "version": "0.1.12", "type": "module", "scripts": { "predev": "pnpm assets:sync-basis-transcoder", diff --git a/src/components/GameScene.tsx b/src/components/GameScene.tsx index d019053..822c42f 100644 --- a/src/components/GameScene.tsx +++ b/src/components/GameScene.tsx @@ -25,6 +25,8 @@ import { type ActorAnimationState, } from "../game/actorAnimation"; 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 type { BossId, MemberId, PulseKind } from "../game/types"; import { BossRoom } from "./BossRoom"; @@ -999,7 +1001,15 @@ function TankAuraField() { } function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { - const group = useRef(null); + const projectile = useRef(null); + const impact = useRef(null); + const coreMaterial = useRef(null); + const accentMaterial = useRef(null); + const trailMaterial = useRef(null); + const trail = useRef(null); + const impactMaterial = useRef(null); + const impactCoreMaterial = useRef(null); + const lastAbilityId = useRef(null); const start = useMemo(() => new THREE.Vector3(), []); const end = 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), []); useFrame(({ clock }) => { - if (!group.current) return; + if (!projectile.current || !impact.current) return; const state = useGameStore.getState(); const action = state.partyCombat.combatants[memberId].visualAction; const member = state.party.find((entry) => entry.id === memberId)!; @@ -1018,8 +1028,19 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { && action.abilityId !== "overcharge" && state.time >= action.startedAt && (rapid ? state.time <= action.endsAt : state.time <= action.impactAt); - group.current.visible = active; - if (!active || !action) return; + projectile.current.visible = active; + 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 projectileDuration = Math.max(0.12, action.impactAt - action.startedAt); @@ -1030,44 +1051,81 @@ function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) { const target = targetMotion.position; start.set(source[0], 1.18, source[1]); end.set(target[0], 1.12, target[1]); - current.copy(start).lerp(end, progress); - current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34); - group.current.position.copy(current); - direction.subVectors(end, start).normalize(); - group.current.quaternion.setFromUnitVectors(up, direction); - if (memberId === "orin") group.current.scale.setScalar(0.9 + Math.sin(clock.elapsedTime * 14) * 0.12); + if (active) { + current.copy(start).lerp(end, progress); + current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34); + projectile.current.position.copy(current); + direction.subVectors(end, start).normalize(); + projectile.current.quaternion.setFromUnitVectors(up, direction); + 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 ( - - {memberId === "nia" ? ( - <> - - - - - - - - - - - - - - ) : ( - <> - - - - - - - - - - )} - + <> + + {memberId === "nia" ? ( + <> + + + + + + + + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + )} + + + + + + + + + + + + ); } @@ -1080,6 +1138,144 @@ function RangedProjectiles() { ); } +function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) { + const group = useRef(null); + const firstArc = useRef(null); + const secondArc = useRef(null); + const groundRing = useRef(null); + const core = useRef(null); + const primaryMaterial = useRef(null); + const secondaryMaterial = useRef(null); + const ringMaterial = useRef(null); + const coreMaterial = useRef(null); + const lastAbilityId = useRef(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 ( + + + + + + + + + + + + + + + + + + + ); +} + +function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) { + const group = useRef(null); + const material = useRef(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 ( + + + + + + + + + + + ); +} + +function PartyCombatVfx() { + return ( + <> + + + + + + + + ); +} + function BossActor() { const phase = useGameStore((state) => state.phase); const primaryBossId = useGameStore((state) => state.boss.id); @@ -1185,27 +1381,72 @@ function PerformanceProbe() { 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 }) { + const group = useRef(null); const ring = useRef(null); const material = useRef(null); + const particles = useRef(null); + const particleMaterial = useRef(null); + const core = useRef(null); + const coreMaterial = useRef(null); + const transform = useMemo(() => new THREE.Object3D(), []); const age = useRef(0); const isBossFx = kind === "boss"; 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" || kind === "slash" ? "#ff643c" : kind === "shield" ? "#62bdff" : kind === "purify" ? "#c39bff" : "#ffe087"; - useFrame((_, delta) => { + const color = scenePulseColor(kind); + useFrame(({ clock }, 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); ring.current.scale.setScalar(0.5 + progress * 3.6); 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 ( - - - - + + + + + + + + + + + + + + ); } @@ -1237,7 +1478,7 @@ export function GameScene() { - + {PERFORMANCE_PROBE_ENABLED && } diff --git a/src/components/boss/BossAttackVfx.tsx b/src/components/boss/BossAttackVfx.tsx new file mode 100644 index 0000000..75c3a6c --- /dev/null +++ b/src/components/boss/BossAttackVfx.tsx @@ -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 ; + if (variant === "slash") return ; + return ; +} + +/** 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(null); + const material = useRef(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 ( + + + + + + + ); +} + +export function BreathParticleVfx({ + position, + angle, + range, + halfAngle, + active, +}: { + position: WorldPosition; + angle: number; + range: number; + halfAngle: number; + active: boolean; +}) { + const particles = useRef(null); + const material = useRef(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 ( + + + + + + + ); +} + +export function CircleMechanicVfx({ + radius, + innerRadius = 0, + kind, + color, + active, +}: { + radius: number; + innerRadius?: number; + kind: Extract | "pounce"; + color: string; + active: boolean; +}) { + const particles = useRef(null); + const material = useRef(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 ( + + + + + ); +} diff --git a/src/components/boss/BossMechanicIndicators.tsx b/src/components/boss/BossMechanicIndicators.tsx index 2cb4198..01f75af 100644 --- a/src/components/boss/BossMechanicIndicators.tsx +++ b/src/components/boss/BossMechanicIndicators.tsx @@ -7,6 +7,7 @@ import { bossHazardVfxProfile } from "../../game/bossHazardVisuals"; import { angleTo } from "../../game/geometry"; import { useGameStore } from "../../game/store"; 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 { 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; } +function bossAt(state: GameStoreState, bossIndex: number) { + return bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss; +} + function earliestSoulSiphonInMotion(motion: BossMotionState | undefined, current?: SoulSiphonState) { if (!motion) return current; let earliest = current; @@ -194,26 +199,37 @@ function SlashLaneIndicator({ laneId, bossIndex }: { laneId: string; bossIndex: (lane.start[1] + lane.end[1]) * 0.5, ]; 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; return ( - - - - - - {[-1, 1].map((side) => ( - - - + <> + + + + - ))} - {active && ( - - - - - )} - + {[-1, 1].map((side) => ( + + + + + ))} + {active && ( + + + + + )} + + + ); } @@ -234,9 +250,10 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) if (!warningMaterial.current) return; 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 active = motionMode === "pouncing"; return ( @@ -263,6 +280,7 @@ export function PounceStackIndicator({ bossIndex = 0 }: { bossIndex?: number }) ))} + ); } @@ -293,6 +311,7 @@ export function BindingWebIndicator({ bossIndex = 0 }: { bossIndex?: number }) { ))} + ); } @@ -307,16 +326,25 @@ export function BreathConeIndicator({ bossIndex = 0 }: { bossIndex?: number }) { 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; return ( - - - - - - - - - - + <> + + + + + + + + + + + + ); } @@ -631,30 +659,41 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str : telegraph.kind === "spread" ? "#e869ff" : 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 dz = telegraph.end[1] - telegraph.start[1]; const length = Math.hypot(dx, dz); const angle = Math.atan2(dx, dz); return ( - - - - - - {[-1, 1].map((side) => ( - - - + <> + + + + - ))} - {CHARGE_MARKERS.map((index) => ( - - - - - ))} - + {[-1, 1].map((side) => ( + + + + + ))} + {CHARGE_MARKERS.map((index) => ( + + + + + ))} + + + ); } @@ -697,6 +736,13 @@ function PooledTelegraphIndicator({ telegraphId, bossIndex }: { telegraphId: str ))} + ); } @@ -706,7 +752,60 @@ export function PooledMechanicIndicators({ bossIndex = 0 }: { bossIndex?: number return <>{telegraphs.map((telegraph) => )}; } +export function BasicMeleeVfx({ bossIndex = 0 }: { bossIndex?: number }) { + const group = useRef(null); + const slashMaterial = useRef(null); + const impactMaterial = useRef(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 ( + + + + + + + + + + + ); +} + const BOSS_MECHANIC_INDICATORS: readonly ComponentType<{ bossIndex?: number }>[] = [ + BasicMeleeVfx, ChargeLaneIndicator, SlashLaneIndicators, PounceStackIndicator, diff --git a/src/game/partyAttackVisuals.test.ts b/src/game/partyAttackVisuals.test.ts new file mode 100644 index 0000000..0fa013a --- /dev/null +++ b/src/game/partyAttackVisuals.test.ts @@ -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"); + } + }); +}); diff --git a/src/game/partyAttackVisuals.ts b/src/game/partyAttackVisuals.ts new file mode 100644 index 0000000..918d817 --- /dev/null +++ b/src/game/partyAttackVisuals.ts @@ -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 = { + 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]; +}