forked from phenom/healer-man
Release Healer Man 0.1.16
This commit is contained in:
1 parent
f49e54f35f
commit
b57d2b2839
89 files changed
+6055
-17
No files matched your search
@@ -0,0 +1,281 @@
|
||||
import { Clone } from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react";
|
||||
import { FrontSide, Group, Mesh, Vector3 } from "three";
|
||||
import { selectedCharacter, useShellStore } from "./app/shellStore";
|
||||
import { CharacterModel } from "./avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
|
||||
import { classById } from "./app/characterCatalog";
|
||||
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
|
||||
import { useArenaStore } from "./game/arenaStore";
|
||||
import { arenaStageById } from "./game/arenaStages";
|
||||
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
|
||||
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
|
||||
|
||||
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) {
|
||||
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
|
||||
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />;
|
||||
}
|
||||
|
||||
function LoadedStage({ url }: { url: string }) {
|
||||
const gltf = useGameGLTF(`${url}?arena-runtime=1`);
|
||||
useEffect(() => {
|
||||
gltf.scene.traverse((object) => {
|
||||
if (!(object instanceof Mesh)) return;
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||
for (const material of materials) {
|
||||
material.side = FrontSide;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
}, [gltf.scene]);
|
||||
return <Clone object={gltf.scene} />;
|
||||
}
|
||||
|
||||
function ProceduralStage({ stage }: { stage: ArenaStageManifest }) {
|
||||
const width = stage.bounds.max[0] - stage.bounds.min[0];
|
||||
const depth = stage.bounds.max[2] - stage.bounds.min[2];
|
||||
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
|
||||
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow>
|
||||
<boxGeometry args={[width, 0.5, depth]} />
|
||||
<meshStandardMaterial color="#252b27" roughness={0.9} />
|
||||
</mesh>
|
||||
{stage.lineOfSight.map((object) => (
|
||||
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
|
||||
{object.shape === "cylinder"
|
||||
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
||||
: <boxGeometry args={object.size as [number, number, number]} />}
|
||||
<meshStandardMaterial color="#4e5146" roughness={0.78} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function DynamicStageObjects({ stage, objects }: { stage: ArenaStageManifest; objects: readonly ArenaDynamicObjectSnapshot[] }) {
|
||||
const states = useMemo(() => new Map(objects.map((object) => [object.id, object])), [objects]);
|
||||
const definitions = useMemo(() => new Map(stage.dynamicObjects.map((object) => [object.id, object])), [stage]);
|
||||
const fire = states.get("fire");
|
||||
const pipes = states.get("pipes");
|
||||
const elevators = states.get("elevators");
|
||||
return (
|
||||
<group>
|
||||
{stage.lineOfSight.filter((object) => object.dynamicObjectId).map((object) => {
|
||||
const state = states.get(object.dynamicObjectId!);
|
||||
const definition = definitions.get(object.dynamicObjectId!);
|
||||
if (!state || !definition) return null;
|
||||
const waterfall = definition.kind === "waterfall";
|
||||
if (waterfall && state.state === "open") return null;
|
||||
const raised = state.state === "raised" || state.state === "active";
|
||||
const movingOffset = definition.kind === "moving-pillars" && !raised ? object.size[1] * 0.8 : 0;
|
||||
const color = waterfall ? (state.state === "warning" ? "#9fcaf0" : "#4aa9ef") : "#9a7447";
|
||||
return (
|
||||
<mesh key={object.id} position={[object.position[0], object.position[1] - movingOffset, object.position[2]]} castShadow receiveShadow>
|
||||
{object.shape === "cylinder"
|
||||
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
|
||||
: <boxGeometry args={object.size as [number, number, number]} />}
|
||||
<meshStandardMaterial color={color} emissive={waterfall ? color : "#000000"} emissiveIntensity={waterfall ? 0.35 : 0} transparent={waterfall} opacity={waterfall ? 0.62 : 0.9} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
{fire?.state === "active" && [-31, 31].map((z) => (
|
||||
<mesh key={z} position={[0, stage.bounds.min[1] + 0.6, z]}>
|
||||
<boxGeometry args={[70, 1.2, 4]} />
|
||||
<meshStandardMaterial color="#ff7a21" emissive="#ff3b0b" emissiveIntensity={1.8} transparent opacity={0.72} />
|
||||
</mesh>
|
||||
))}
|
||||
{pipes && (pipes.state === "warning" || pipes.state === "active") && stage.teamSpawns.map((spawn, index) => (
|
||||
<mesh key={index} position={[spawn[0], spawn[1] + 1.3, spawn[2] + (index === 0 ? 8 : -8)]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<cylinderGeometry args={[pipes.state === "active" ? 2.2 : 1.1, 0.4, pipes.state === "active" ? 10 : 4, 12]} />
|
||||
<meshStandardMaterial color={pipes.state === "active" ? "#73c8ff" : "#d9e9f6"} emissive="#4b9dcc" emissiveIntensity={0.8} transparent opacity={0.7} />
|
||||
</mesh>
|
||||
))}
|
||||
{elevators && stage.teamSpawns.map((spawn, index) => {
|
||||
const descent = elevators.state === "open" ? 1 : elevators.state === "moving" ? elevators.progress : 0;
|
||||
return (
|
||||
<mesh key={index} position={[spawn[0], spawn[1] - 0.5 - descent * 7, spawn[2]]} receiveShadow>
|
||||
<cylinderGeometry args={[4.5, 4.5, 1, 24]} />
|
||||
<meshStandardMaterial color="#6b5944" metalness={0.45} roughness={0.5} />
|
||||
</mesh>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ArenaActor({ actor, local, character, movement, stage }: { actor: ArenaActorSnapshot; local: boolean; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; stage: ArenaStageManifest }) {
|
||||
const group = useRef<Group>(null);
|
||||
useFrame((_, delta) => {
|
||||
const node = group.current;
|
||||
if (!node) return;
|
||||
if (local) {
|
||||
node.position.x = Math.max(stage.bounds.min[0], Math.min(stage.bounds.max[0], node.position.x + movement.current[0] * 7 * delta));
|
||||
node.position.z = Math.max(stage.bounds.min[2], Math.min(stage.bounds.max[2], node.position.z + movement.current[1] * 7 * delta));
|
||||
}
|
||||
node.position.lerp(new Vector3(...actor.position), Math.min(1, delta * (local ? 4 : 14)));
|
||||
node.rotation.y = actor.facing;
|
||||
});
|
||||
const color = actor.team === 0 ? "#58b9ff" : "#e45b55";
|
||||
const cosmeticEquipment = useMemo(
|
||||
() => character ? equipmentItemsForOwner(character.equipment, character.inventory, PLAYER_EQUIPMENT_OWNER_ID) : [],
|
||||
[character],
|
||||
);
|
||||
return (
|
||||
<group ref={group} position={actor.position as [number, number, number]} rotation={[0, actor.facing, 0]} visible={actor.alive}>
|
||||
{local && character ? (
|
||||
<CharacterModel identity={character} equipment={cosmeticEquipment} fallback={<ProceduralAvatar accent={classById(character.classId).color} />} />
|
||||
) : (
|
||||
<group>
|
||||
<mesh position={[0, 0.9, 0]} castShadow><capsuleGeometry args={[0.45, 1, 6, 12]} /><meshStandardMaterial color={color} roughness={0.55} /></mesh>
|
||||
<mesh position={[0, 1.85, 0]} castShadow><sphereGeometry args={[0.34, 14, 10]} /><meshStandardMaterial color={actor.role === "healer" ? "#e8df8b" : color} /></mesh>
|
||||
</group>
|
||||
)}
|
||||
<pointLight position={[0, 1.2, 0]} color={color} intensity={0.75} distance={3} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
|
||||
const target = useMemo(() => new Vector3(), []);
|
||||
const desired = useMemo(() => new Vector3(), []);
|
||||
useFrame(({ camera }, delta) => {
|
||||
if (!actor) return;
|
||||
const spawn = stage.teamSpawns[actor.team];
|
||||
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
|
||||
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
|
||||
const directionX = centerX - spawn[0];
|
||||
const directionZ = centerZ - spawn[2];
|
||||
const length = Math.max(0.001, Math.hypot(directionX, directionZ));
|
||||
target.set(actor.position[0], actor.position[1] + 1.2, actor.position[2]);
|
||||
desired.set(actor.position[0] - directionX / length * 5, actor.position[1] + 7.5, actor.position[2] - directionZ / length * 5);
|
||||
camera.position.lerp(desired, Math.min(1, delta * 5));
|
||||
camera.lookAt(target);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]> }) {
|
||||
return (
|
||||
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}>
|
||||
<color attach="background" args={["#111815"]} />
|
||||
<fog attach="fog" args={["#111815", 55, 240]} />
|
||||
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
|
||||
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
|
||||
<GameGltfLoaderLifecycle>
|
||||
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense>
|
||||
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
|
||||
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
|
||||
<ArenaCamera actor={localActor} stage={stage} />
|
||||
</GameGltfLoaderLifecycle>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
|
||||
return (
|
||||
<div className={`arena-team-frames arena-team-frames--${team}`}>
|
||||
{actors.filter((actor) => actor.team === team).map((actor) => (
|
||||
<button key={actor.id} type="button" className={!actor.alive ? "is-dead" : ""} onClick={() => useArenaStore.getState().intent({ type: "target", targetActorId: actor.id })}>
|
||||
<span><strong>{actor.name}</strong><small>{actor.isBot ? "AI" : "Player"} / {actor.role}</small></span>
|
||||
<i><b style={{ width: `${actor.health / actor.maxHealth * 100}%` }} /></i>
|
||||
<em>{actor.health}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArenaRuntime() {
|
||||
const snapshot = useArenaStore((state) => state.snapshot);
|
||||
const result = useArenaStore((state) => state.result);
|
||||
const intent = useArenaStore((state) => state.intent);
|
||||
const leave = useArenaStore((state) => state.leave);
|
||||
const load = useArenaStore((state) => state.load);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
|
||||
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
|
||||
const movementRef = useRef<[number, number]>([0, 0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot || !character) return;
|
||||
if (!session?.accessToken) {
|
||||
useArenaStore.setState({ error: "Offline Practice ended when the game reloaded. Start a new practice match.", queue: { status: "idle" } });
|
||||
useShellStore.setState({ phase: "arenas" });
|
||||
return;
|
||||
}
|
||||
void load(session, character).then(() => {
|
||||
if (useArenaStore.getState().queue.status !== "matched") useShellStore.setState({ phase: "arenas" });
|
||||
});
|
||||
}, [character, load, session, snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const pressed = new Set<string>();
|
||||
const movement = () => {
|
||||
const next: [number, number] = [
|
||||
Number(pressed.has("KeyD") || pressed.has("ArrowRight")) - Number(pressed.has("KeyA") || pressed.has("ArrowLeft")),
|
||||
Number(pressed.has("KeyS") || pressed.has("ArrowDown")) - Number(pressed.has("KeyW") || pressed.has("ArrowUp")),
|
||||
];
|
||||
movementRef.current = next;
|
||||
intent({ type: "movement", movement: { x: next[0], z: next[1] } });
|
||||
};
|
||||
const down = (event: KeyboardEvent) => {
|
||||
if (["KeyW", "KeyA", "KeyS", "KeyD", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code)) {
|
||||
pressed.add(event.code);
|
||||
movement();
|
||||
}
|
||||
if (event.code === "Digit1") intent({ type: "ability", abilityId: "attack" });
|
||||
if (event.code === "Digit2") intent({ type: "ability", abilityId: "strike" });
|
||||
if (event.code === "Digit3") intent({ type: "ability", abilityId: "interrupt" });
|
||||
if (event.code === "Digit4") intent({ type: "ability", abilityId: "control" });
|
||||
if (event.code === "Digit5") intent({ type: "ability", abilityId: "defensive" });
|
||||
if (event.code === "Digit6") intent({ type: "ability", abilityId: "heal" });
|
||||
};
|
||||
const up = (event: KeyboardEvent) => {
|
||||
if (pressed.delete(event.code)) movement();
|
||||
};
|
||||
window.addEventListener("keydown", down);
|
||||
window.addEventListener("keyup", up);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", down);
|
||||
window.removeEventListener("keyup", up);
|
||||
};
|
||||
}, [intent]);
|
||||
|
||||
const exit = async () => {
|
||||
await leave(session);
|
||||
useShellStore.setState({ phase: "arenas" });
|
||||
};
|
||||
|
||||
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
|
||||
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
|
||||
return (
|
||||
<main className="arena-runtime">
|
||||
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} /></div>
|
||||
<header className="arena-match-header">
|
||||
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
|
||||
</header>
|
||||
<TeamFrames actors={snapshot.actors} team={0} />
|
||||
<TeamFrames actors={snapshot.actors} team={1} />
|
||||
{snapshot.phase === "preparation" && <div className="arena-countdown"><small>Gates sealed</small><strong>{seconds}</strong><span>Prepare your opening</span></div>}
|
||||
<nav className="arena-action-bar" aria-label="Arena abilities">
|
||||
{["attack", "strike", "interrupt", "control", "defensive", "heal"].map((ability, index) => (
|
||||
<button key={ability} type="button" onClick={() => intent({ type: "ability", abilityId: ability })}><b>{index + 1}</b><span>{ability}</span></button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="arena-exit" type="button" onClick={() => void exit()}>{result ? "Leave arena" : "Exit / bot takeover"}</button>
|
||||
{result && (
|
||||
<section className="arena-result-panel" role="dialog" aria-modal="true">
|
||||
<p className="front-kicker">Match complete</p>
|
||||
<h1>{result.draw ? "Draw" : result.winnerTeam === localActor?.team ? "Victory" : "Defeat"}</h1>
|
||||
{result.ratingUpdates.map((update) => <p key={`${update.characterId}-${update.bracket}`}>{update.bracket}: {update.before} {update.delta >= 0 ? "+" : ""}{update.delta} → {update.after} / {update.title}</p>)}
|
||||
{!result.ratingUpdates.length && <p>Practice result — rating unchanged.</p>}
|
||||
<button type="button" className="front-button front-button--primary" onClick={() => void exit()}>Return to Arena Command</button>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user