Release Healer Man 0.1.47
This commit is contained in:
1 parent
9ff574ac0b
commit
2162039b49
24 files changed
+486
-55
No files matched your search
@@ -54,6 +54,23 @@ describe("dungeon simulation fixed clock", () => {
|
||||
});
|
||||
|
||||
describe("spectator roster and exact-hash approvals", () => {
|
||||
it("tracks the chosen party member and returns to follow mode when one is picked", () => {
|
||||
const store = useDungeonSimulationStore.getState();
|
||||
store.startLive({ dungeonId: route.dungeonId, route, mode: "navigation-only", seed: "camera" });
|
||||
expect(useDungeonSimulationStore.getState()).toMatchObject({
|
||||
cameraMode: "follow-member",
|
||||
spectatedMemberId: null,
|
||||
});
|
||||
store.setCameraMode("free-fly");
|
||||
store.setSpectatedMember("party-healer");
|
||||
expect(useDungeonSimulationStore.getState()).toMatchObject({
|
||||
cameraMode: "follow-member",
|
||||
spectatedMemberId: "party-healer",
|
||||
});
|
||||
store.leave();
|
||||
expect(useDungeonSimulationStore.getState().spectatedMemberId).toBeNull();
|
||||
});
|
||||
|
||||
it("creates a stable tank, healer, and three damage dealers", () => {
|
||||
const first = createSpectatorDungeonParty("same-seed", 80, "wow");
|
||||
const second = createSpectatorDungeonParty("same-seed", 80, "wow");
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface DungeonSimulationState {
|
||||
runState: "idle" | "running" | "paused" | "finished";
|
||||
speed: DungeonSimulationSpeed;
|
||||
cameraMode: DungeonSpectatorCameraMode;
|
||||
spectatedMemberId: string | null;
|
||||
dungeonId: string | null;
|
||||
routeId: string | null;
|
||||
routeHash: string | null;
|
||||
@@ -56,6 +57,7 @@ export interface DungeonSimulationState {
|
||||
toggleRunning: () => void;
|
||||
setSpeed: (speed: DungeonSimulationSpeed) => void;
|
||||
setCameraMode: (mode: DungeonSpectatorCameraMode) => void;
|
||||
setSpectatedMember: (memberId: string | null) => void;
|
||||
step: () => void;
|
||||
seekReplay: (elapsedMs: number) => void;
|
||||
consumeTicks: (wallDeltaMs: number) => readonly number[];
|
||||
@@ -72,7 +74,8 @@ const initialState = {
|
||||
mode: "full-combat" as DungeonSimulationMode,
|
||||
runState: "idle" as const,
|
||||
speed: 1 as DungeonSimulationSpeed,
|
||||
cameraMode: "follow-leader" as DungeonSpectatorCameraMode,
|
||||
cameraMode: "follow-member" as DungeonSpectatorCameraMode,
|
||||
spectatedMemberId: null as string | null,
|
||||
dungeonId: null as string | null,
|
||||
routeId: null as string | null,
|
||||
routeHash: null as string | null,
|
||||
@@ -146,6 +149,7 @@ export const useDungeonSimulationStore = create<DungeonSimulationState>((set, ge
|
||||
},
|
||||
setSpeed: (speed) => set({ speed }),
|
||||
setCameraMode: (cameraMode) => set({ cameraMode }),
|
||||
setSpectatedMember: (spectatedMemberId) => set({ spectatedMemberId, cameraMode: "follow-member" }),
|
||||
step: () => set((state) => {
|
||||
if (state.runState !== "paused") return state;
|
||||
if (state.sessionMode === "spectate-replay" && state.replay) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Vector3Tuple } from "./dungeonTypes";
|
||||
export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay";
|
||||
export type DungeonSimulationMode = "full-combat" | "navigation-only";
|
||||
export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4;
|
||||
export type DungeonSpectatorCameraMode = "follow-leader" | "free-fly";
|
||||
export type DungeonSpectatorCameraMode = "follow-member" | "free-fly";
|
||||
export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork";
|
||||
export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit";
|
||||
export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical";
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
spectatorCameraBoosted,
|
||||
spectatorCameraTranslation,
|
||||
spectatorKeyboardAxes,
|
||||
} from "./spectatorCameraMath";
|
||||
|
||||
describe("dungeon spectator free camera", () => {
|
||||
it("maps desktop flight controls independently from player movement state", () => {
|
||||
const held = new Set(["KeyW", "KeyD", "KeyE", "ShiftLeft"]);
|
||||
expect(spectatorKeyboardAxes(held)).toEqual({ forward: 1, right: 1, vertical: 1 });
|
||||
expect(spectatorCameraBoosted(held)).toBe(true);
|
||||
expect(spectatorKeyboardAxes(new Set(["KeyS", "KeyA", "KeyQ"]))).toEqual({
|
||||
forward: -1,
|
||||
right: -1,
|
||||
vertical: -1,
|
||||
});
|
||||
});
|
||||
|
||||
it("flies along the view direction and uses world-relative vertical movement", () => {
|
||||
expect(spectatorCameraTranslation(0, 0, { forward: 1, right: 0, vertical: 0 }, 0.1, false))
|
||||
.toEqual([0, 0, 1]);
|
||||
expect(spectatorCameraTranslation(0, 0, { forward: 0, right: 0, vertical: 1 }, 0.1, false))
|
||||
.toEqual([0, 1, 0]);
|
||||
const pitched = spectatorCameraTranslation(0, Math.PI / 6, { forward: 1, right: 0, vertical: 0 }, 0.1, false);
|
||||
expect(pitched[1]).toBeCloseTo(0.5, 6);
|
||||
expect(pitched[2]).toBeCloseTo(Math.sqrt(3) / 2, 6);
|
||||
});
|
||||
|
||||
it("normalizes diagonal speed and clamps long render-frame gaps", () => {
|
||||
const diagonal = spectatorCameraTranslation(0, 0, { forward: 1, right: 1, vertical: 1 }, 1, false);
|
||||
expect(Math.hypot(...diagonal)).toBeCloseTo(1, 6);
|
||||
const boosted = spectatorCameraTranslation(0, 0, { forward: 1, right: 0, vertical: 0 }, 1, true);
|
||||
expect(Math.hypot(...boosted)).toBeCloseTo(2.8, 6);
|
||||
expect(spectatorCameraTranslation(Number.NaN, 0, { forward: 1, right: 0, vertical: 0 }, 0.1, false))
|
||||
.toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Vector3Tuple } from "./dungeonTypes";
|
||||
|
||||
export interface SpectatorCameraAxes {
|
||||
readonly forward: number;
|
||||
readonly right: number;
|
||||
readonly vertical: number;
|
||||
}
|
||||
|
||||
const MAX_CAMERA_FRAME_SECONDS = 0.1;
|
||||
|
||||
function held(codes: ReadonlySet<string>, ...candidates: readonly string[]): number {
|
||||
return Number(candidates.some((code) => codes.has(code)));
|
||||
}
|
||||
|
||||
export function spectatorKeyboardAxes(codes: ReadonlySet<string>): SpectatorCameraAxes {
|
||||
return {
|
||||
forward: held(codes, "KeyW") - held(codes, "KeyS"),
|
||||
right: held(codes, "KeyD") - held(codes, "KeyA"),
|
||||
vertical: held(codes, "KeyE", "Space") - held(codes, "KeyQ", "ControlLeft", "ControlRight"),
|
||||
};
|
||||
}
|
||||
|
||||
export function spectatorCameraBoosted(codes: ReadonlySet<string>): boolean {
|
||||
return codes.has("ShiftLeft") || codes.has("ShiftRight");
|
||||
}
|
||||
|
||||
/** Returns a bounded, normalized free-flight translation for one render frame. */
|
||||
export function spectatorCameraTranslation(
|
||||
yaw: number,
|
||||
pitch: number,
|
||||
axes: SpectatorCameraAxes,
|
||||
deltaSeconds: number,
|
||||
boosted: boolean,
|
||||
): Vector3Tuple {
|
||||
if (![yaw, pitch, deltaSeconds, axes.forward, axes.right, axes.vertical].every(Number.isFinite)) return [0, 0, 0];
|
||||
const forwardX = Math.sin(yaw) * Math.cos(pitch);
|
||||
const forwardY = Math.sin(pitch);
|
||||
const forwardZ = Math.cos(yaw) * Math.cos(pitch);
|
||||
const rightX = Math.cos(yaw);
|
||||
const rightZ = -Math.sin(yaw);
|
||||
let x = forwardX * axes.forward + rightX * axes.right;
|
||||
let y = forwardY * axes.forward + axes.vertical;
|
||||
let z = forwardZ * axes.forward + rightZ * axes.right;
|
||||
const length = Math.hypot(x, y, z);
|
||||
if (length <= 1e-6) return [0, 0, 0];
|
||||
const scale = (boosted ? 28 : 10) * Math.max(0, Math.min(MAX_CAMERA_FRAME_SECONDS, deltaSeconds)) / Math.max(1, length);
|
||||
x *= scale;
|
||||
y *= scale;
|
||||
z *= scale;
|
||||
return [x, y, z];
|
||||
}
|
||||
Reference in new issue
Block a user