From 39425f2494769b4a6e739f3b59a8ca881c3d1d9b Mon Sep 17 00:00:00 2001 From: phenom Date: Wed, 16 Sep 2026 20:58:10 -0400 Subject: [PATCH] Release Healer Man 0.1.48 --- package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/playtest-boss-testing.cjs | 32 +++++ scripts/playtest-dungeon-simulation.cjs | 37 ++++- scripts/playtest-mouse-camera.cjs | 32 +++++ .../DungeonSimulationSceneBridge.test.ts | 14 ++ src/scene/DungeonSimulationSceneBridge.tsx | 4 +- src/scene/DungeonSpectatorCamera.tsx | 130 ++++++++++++++---- 9 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 src/scene/DungeonSimulationSceneBridge.test.ts diff --git a/package-lock.json b/package-lock.json index 19c98ab1..bc50e30f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.47", + "version": "0.1.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.47", + "version": "0.1.48", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 0b05c678..87764818 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.47", + "version": "0.1.48", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 467d07c5..e5101389 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.47", - "versionCode": 1047 + "versionName": "0.1.48", + "versionCode": 1048 } diff --git a/scripts/playtest-boss-testing.cjs b/scripts/playtest-boss-testing.cjs index d4e9af21..72772f9c 100644 --- a/scripts/playtest-boss-testing.cjs +++ b/scripts/playtest-boss-testing.cjs @@ -2,6 +2,7 @@ const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primar const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra'; const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join( '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`) }); 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 { await page.goto(base, { waitUntil: 'domcontentloaded' }); 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]); }, null, { timeout: 90000 }); 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'); const before = await page.evaluate(async () => { const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores(); diff --git a/scripts/playtest-dungeon-simulation.cjs b/scripts/playtest-dungeon-simulation.cjs index e19266e7..39cb66a3 100644 --- a/scripts/playtest-dungeon-simulation.cjs +++ b/scripts/playtest-dungeon-simulation.cjs @@ -34,7 +34,31 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true }); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); 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 { 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(); 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('e'); await page.waitForTimeout(700); - await page.keyboard.up('e'); await page.keyboard.up('w'); await page.waitForTimeout(150); - const afterFlight = await canvasHash(); - assert.notEqual(afterFlight, beforeFlight, 'W/E free-camera input changes the rendered viewpoint'); + const afterFlight = await captureCanvas(); + assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays'); const canvasBounds = await page.locator('canvas').boundingBox(); assert(canvasBounds, 'simulation canvas is visible'); const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 }; const beforeLook = await page.evaluate(() => window.readSpectatorCameraState()); await page.mouse.move(lookStart.x, lookStart.y); 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); const duringLook = await page.evaluate(() => window.readSpectatorCameraState()); 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, '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 memberButtons.nth(4).click(); diff --git a/scripts/playtest-mouse-camera.cjs b/scripts/playtest-mouse-camera.cjs index 6953c753..8ee0b138 100644 --- a/scripts/playtest-mouse-camera.cjs +++ b/scripts/playtest-mouse-camera.cjs @@ -1,4 +1,5 @@ const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); 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 = []; 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 storeEntry = performance.getEntriesByType("resource") .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 start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 }; const before = await gameState(); + const frameBefore = await canvasHash(); assert.equal(before.cameraLookActive, false); await page.mouse.move(start.x, start.y); await page.mouse.down({ button: "right" }); @@ -99,6 +126,11 @@ fs.mkdirSync(directory, { recursive: true }); await page.waitForTimeout(350); const moved = await gameState(); 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.mouse.up({ button: "right" }); diff --git a/src/scene/DungeonSimulationSceneBridge.test.ts b/src/scene/DungeonSimulationSceneBridge.test.ts new file mode 100644 index 00000000..cd721477 --- /dev/null +++ b/src/scene/DungeonSimulationSceneBridge.test.ts @@ -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*\);/); + }); +}); diff --git a/src/scene/DungeonSimulationSceneBridge.tsx b/src/scene/DungeonSimulationSceneBridge.tsx index d4952c51..fa38fd30 100644 --- a/src/scene/DungeonSimulationSceneBridge.tsx +++ b/src/scene/DungeonSimulationSceneBridge.tsx @@ -436,7 +436,9 @@ export function DungeonSimulationSceneBridge() { } if (simulation.runState === "finished") return; 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; return ( diff --git a/src/scene/DungeonSpectatorCamera.tsx b/src/scene/DungeonSpectatorCamera.tsx index 0a2e4a5c..482d1028 100644 --- a/src/scene/DungeonSpectatorCamera.tsx +++ b/src/scene/DungeonSpectatorCamera.tsx @@ -1,8 +1,9 @@ import { useFrame, useThree } from "@react-three/fiber"; import { useRapier } from "@react-three/rapier"; -import { useEffect, useRef } from "react"; -import { Vector3 } from "three"; +import { useEffect, useMemo, useRef } from "react"; +import { Quaternion, Vector3 } from "three"; import { consumeMouseLook } from "../game/inputManager"; +import { cameraYawAfterHorizontalLook, DEFAULT_CAMERA_PITCH } from "../game/inputMath"; import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry"; import { usePartyStore } from "../game/partyStore"; import { useDungeonSimulationStore } from "../game/dungeonSimulationStore"; @@ -12,9 +13,16 @@ import { spectatorCameraTranslation, spectatorKeyboardAxes, } from "../game/spectatorCameraMath"; +import { ACTOR_TERRAIN_QUERY_GROUPS } from "./actorCollision"; +import { DUNGEON_SIGHT_QUERY_GROUPS } from "./dungeonLineOfSight"; -const FOLLOW_DISTANCE = 7; -const FOLLOW_HEIGHT = 2.4; +const FOLLOW_DISTANCE = 3.2; +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 SPECTATOR_FLIGHT_KEYS = new Set([ "KeyW", "KeyA", "KeyS", "KeyD", "KeyQ", "KeyE", "Space", @@ -25,11 +33,21 @@ export function DungeonSpectatorCamera() { const camera = useThree((state) => state.camera); const { rapier, world } = useRapier(); 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 pressedKeysRef = useRef(new Set()); const initializedFreeRef = useRef(false); const followedMemberRef = useRef(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(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -58,25 +76,28 @@ export function DungeonSpectatorCamera() { const simulation = useDungeonSimulationStore.getState(); if (simulation.sessionMode === "play") return; 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 (!initializedFreeRef.current) { 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; } + 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; - const forward = new Vector3( - Math.sin(yawRef.current) * Math.cos(pitchRef.current), - Math.sin(pitchRef.current), - Math.cos(yawRef.current) * Math.cos(pitchRef.current), + forward.set( + Math.sin(yawRef.current) * Math.cos(freePitchRef.current), + Math.sin(freePitchRef.current), + Math.cos(yawRef.current) * Math.cos(freePitchRef.current), ); const keyboard = spectatorKeyboardAxes(pressedKeysRef.current); const movement = spectatorCameraTranslation( yawRef.current, - pitchRef.current, + freePitchRef.current, keyboard, delta, spectatorCameraBoosted(pressedKeysRef.current), @@ -85,11 +106,18 @@ export function DungeonSpectatorCamera() { freePositionRef.current.y += movement[1]; freePositionRef.current.z += movement[2]; camera.position.copy(freePositionRef.current); - camera.lookAt(new Vector3().copy(camera.position).add(forward)); + lookAt.copy(camera.position).add(forward); + camera.lookAt(lookAt); return; } + if (initializedFreeRef.current) { + orbitPitchRef.current = Math.max(-0.18, Math.min(0.88, -freePitchRef.current)); + } 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 requested = party.members.find((member) => member.id === simulation.spectatedMemberId); const candidates = requested @@ -98,20 +126,68 @@ export function DungeonSpectatorCamera() { const followed = candidates.map((member) => ({ member, position: getPartyRuntimePosition(member.id) })) .find((candidate) => candidate.position !== null); if (!followed?.position) return; - const anchor = new Vector3(followed.position[0], followed.position[1] + FOLLOW_HEIGHT, followed.position[2]); - const backward = new Vector3( - -Math.sin(yawRef.current) * Math.cos(pitchRef.current), - -Math.sin(pitchRef.current), - -Math.cos(yawRef.current) * Math.cos(pitchRef.current), - ).normalize(); - const ray = new rapier.Ray(anchor, backward); - const hit = world.castRay(ray, FOLLOW_DISTANCE, true, rapier.QueryFilterFlags.EXCLUDE_SENSORS); - const distance = hit ? Math.max(0.8, hit.timeOfImpact - 0.25) : FOLLOW_DISTANCE; - const desired = anchor.clone().addScaledVector(backward, distance); - if (followedMemberRef.current !== followed.member.id) camera.position.copy(desired); - else camera.position.lerp(desired, 1 - Math.exp(-12 * Math.min(delta, 0.1))); + focus.set( + followed.position[0], + followed.position[1] + FOLLOW_FOCUS_HEIGHT, + followed.position[2], + ); + const horizontalDistance = FOLLOW_DISTANCE * Math.cos(orbitPitchRef.current); + desired.set( + focus.x - Math.sin(yawRef.current) * horizontalDistance, + focus.y + FOLLOW_VERTICAL_OFFSET + FOLLOW_DISTANCE * Math.sin(orbitPitchRef.current), + focus.z - Math.cos(yawRef.current) * horizontalDistance, + ); + 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; - 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;