Release Healer Man 0.1.48
This commit is contained in:
1 parent
2162039b49
commit
39425f2494
9 files changed
+219
-40
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.47",
|
"version": "0.1.48",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.47",
|
"version": "0.1.48",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.47",
|
"version": "0.1.48",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.47",
|
"versionName": "0.1.48",
|
||||||
"versionCode": 1047
|
"versionCode": 1048
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primar
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
|
const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
|
||||||
const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join(
|
const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join(
|
||||||
'playtest-artifacts',
|
'playtest-artifacts',
|
||||||
@@ -38,6 +39,30 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
|||||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||||
console.log(`Captured ${name}`);
|
console.log(`Captured ${name}`);
|
||||||
};
|
};
|
||||||
|
const canvasHash = async () => {
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
await canvas.evaluate((element) => {
|
||||||
|
const hidden = [];
|
||||||
|
let current = element;
|
||||||
|
while (current.parentElement) {
|
||||||
|
for (const sibling of current.parentElement.children) {
|
||||||
|
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
|
||||||
|
hidden.push([sibling, sibling.style.visibility]);
|
||||||
|
sibling.style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
window.__restoreCanvasCapture = () => {
|
||||||
|
for (const [node, visibility] of hidden) node.style.visibility = visibility;
|
||||||
|
delete window.__restoreCanvasCapture;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return crypto.createHash('sha256').update(await canvas.screenshot()).digest('hex');
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(() => window.__restoreCanvasCapture?.());
|
||||||
|
}
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||||
console.log('Production menu loaded');
|
console.log('Production menu loaded');
|
||||||
@@ -66,6 +91,13 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
|||||||
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
|
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
|
||||||
}, null, { timeout: 90000 });
|
}, null, { timeout: 90000 });
|
||||||
await page.waitForTimeout(4000);
|
await page.waitForTimeout(4000);
|
||||||
|
const bossFrameBefore = await canvasHash();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
assert.notEqual(
|
||||||
|
await canvasHash(),
|
||||||
|
bossFrameBefore,
|
||||||
|
'Boss Testing redraws the WebGL scene independently from its HUD and nameplates',
|
||||||
|
);
|
||||||
await capture('03-empty-room');
|
await capture('03-empty-room');
|
||||||
const before = await page.evaluate(async () => {
|
const before = await page.evaluate(async () => {
|
||||||
const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores();
|
const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores();
|
||||||
|
|||||||
@@ -34,7 +34,31 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
|
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
|
||||||
const canvasHash = async () => crypto.createHash('sha256').update(await page.locator('canvas').screenshot()).digest('hex');
|
const captureCanvas = async (name) => {
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
await canvas.evaluate((element) => {
|
||||||
|
const hidden = [];
|
||||||
|
let current = element;
|
||||||
|
while (current.parentElement) {
|
||||||
|
for (const sibling of current.parentElement.children) {
|
||||||
|
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
|
||||||
|
hidden.push([sibling, sibling.style.visibility]);
|
||||||
|
sibling.style.visibility = 'hidden';
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
window.__restoreCanvasCapture = () => {
|
||||||
|
for (const [node, visibility] of hidden) node.style.visibility = visibility;
|
||||||
|
delete window.__restoreCanvasCapture;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const image = await canvas.screenshot(name ? { path: path.join(directory, `${name}.png`) } : undefined);
|
||||||
|
return crypto.createHash('sha256').update(image).digest('hex');
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(() => window.__restoreCanvasCapture?.());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
|
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
|
||||||
@@ -79,22 +103,20 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Free camera' }).click();
|
await page.getByRole('button', { name: 'Free camera' }).click();
|
||||||
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
|
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
|
||||||
const beforeFlight = await canvasHash();
|
const beforeFlight = await captureCanvas('03-canvas-before-flight');
|
||||||
await page.keyboard.down('w');
|
await page.keyboard.down('w');
|
||||||
await page.keyboard.down('e');
|
|
||||||
await page.waitForTimeout(700);
|
await page.waitForTimeout(700);
|
||||||
await page.keyboard.up('e');
|
|
||||||
await page.keyboard.up('w');
|
await page.keyboard.up('w');
|
||||||
await page.waitForTimeout(150);
|
await page.waitForTimeout(150);
|
||||||
const afterFlight = await canvasHash();
|
const afterFlight = await captureCanvas();
|
||||||
assert.notEqual(afterFlight, beforeFlight, 'W/E free-camera input changes the rendered viewpoint');
|
assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays');
|
||||||
const canvasBounds = await page.locator('canvas').boundingBox();
|
const canvasBounds = await page.locator('canvas').boundingBox();
|
||||||
assert(canvasBounds, 'simulation canvas is visible');
|
assert(canvasBounds, 'simulation canvas is visible');
|
||||||
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
|
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
|
||||||
const beforeLook = await page.evaluate(() => window.readSpectatorCameraState());
|
const beforeLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||||
await page.mouse.move(lookStart.x, lookStart.y);
|
await page.mouse.move(lookStart.x, lookStart.y);
|
||||||
await page.mouse.down({ button: 'right' });
|
await page.mouse.down({ button: 'right' });
|
||||||
await page.mouse.move(lookStart.x + 140, lookStart.y - 45, { steps: 10 });
|
await page.mouse.move(lookStart.x + 220, lookStart.y + 100, { steps: 12 });
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
const duringLook = await page.evaluate(() => window.readSpectatorCameraState());
|
const duringLook = await page.evaluate(() => window.readSpectatorCameraState());
|
||||||
assert.notEqual(duringLook.yaw, beforeLook.yaw, 'right-drag rotates the free camera');
|
assert.notEqual(duringLook.yaw, beforeLook.yaw, 'right-drag rotates the free camera');
|
||||||
@@ -108,6 +130,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
|
|||||||
releasedLook.yaw,
|
releasedLook.yaw,
|
||||||
'mouse movement after right-button release does not rotate the camera',
|
'mouse movement after right-button release does not rotate the camera',
|
||||||
);
|
);
|
||||||
|
await page.locator('canvas').screenshot({ path: path.join(directory, '04-canvas-after-flight-look.png') });
|
||||||
await capture('04-paused-free-camera-moved');
|
await capture('04-paused-free-camera-moved');
|
||||||
|
|
||||||
await memberButtons.nth(4).click();
|
await memberButtons.nth(4).click();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { chromium } = require("C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright");
|
const { chromium } = require("C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright");
|
||||||
@@ -14,6 +15,31 @@ fs.mkdirSync(directory, { recursive: true });
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
page.on("pageerror", (error) => errors.push(error.message));
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
|
||||||
|
const canvasHash = async () => {
|
||||||
|
const canvas = page.locator("canvas");
|
||||||
|
await canvas.evaluate((element) => {
|
||||||
|
const hidden = [];
|
||||||
|
let current = element;
|
||||||
|
while (current.parentElement) {
|
||||||
|
for (const sibling of current.parentElement.children) {
|
||||||
|
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
|
||||||
|
hidden.push([sibling, sibling.style.visibility]);
|
||||||
|
sibling.style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
window.__restoreCanvasCapture = () => {
|
||||||
|
for (const [node, visibility] of hidden) node.style.visibility = visibility;
|
||||||
|
delete window.__restoreCanvasCapture;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return crypto.createHash("sha256").update(await canvas.screenshot()).digest("hex");
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(() => window.__restoreCanvasCapture?.());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const gameState = () => page.evaluate(async () => {
|
const gameState = () => page.evaluate(async () => {
|
||||||
const storeEntry = performance.getEntriesByType("resource")
|
const storeEntry = performance.getEntriesByType("resource")
|
||||||
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
|
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
|
||||||
@@ -74,6 +100,7 @@ fs.mkdirSync(directory, { recursive: true });
|
|||||||
const cdp = await page.context().newCDPSession(page);
|
const cdp = await page.context().newCDPSession(page);
|
||||||
const start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 };
|
const start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 };
|
||||||
const before = await gameState();
|
const before = await gameState();
|
||||||
|
const frameBefore = await canvasHash();
|
||||||
assert.equal(before.cameraLookActive, false);
|
assert.equal(before.cameraLookActive, false);
|
||||||
await page.mouse.move(start.x, start.y);
|
await page.mouse.move(start.x, start.y);
|
||||||
await page.mouse.down({ button: "right" });
|
await page.mouse.down({ button: "right" });
|
||||||
@@ -99,6 +126,11 @@ fs.mkdirSync(directory, { recursive: true });
|
|||||||
await page.waitForTimeout(350);
|
await page.waitForTimeout(350);
|
||||||
const moved = await gameState();
|
const moved = await gameState();
|
||||||
assert.notEqual(moved.cameraYaw, before.cameraYaw);
|
assert.notEqual(moved.cameraYaw, before.cameraYaw);
|
||||||
|
assert.notEqual(
|
||||||
|
await canvasHash(),
|
||||||
|
frameBefore,
|
||||||
|
"regular dungeon camera input redraws the WebGL scene independently from its HUD and nameplates",
|
||||||
|
);
|
||||||
await page.screenshot({ path: path.join(directory, "right-drag-camera.png") });
|
await page.screenshot({ path: path.join(directory, "right-drag-camera.png") });
|
||||||
|
|
||||||
await page.mouse.up({ button: "right" });
|
await page.mouse.up({ button: "right" });
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const componentSource = readFileSync(
|
||||||
|
new URL("./DungeonSimulationSceneBridge.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("dungeon simulation render-loop contract", () => {
|
||||||
|
it("leaves React Three Fiber in charge of rendering each frame", () => {
|
||||||
|
expect(componentSource).toContain("useFrame(() => {");
|
||||||
|
expect(componentSource).not.toMatch(/useFrame\([\s\S]*?\},\s*[1-9]\d*\s*\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -436,7 +436,9 @@ export function DungeonSimulationSceneBridge() {
|
|||||||
}
|
}
|
||||||
if (simulation.runState === "finished") return;
|
if (simulation.runState === "finished") return;
|
||||||
inspectLiveSimulation(cursorRef.current);
|
inspectLiveSimulation(cursorRef.current);
|
||||||
}, 1);
|
// Keep the default frame priority. A positive priority takes ownership of
|
||||||
|
// R3F rendering and would require this bridge to call gl.render() itself.
|
||||||
|
});
|
||||||
|
|
||||||
if (sessionMode === "play") return null;
|
if (sessionMode === "play") return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { useRapier } from "@react-three/rapier";
|
import { useRapier } from "@react-three/rapier";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Vector3 } from "three";
|
import { Quaternion, Vector3 } from "three";
|
||||||
import { consumeMouseLook } from "../game/inputManager";
|
import { consumeMouseLook } from "../game/inputManager";
|
||||||
|
import { cameraYawAfterHorizontalLook, DEFAULT_CAMERA_PITCH } from "../game/inputMath";
|
||||||
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
|
||||||
import { usePartyStore } from "../game/partyStore";
|
import { usePartyStore } from "../game/partyStore";
|
||||||
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
|
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
|
||||||
@@ -12,9 +13,16 @@ import {
|
|||||||
spectatorCameraTranslation,
|
spectatorCameraTranslation,
|
||||||
spectatorKeyboardAxes,
|
spectatorKeyboardAxes,
|
||||||
} from "../game/spectatorCameraMath";
|
} from "../game/spectatorCameraMath";
|
||||||
|
import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision";
|
||||||
|
import { DUNGEON_SIGHT_QUERY_GROUPS } from "./dungeonLineOfSight";
|
||||||
|
|
||||||
const FOLLOW_DISTANCE = 7;
|
const FOLLOW_DISTANCE = 3.2;
|
||||||
const FOLLOW_HEIGHT = 2.4;
|
const FOLLOW_FOCUS_HEIGHT = 1.3;
|
||||||
|
const FOLLOW_VERTICAL_OFFSET = 0.35;
|
||||||
|
const FOLLOW_LOOK_AHEAD = 1.25;
|
||||||
|
const CAMERA_COLLISION_RADIUS = 0.24;
|
||||||
|
const CAMERA_WALL_GAP = 0.08;
|
||||||
|
const CAMERA_PULLBACK_DAMPING = 7;
|
||||||
const LOOK_SENSITIVITY = 0.0021;
|
const LOOK_SENSITIVITY = 0.0021;
|
||||||
const SPECTATOR_FLIGHT_KEYS = new Set([
|
const SPECTATOR_FLIGHT_KEYS = new Set([
|
||||||
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space",
|
"KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space",
|
||||||
@@ -25,11 +33,21 @@ export function DungeonSpectatorCamera() {
|
|||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const { rapier, world } = useRapier();
|
const { rapier, world } = useRapier();
|
||||||
const yawRef = useRef(useGameStore.getState().cameraYaw);
|
const yawRef = useRef(useGameStore.getState().cameraYaw);
|
||||||
const pitchRef = useRef(-0.22);
|
const orbitPitchRef = useRef(DEFAULT_CAMERA_PITCH);
|
||||||
|
const freePitchRef = useRef(-DEFAULT_CAMERA_PITCH);
|
||||||
const freePositionRef = useRef(new Vector3().copy(camera.position));
|
const freePositionRef = useRef(new Vector3().copy(camera.position));
|
||||||
const pressedKeysRef = useRef(new Set<string>());
|
const pressedKeysRef = useRef(new Set<string>());
|
||||||
const initializedFreeRef = useRef(false);
|
const initializedFreeRef = useRef(false);
|
||||||
const followedMemberRef = useRef<string | null>(null);
|
const followedMemberRef = useRef<string | null>(null);
|
||||||
|
const boomDistanceRef = useRef(FOLLOW_DISTANCE);
|
||||||
|
const cameraShape = useMemo(() => new rapier.Ball(CAMERA_COLLISION_RADIUS), [rapier]);
|
||||||
|
const shapeRotation = useMemo(() => new Quaternion(), []);
|
||||||
|
const focus = useMemo(() => new Vector3(), []);
|
||||||
|
const desired = useMemo(() => new Vector3(), []);
|
||||||
|
const boomDirection = useMemo(() => new Vector3(), []);
|
||||||
|
const safeDesired = useMemo(() => new Vector3(), []);
|
||||||
|
const lookAt = useMemo(() => new Vector3(), []);
|
||||||
|
const forward = useMemo(() => new Vector3(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -58,25 +76,28 @@ export function DungeonSpectatorCamera() {
|
|||||||
const simulation = useDungeonSimulationStore.getState();
|
const simulation = useDungeonSimulationStore.getState();
|
||||||
if (simulation.sessionMode === "play") return;
|
if (simulation.sessionMode === "play") return;
|
||||||
const [mouseX, mouseY] = consumeMouseLook();
|
const [mouseX, mouseY] = consumeMouseLook();
|
||||||
yawRef.current -= mouseX * LOOK_SENSITIVITY;
|
|
||||||
pitchRef.current = Math.max(-1.35, Math.min(1.15, pitchRef.current - mouseY * LOOK_SENSITIVITY));
|
|
||||||
useGameStore.setState({ cameraYaw: yawRef.current });
|
|
||||||
|
|
||||||
if (simulation.cameraMode === "free-fly") {
|
if (simulation.cameraMode === "free-fly") {
|
||||||
if (!initializedFreeRef.current) {
|
if (!initializedFreeRef.current) {
|
||||||
freePositionRef.current.copy(camera.position);
|
freePositionRef.current.copy(camera.position);
|
||||||
|
camera.getWorldDirection(forward);
|
||||||
|
yawRef.current = Math.atan2(forward.x, forward.z);
|
||||||
|
freePitchRef.current = Math.asin(Math.max(-1, Math.min(1, forward.y)));
|
||||||
initializedFreeRef.current = true;
|
initializedFreeRef.current = true;
|
||||||
}
|
}
|
||||||
|
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, mouseX * LOOK_SENSITIVITY);
|
||||||
|
freePitchRef.current = Math.max(-1.35, Math.min(1.15, freePitchRef.current - mouseY * LOOK_SENSITIVITY));
|
||||||
|
useGameStore.setState({ cameraYaw: yawRef.current });
|
||||||
followedMemberRef.current = null;
|
followedMemberRef.current = null;
|
||||||
const forward = new Vector3(
|
forward.set(
|
||||||
Math.sin(yawRef.current) * Math.cos(pitchRef.current),
|
Math.sin(yawRef.current) * Math.cos(freePitchRef.current),
|
||||||
Math.sin(pitchRef.current),
|
Math.sin(freePitchRef.current),
|
||||||
Math.cos(yawRef.current) * Math.cos(pitchRef.current),
|
Math.cos(yawRef.current) * Math.cos(freePitchRef.current),
|
||||||
);
|
);
|
||||||
const keyboard = spectatorKeyboardAxes(pressedKeysRef.current);
|
const keyboard = spectatorKeyboardAxes(pressedKeysRef.current);
|
||||||
const movement = spectatorCameraTranslation(
|
const movement = spectatorCameraTranslation(
|
||||||
yawRef.current,
|
yawRef.current,
|
||||||
pitchRef.current,
|
freePitchRef.current,
|
||||||
keyboard,
|
keyboard,
|
||||||
delta,
|
delta,
|
||||||
spectatorCameraBoosted(pressedKeysRef.current),
|
spectatorCameraBoosted(pressedKeysRef.current),
|
||||||
@@ -85,11 +106,18 @@ export function DungeonSpectatorCamera() {
|
|||||||
freePositionRef.current.y += movement[1];
|
freePositionRef.current.y += movement[1];
|
||||||
freePositionRef.current.z += movement[2];
|
freePositionRef.current.z += movement[2];
|
||||||
camera.position.copy(freePositionRef.current);
|
camera.position.copy(freePositionRef.current);
|
||||||
camera.lookAt(new Vector3().copy(camera.position).add(forward));
|
lookAt.copy(camera.position).add(forward);
|
||||||
|
camera.lookAt(lookAt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initializedFreeRef.current) {
|
||||||
|
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, -freePitchRef.current));
|
||||||
|
}
|
||||||
initializedFreeRef.current = false;
|
initializedFreeRef.current = false;
|
||||||
|
yawRef.current = cameraYawAfterHorizontalLook(yawRef.current, mouseX * LOOK_SENSITIVITY);
|
||||||
|
orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, orbitPitchRef.current + mouseY * LOOK_SENSITIVITY));
|
||||||
|
useGameStore.setState({ cameraYaw: yawRef.current });
|
||||||
const party = usePartyStore.getState();
|
const party = usePartyStore.getState();
|
||||||
const requested = party.members.find((member) => member.id === simulation.spectatedMemberId);
|
const requested = party.members.find((member) => member.id === simulation.spectatedMemberId);
|
||||||
const candidates = requested
|
const candidates = requested
|
||||||
@@ -98,20 +126,68 @@ export function DungeonSpectatorCamera() {
|
|||||||
const followed = candidates.map((member) => ({ member, position: getPartyRuntimePosition(member.id) }))
|
const followed = candidates.map((member) => ({ member, position: getPartyRuntimePosition(member.id) }))
|
||||||
.find((candidate) => candidate.position !== null);
|
.find((candidate) => candidate.position !== null);
|
||||||
if (!followed?.position) return;
|
if (!followed?.position) return;
|
||||||
const anchor = new Vector3(followed.position[0], followed.position[1] + FOLLOW_HEIGHT, followed.position[2]);
|
focus.set(
|
||||||
const backward = new Vector3(
|
followed.position[0],
|
||||||
-Math.sin(yawRef.current) * Math.cos(pitchRef.current),
|
followed.position[1] + FOLLOW_FOCUS_HEIGHT,
|
||||||
-Math.sin(pitchRef.current),
|
followed.position[2],
|
||||||
-Math.cos(yawRef.current) * Math.cos(pitchRef.current),
|
);
|
||||||
).normalize();
|
const horizontalDistance = FOLLOW_DISTANCE * Math.cos(orbitPitchRef.current);
|
||||||
const ray = new rapier.Ray(anchor, backward);
|
desired.set(
|
||||||
const hit = world.castRay(ray, FOLLOW_DISTANCE, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS);
|
focus.x - Math.sin(yawRef.current) * horizontalDistance,
|
||||||
const distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_DISTANCE;
|
focus.y + FOLLOW_VERTICAL_OFFSET + FOLLOW_DISTANCE * Math.sin(orbitPitchRef.current),
|
||||||
const desired = anchor.clone().addScaledVector(backward, distance);
|
focus.z - Math.cos(yawRef.current) * horizontalDistance,
|
||||||
if (followedMemberRef.current !== followed.member.id) camera.position.copy(desired);
|
);
|
||||||
else camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
|
boomDirection.copy(desired).sub(focus);
|
||||||
|
const fullDistance = boomDirection.length();
|
||||||
|
if (!Number.isFinite(fullDistance) || fullDistance <= 1e-5) return;
|
||||||
|
boomDirection.multiplyScalar(1 / fullDistance);
|
||||||
|
const hit = world.castShape(
|
||||||
|
focus,
|
||||||
|
shapeRotation,
|
||||||
|
boomDirection,
|
||||||
|
cameraShape,
|
||||||
|
CAMERA_WALL_GAP,
|
||||||
|
fullDistance,
|
||||||
|
false,
|
||||||
|
rapier.QueryFilterFlags.EXCLUDE_SENSORS,
|
||||||
|
ACTOR_TERRAIN_QUERY_GROUPS,
|
||||||
|
);
|
||||||
|
const timeOfImpact = hit?.time_of_impact;
|
||||||
|
const physicalDistance = typeof timeOfImpact === "number" && Number.isFinite(timeOfImpact)
|
||||||
|
? Math.max(0, Math.min(fullDistance, timeOfImpact))
|
||||||
|
: fullDistance;
|
||||||
|
// WMO sight blockers more closely match the visible wall shell than the
|
||||||
|
// simplified physical terrain. Include them in the boom test so a camera
|
||||||
|
// cannot sit behind a rendered wall while HTML nameplates remain visible.
|
||||||
|
const sightHit = world.castRay(
|
||||||
|
new rapier.Ray(focus, boomDirection),
|
||||||
|
fullDistance,
|
||||||
|
true,
|
||||||
|
rapier.QueryFilterFlags.EXCLUDE_DYNAMIC | rapier.QueryFilterFlags.EXCLUDE_KINEMATIC,
|
||||||
|
DUNGEON_SIGHT_QUERY_GROUPS,
|
||||||
|
);
|
||||||
|
const sightDistance = sightHit && Number.isFinite(sightHit.timeOfImpact)
|
||||||
|
? Math.max(0, Math.min(fullDistance, sightHit.timeOfImpact - CAMERA_COLLISION_RADIUS - CAMERA_WALL_GAP))
|
||||||
|
: fullDistance;
|
||||||
|
const allowedDistance = Math.min(physicalDistance, sightDistance);
|
||||||
|
if (followedMemberRef.current !== followed.member.id || !Number.isFinite(boomDistanceRef.current)) {
|
||||||
|
boomDistanceRef.current = allowedDistance;
|
||||||
|
} else if (allowedDistance < boomDistanceRef.current) {
|
||||||
|
boomDistanceRef.current = allowedDistance;
|
||||||
|
} else {
|
||||||
|
const pullbackAlpha = 1 - Math.exp(-CAMERA_PULLBACK_DAMPING * Math.min(delta, 0.05));
|
||||||
|
boomDistanceRef.current += (allowedDistance - boomDistanceRef.current) * pullbackAlpha;
|
||||||
|
}
|
||||||
|
safeDesired.copy(focus).addScaledVector(boomDirection, boomDistanceRef.current);
|
||||||
|
if (followedMemberRef.current !== followed.member.id) camera.position.copy(safeDesired);
|
||||||
|
else camera.position.lerp(safeDesired, 1 - Math.exp(-12 * Math.min(delta, 0.1)));
|
||||||
followedMemberRef.current = followed.member.id;
|
followedMemberRef.current = followed.member.id;
|
||||||
camera.lookAt(anchor);
|
lookAt.set(
|
||||||
|
focus.x + Math.sin(yawRef.current) * FOLLOW_LOOK_AHEAD,
|
||||||
|
focus.y + 0.08,
|
||||||
|
focus.z + Math.cos(yawRef.current) * FOLLOW_LOOK_AHEAD,
|
||||||
|
);
|
||||||
|
camera.lookAt(lookAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in new issue
Block a user