forked from phenom/healer-man
Release Healer Man 0.1.17
This commit is contained in:
1 parent
b57d2b2839
commit
fc8a7a722a
13 files changed
+234
-40
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.17",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.16",
|
||||
"versionCode": 1016
|
||||
"versionName": "0.1.17",
|
||||
"versionCode": 1017
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export declare const ARENA_TICK_MS: 50;
|
||||
export declare class ArenaSimulation {
|
||||
constructor(options: ArenaSimulationOptions);
|
||||
start(): this;
|
||||
resetPreparationClock(startedAt?: number): boolean;
|
||||
stop(): void;
|
||||
step(): void;
|
||||
snapshot(): ArenaAuthoritativeSnapshot;
|
||||
|
||||
@@ -156,6 +156,13 @@ export class ArenaSimulation {
|
||||
return this;
|
||||
}
|
||||
|
||||
resetPreparationClock(startedAt = Date.now()) {
|
||||
if (this.interval || this.tickNumber !== 0 || this.phase !== "preparation" || this.finished) return false;
|
||||
this.startedAt = Number(startedAt);
|
||||
this.phaseEndsAt = this.startedAt + 10_000;
|
||||
return true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
|
||||
@@ -26,6 +26,14 @@ test("authoritative simulation seals ten seconds, runs 20 Hz, and rejects duplic
|
||||
assert.ok(simulation.actor("left-healer").position[0] > before);
|
||||
});
|
||||
|
||||
test("a deferred practice start receives a fresh preparation clock", () => {
|
||||
const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena");
|
||||
const simulation = new ArenaSimulation({ id: "deferred", stage, bracket: "2v2", mode: "practice", ruleset: "wow", participants: participants(), startedAt: 1_000 });
|
||||
assert.equal(simulation.resetPreparationClock(20_000), true);
|
||||
assert.equal(simulation.snapshot().serverTime, 20_000);
|
||||
assert.equal(simulation.snapshot().phaseEndsAt, 30_000);
|
||||
});
|
||||
|
||||
test("Dalaran and Ring of Valor mechanics synchronize collision state", () => {
|
||||
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
|
||||
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
|
||||
|
||||
+103
-23
@@ -1,7 +1,7 @@
|
||||
import { Clone } from "@react-three/drei";
|
||||
import { Clone, useProgress } 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 { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { Group, Vector3 } from "three";
|
||||
import { selectedCharacter, useShellStore } from "./app/shellStore";
|
||||
import { CharacterModel } from "./avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
|
||||
@@ -12,23 +12,46 @@ 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} />;
|
||||
class ArenaStageErrorBoundary extends Component<{
|
||||
readonly children: ReactNode;
|
||||
readonly fallback: ReactNode;
|
||||
readonly onError: (error: unknown) => void;
|
||||
}, { readonly failed: boolean }> {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
function LoadedStage({ url }: { url: string }) {
|
||||
componentDidCatch(error: unknown) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onReady: () => void }) {
|
||||
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
|
||||
return (
|
||||
<>
|
||||
<ProceduralStage stage={stage} />
|
||||
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StageReady({ onReady }: { readonly onReady: () => void }) {
|
||||
useEffect(onReady, [onReady]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
|
||||
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]);
|
||||
onReady();
|
||||
}, [gltf.scene, onReady]);
|
||||
return <Clone object={gltf.scene} />;
|
||||
}
|
||||
|
||||
@@ -37,11 +60,12 @@ function ProceduralStage({ stage }: { stage: ArenaStageManifest }) {
|
||||
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;
|
||||
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow>
|
||||
<boxGeometry args={[width, 0.5, depth]} />
|
||||
<meshStandardMaterial color="#252b27" roughness={0.9} />
|
||||
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
|
||||
<boxGeometry args={[width, 0.25, depth]} />
|
||||
<meshStandardMaterial color="#364038" roughness={0.92} />
|
||||
</mesh>
|
||||
{stage.lineOfSight.map((object) => (
|
||||
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
|
||||
@@ -157,15 +181,26 @@ function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage
|
||||
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]> }) {
|
||||
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; onStageReady: () => void; onStageError: (error: unknown) => void }) {
|
||||
return (
|
||||
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}>
|
||||
<Canvas
|
||||
shadows
|
||||
dpr={[1, 1.35]}
|
||||
camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}
|
||||
fallback={<div className="arena-render-fallback"><strong>3D arena unavailable</strong><small>This device could not start WebGL. Return to Arena Command and try another renderer.</small></div>}
|
||||
>
|
||||
<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>
|
||||
<ArenaStageErrorBoundary
|
||||
key={stage.id}
|
||||
fallback={<ProceduralStage stage={stage} />}
|
||||
onError={onStageError}
|
||||
>
|
||||
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
|
||||
</ArenaStageErrorBoundary>
|
||||
<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} />
|
||||
@@ -174,6 +209,25 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
|
||||
);
|
||||
}
|
||||
|
||||
function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: ArenaStageManifest; readonly ready: boolean; readonly fallbackReason: string | null }) {
|
||||
const { active, progress } = useProgress();
|
||||
if (ready && !active && !fallbackReason) return null;
|
||||
const value = Math.max(3, Math.min(100, Math.round(progress)));
|
||||
return (
|
||||
<section className={`arena-asset-status${fallbackReason ? " arena-asset-status--fallback" : ""}`} role="status" aria-live="polite">
|
||||
<strong>{fallbackReason ? "Arena ready with safe geometry" : `Loading ${stage.name}`}</strong>
|
||||
{fallbackReason ? (
|
||||
<small>The detailed arena asset could not be decoded, so a collision-matched fallback is active.</small>
|
||||
) : (
|
||||
<>
|
||||
<span><i style={{ width: `${value}%` }} /></span>
|
||||
<small>Streaming arena geometry · {value}%</small>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
|
||||
return (
|
||||
<div className={`arena-team-frames arena-team-frames--${team}`}>
|
||||
@@ -194,11 +248,36 @@ export default function ArenaRuntime() {
|
||||
const intent = useArenaStore((state) => state.intent);
|
||||
const leave = useArenaStore((state) => state.leave);
|
||||
const load = useArenaStore((state) => state.load);
|
||||
const startPractice = useArenaStore((state) => state.startPractice);
|
||||
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]);
|
||||
const [loadedStageId, setLoadedStageId] = useState<string | null>(null);
|
||||
const [stageFailure, setStageFailure] = useState<{ readonly stageId: string; readonly reason: string } | null>(null);
|
||||
const handleStageReady = useCallback(() => {
|
||||
if (!stage) return;
|
||||
setLoadedStageId(stage.id);
|
||||
setStageFailure((current) => current?.stageId === stage.id ? null : current);
|
||||
startPractice();
|
||||
}, [stage, startPractice]);
|
||||
const handleStageError = useCallback((error: unknown) => {
|
||||
if (!stage) return;
|
||||
setLoadedStageId(stage.id);
|
||||
setStageFailure({ stageId: stage.id, reason: error instanceof Error ? error.message : "Arena asset failed to load" });
|
||||
startPractice();
|
||||
}, [stage, startPractice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stage || loadedStageId === stage.id) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLoadedStageId(stage.id);
|
||||
setStageFailure({ stageId: stage.id, reason: "Arena asset loading timed out" });
|
||||
startPractice();
|
||||
}, 30_000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [loadedStageId, stage, startPractice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot || !character) return;
|
||||
@@ -254,7 +333,8 @@ export default function ArenaRuntime() {
|
||||
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>
|
||||
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
|
||||
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
|
||||
<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>
|
||||
|
||||
@@ -55,9 +55,15 @@ export function createArenaPractice(options: PracticeOptions) {
|
||||
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
|
||||
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
|
||||
});
|
||||
simulation.start();
|
||||
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
|
||||
let started = false;
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
simulation.resetPreparationClock();
|
||||
simulation.start();
|
||||
},
|
||||
intent(intent: ArenaCombatIntent) {
|
||||
return simulation.receiveIntent("practice-player", intent);
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ interface ArenaStoreState {
|
||||
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
|
||||
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
|
||||
ready: (session: PlayerSession) => Promise<void>;
|
||||
startPractice: () => void;
|
||||
leave: (session: PlayerSession | null) => Promise<void>;
|
||||
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
|
||||
resetResult: () => void;
|
||||
@@ -221,6 +222,8 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
|
||||
catch (error) { set({ error: messageFor(error) }); }
|
||||
},
|
||||
|
||||
startPractice: () => get().practice?.start(),
|
||||
|
||||
leave: async (session) => {
|
||||
get().practice?.stop();
|
||||
get().socket?.close();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MINIMUM_CORPSE_VISIBLE_MS,
|
||||
MOB_WOUND_ANIMATION_COOLDOWN_MS,
|
||||
PLAYER_AURA_ENTITY_ID,
|
||||
resolvedAbilityCosts,
|
||||
selectedMob,
|
||||
useCombatStore,
|
||||
} from "./combatStore";
|
||||
@@ -97,6 +98,32 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the displayed primary mana and resolved percentage cost for affordability", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
const initial = useCombatStore.getState();
|
||||
const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt");
|
||||
expect(frostbolt).toBeDefined();
|
||||
const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial);
|
||||
expect(cost).toEqual({ resource: "mana", amount: Math.round(initial.maxResource * 0.14) });
|
||||
registerTarget("mana-target", [10, 0, 0]);
|
||||
|
||||
useCombatStore.setState({
|
||||
resource: cost.amount,
|
||||
resourcePools: { ...initial.resourcePools, mana: 0 },
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 1_000)).toMatchObject({ ok: true });
|
||||
useCombatStore.getState().cancelCast();
|
||||
|
||||
useCombatStore.setState({
|
||||
resource: cost.amount - 1,
|
||||
resourcePools: { ...initial.resourcePools, mana: initial.maxResource },
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 3_000)).toMatchObject({
|
||||
ok: false,
|
||||
reason: "not-enough-resource",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
registerTarget("presentation-target", [10, 0, 0]);
|
||||
|
||||
+14
-6
@@ -1504,15 +1504,18 @@ function initialResourcePools(
|
||||
return { pools, maximums };
|
||||
}
|
||||
|
||||
function abilityCosts(
|
||||
export function resolvedAbilityCosts(
|
||||
ability: AbilityDefinition,
|
||||
modifiers: readonly TalentModifier[],
|
||||
state: Pick<CombatState, "maxHealth" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
||||
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
|
||||
): readonly { resource: ResourceType; amount: number }[] {
|
||||
const aggregated = new Map<ResourceType, number>();
|
||||
const activeResourceType = resourceProfileForClass(state.classId).type;
|
||||
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
|
||||
const maximum = cost.resource === "health"
|
||||
? state.maxHealth
|
||||
: cost.resource === activeResourceType
|
||||
? state.maxResource
|
||||
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
|
||||
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
|
||||
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
|
||||
@@ -3866,7 +3869,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: ability?.target;
|
||||
const modifiers = state.talentModifiers;
|
||||
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
|
||||
const costs = ability ? abilityCosts(ability, modifiers, state) : [];
|
||||
const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
|
||||
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
|
||||
const targeting = ability ? targetingRulesForAbility(ability) : null;
|
||||
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
|
||||
@@ -3895,7 +3898,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
|
||||
else if (costs.some((cost) => cost.resource === "health"
|
||||
? state.health <= cost.amount
|
||||
: (state.resourcePools[cost.resource] ?? (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : 0)) < cost.amount)) failure = "not-enough-resource";
|
||||
: (cost.resource === resourceProfileForClass(state.classId).type
|
||||
? state.resource
|
||||
: state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
|
||||
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
|
||||
|
||||
const playerOrigin = currentOrigin(state, origin);
|
||||
@@ -4060,7 +4065,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
state.classId,
|
||||
);
|
||||
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
|
||||
const spentPools = { ...state.resourcePools };
|
||||
const activeResourceType = resourceProfileForClass(state.classId).type;
|
||||
const spentPools: Partial<Record<ResourceType, number>> = {
|
||||
...state.resourcePools,
|
||||
[activeResourceType]: state.resource,
|
||||
};
|
||||
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
|
||||
let healthAfterCosts = state.health;
|
||||
for (const cost of costsToSpend) {
|
||||
@@ -4073,7 +4082,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const spentRunes = runeCost && completingCast?.mode !== "channel"
|
||||
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
|
||||
: state.deathKnightRunes;
|
||||
const activeResourceType = resourceProfileForClass(state.classId).type;
|
||||
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
|
||||
if (!completingCast && castTimeMs > 0) {
|
||||
castSequence += 1;
|
||||
|
||||
@@ -3327,6 +3327,51 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.arena-world,
|
||||
.arena-world > div { position: absolute; inset: 0; }
|
||||
|
||||
.arena-render-fallback {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: .4rem;
|
||||
padding: 2rem;
|
||||
color: #e8eee4;
|
||||
text-align: center;
|
||||
background: radial-gradient(circle at center, #17251f, #07100d 68%);
|
||||
}
|
||||
|
||||
.arena-render-fallback small { max-width: 32rem; color: #b9c4b6; }
|
||||
|
||||
.arena-asset-status {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
gap: .45rem;
|
||||
padding: .85rem 1rem;
|
||||
border: 1px solid rgba(220, 190, 99, .5);
|
||||
text-align: center;
|
||||
background: rgba(6, 13, 10, .92);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 1rem 4rem rgba(0, 0, 0, .45);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arena-asset-status > span {
|
||||
overflow: hidden;
|
||||
height: .32rem;
|
||||
background: rgba(216, 185, 98, .16);
|
||||
}
|
||||
|
||||
.arena-asset-status > span i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #d8b962;
|
||||
transition: width .15s ease-out;
|
||||
}
|
||||
|
||||
.arena-asset-status small { color: #b9c4b6; }
|
||||
.arena-asset-status--fallback { top: auto; bottom: 5.3rem; transform: translateX(-50%); }
|
||||
|
||||
.arena-match-header {
|
||||
position: absolute;
|
||||
top: .7rem;
|
||||
|
||||
+14
-5
@@ -4,7 +4,7 @@ import { classById, raceById } from "../app/characterCatalog";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
||||
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
||||
import { respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { resurrectManastormSession } from "../game/manastormSession";
|
||||
@@ -137,9 +137,12 @@ export function Hud() {
|
||||
const resource = useCombatStore((state) => state.resource);
|
||||
const maxResource = useCombatStore((state) => state.maxResource);
|
||||
const resourcePools = useCombatStore((state) => state.resourcePools);
|
||||
const maxResourcePools = useCombatStore((state) => state.maxResourcePools);
|
||||
const resourceName = useCombatStore((state) => state.resourceName);
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const talentModifiers = useCombatStore((state) => state.talentModifiers);
|
||||
const auras = useCombatStore((state) => state.auras);
|
||||
const actionBindings = useCombatStore((state) => state.actionBindings);
|
||||
const cooldowns = useCombatStore((state) => state.cooldowns);
|
||||
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
|
||||
@@ -190,11 +193,17 @@ export function Hud() {
|
||||
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
||||
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
|
||||
const remaining = Math.max(personalRemaining, globalRemaining);
|
||||
const abilityCosts = ability.costs?.length ? ability.costs : [ability.cost];
|
||||
const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
|
||||
classId,
|
||||
maxHealth,
|
||||
maxResource,
|
||||
maxResourcePools,
|
||||
auras,
|
||||
});
|
||||
const cannotPay = abilityCosts.some((cost) => cost.resource === "health"
|
||||
? health <= cost.amount
|
||||
: (resourcePools[cost.resource] ?? (cost.resource === resourceTone ? resource : 0)) < cost.amount);
|
||||
const displayCost = abilityCosts[0] ?? ability.cost;
|
||||
: (cost.resource === resourceTone ? resource : resourcePools[cost.resource] ?? 0) < cost.amount);
|
||||
const displayCost = abilityCosts[0] ?? { resource: ability.cost.resource, amount: 0 };
|
||||
const item: ActionBarItem = {
|
||||
id: ability.id,
|
||||
name: ability.name,
|
||||
@@ -211,7 +220,7 @@ export function Hud() {
|
||||
target: ability.target,
|
||||
};
|
||||
return [control, item];
|
||||
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, classColor, clock, cooldowns, globalCooldownEndsAt, health, level, resource, resourceName, resourcePools, resourceTone, talentRanks]);
|
||||
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
||||
|
||||
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
|
||||
useGameStore.getState().actionLayer,
|
||||
|
||||
Reference in new issue
Block a user