feat: anchor companion menu above selected character

This commit is contained in:
2026-11-02 12:21:06 +09:00
parent db945a41cb
commit 1581b7a64a
7 changed files with 84 additions and 25 deletions
+3 -3
View File
@@ -87,13 +87,13 @@
</fieldset> </fieldset>
</div> </div>
</div> </div>
<div class="movement-controls" aria-label="위치와 방향 조절"> <div id="companion-selection" role="group" aria-label="선택한 캐릭터" hidden>
<div id="companion-selection" hidden>
<span id="companion-name"></span> <span id="companion-name"></span>
<button type="button" id="stand-beside">옆에 서기</button> <button type="button" id="stand-beside">옆에 서기</button>
<button type="button" id="companion-close" aria-label="선택 닫기">×</button> <button type="button" id="companion-close" aria-label="선택 닫기">×</button>
<p id="companion-status" role="status"></p> <p id="companion-status" role="status"></p>
</div> </div>
<div class="movement-controls" aria-label="위치와 방향 조절">
<p id="direction-hint" role="status" hidden>좌우로 드래그하면 내 캐릭터만 돌아갑니다</p> <p id="direction-hint" role="status" hidden>좌우로 드래그하면 내 캐릭터만 돌아갑니다</p>
<button type="button" id="direction-toggle" aria-pressed="false" aria-describedby="direction-hint" disabled>방향 맞추기</button> <button type="button" id="direction-toggle" aria-pressed="false" aria-describedby="direction-hint" disabled>방향 맞추기</button>
</div> </div>
+1
View File
@@ -13,3 +13,4 @@ export const EQUIPMENT_NOTICE = {
} as const; } as const;
export const PRAYER_NOTICE = '일출 전후 5분에 사용할 수 있습니다'; export const PRAYER_NOTICE = '일출 전후 5분에 사용할 수 있습니다';
export const COMPANION_MENU = { margin: 12 } as const;
+10 -4
View File
@@ -7,7 +7,7 @@ import type { SunState } from '../astronomy/sunCalculator.ts';
import { directionFromAngles } from '../astronomy/direction.ts'; import { directionFromAngles } from '../astronomy/direction.ts';
import { observer } from '../config/observer.ts'; import { observer } from '../config/observer.ts';
import { SCENE } from '../constants/scene.ts'; import { SCENE } from '../constants/scene.ts';
import { EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts'; import { COMPANION_MENU, EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts';
import { MOVEMENT } from '../constants/movement.ts'; import { MOVEMENT } from '../constants/movement.ts';
import { CHARACTER } from '../constants/character.ts'; import { CHARACTER } from '../constants/character.ts';
import { createFpsCameraController } from '../controls/fpsCameraController.ts'; import { createFpsCameraController } from '../controls/fpsCameraController.ts';
@@ -51,6 +51,7 @@ import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCame
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts'; import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts'; import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
import { createSelfBadge } from '../ui/selfBadge.ts'; import { createSelfBadge } from '../ui/selfBadge.ts';
import { createCharacterOverlay } from '../ui/characterOverlay.ts';
import { createFlashlight } from './flashlight.ts'; import { createFlashlight } from './flashlight.ts';
import { FLASHLIGHT } from '../constants/flashlight.ts'; import { FLASHLIGHT } from '../constants/flashlight.ts';
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts'; import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
@@ -171,13 +172,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
const pointer = new Vector2(); const pointer = new Vector2();
let cameraDistance: number = CHARACTER.cameraDistance; let cameraDistance: number = CHARACTER.cameraDistance;
let selectedCompanion: string | undefined; let selectedCompanion: string | undefined;
let companionAnchor: ReturnType<typeof createCharacterOverlay> | undefined;
const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!; const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!;
const directionHint = document.getElementById('direction-hint')!; const directionHint = document.getElementById('direction-hint')!;
const companionPanel = document.getElementById('companion-selection')!; const companionPanel = document.getElementById('companion-selection')!;
const companionName = document.getElementById('companion-name')!; const companionName = document.getElementById('companion-name')!;
const companionStatus = document.getElementById('companion-status')!; const companionStatus = document.getElementById('companion-status')!;
const besideButton = document.querySelector<HTMLButtonElement>('#stand-beside')!; const besideButton = document.querySelector<HTMLButtonElement>('#stand-beside')!;
const closeCompanion = () => { selectedCompanion = undefined; companionPanel.hidden = true; companionStatus.textContent = ''; }; const closeCompanion = () => { selectedCompanion = undefined; companionAnchor = undefined; companionPanel.hidden = true; companionStatus.textContent = ''; };
const pickGround = (x: number, y: number) => { const pickGround = (x: number, y: number) => {
const bounds = canvas.getBoundingClientRect(); const bounds = canvas.getBoundingClientRect();
pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1); pointer.set((x - bounds.left) / bounds.width * 2 - 1, -(y - bounds.top) / bounds.height * 2 + 1);
@@ -212,8 +214,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
closeCompanion(); closeCompanion();
if (!id) return false; if (!id) return false;
selectedCompanion = id; selectedCompanion = id;
companionName.textContent = remotePlayers!.get(id)!.name; const player = remotePlayers!.get(id)!;
companionPanel.hidden = false; companionName.textContent = player.name;
companionAnchor = createCharacterOverlay(player, canvas, companionPanel, COMPANION_MENU.margin);
rendering.invalidate();
return true; return true;
}, },
}); });
@@ -456,6 +460,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance); updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
} }
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera; const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
companionAnchor?.update(activeCamera, !overview);
flashlight?.update(flashlightOn && !overview); flashlight?.update(flashlightOn && !overview);
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall, const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height); MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
@@ -620,6 +625,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
}, },
dispose() { dispose() {
disposed = true; disposed = true;
closeCompanion();
profile?.dispose(); profile?.dispose();
renderer.setAnimationLoop(null); renderer.setAnimationLoop(null);
minimap.dispose(); minimap.dispose();
+1 -1
View File
@@ -58,7 +58,7 @@ button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
.movement-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: calc(max(24px, env(safe-area-inset-bottom)) + 96px); display: flex; flex-direction: column; align-items: flex-end; gap: 8px; max-width: calc(100vw - 36px); } .movement-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: calc(max(24px, env(safe-area-inset-bottom)) + 96px); display: flex; flex-direction: column; align-items: flex-end; gap: 8px; max-width: calc(100vw - 36px); }
.movement-controls button { pointer-events: auto; } .movement-controls button { pointer-events: auto; }
#direction-hint, #companion-selection { margin: 0; padding: 10px 12px; border: 1px solid #ffffff26; border-radius: 14px; background: #18232ef2; font-size: 12px; line-height: 1.5; } #direction-hint, #companion-selection { margin: 0; padding: 10px 12px; border: 1px solid #ffffff26; border-radius: 14px; background: #18232ef2; font-size: 12px; line-height: 1.5; }
#companion-selection { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; pointer-events: auto; } #companion-selection { position: fixed; transform: translate(-50%, -100%); display: flex; align-items: center; flex-wrap: wrap; gap: 8px; width: max-content; max-width: calc(100vw - 24px); pointer-events: auto; }
#companion-name { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #companion-name { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#companion-status { flex-basis: 100%; margin: 0; } #companion-status { flex-basis: 100%; margin: 0; }
#companion-status:empty { display: none; } #companion-status:empty { display: none; }
+31
View File
@@ -0,0 +1,31 @@
import { Box3, Vector3, type Camera, type Object3D } from 'three';
import { CHARACTER } from '../constants/character.ts';
/** Position a screen-sized element above a character's head. */
export function createCharacterOverlay(player: Object3D, canvas: HTMLCanvasElement, element: HTMLElement, margin = 0) {
const head = player.getObjectByName('Head')!;
const point = new Vector3();
player.updateWorldMatrix(true, true);
const headTop = new Box3().setFromObject(head).max.y - head.getWorldPosition(point).y;
return {
update(camera: Camera, visible: boolean) {
if (!visible) { element.hidden = true; return; }
head.getWorldPosition(point);
point.y += headTop + CHARACTER.selfBadgeGap;
camera.updateMatrixWorld(true);
point.project(camera);
element.hidden = point.z < -1 || point.z > 1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1;
if (element.hidden) return;
const rect = canvas.getBoundingClientRect();
let x = rect.left + (point.x + 1) * rect.width / 2;
let y = rect.top + (1 - point.y) * rect.height / 2;
if (margin > 0) {
const halfWidth = element.offsetWidth / 2;
x = Math.max(rect.left + margin + halfWidth, Math.min(x, rect.right - margin - halfWidth));
y = Math.max(rect.top + margin + element.offsetHeight, Math.min(y, rect.bottom - margin));
}
element.style.left = `${x}px`;
element.style.top = `${y}px`;
},
};
}
+3 -17
View File
@@ -1,12 +1,8 @@
import { Box3, Vector3, type Camera, type Object3D } from 'three'; import { type Object3D } from 'three';
import { CHARACTER } from '../constants/character.ts'; import { createCharacterOverlay } from './characterOverlay.ts';
/** A screen-sized label attached only to the local player's head. */ /** A screen-sized label attached only to the local player's head. */
export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) { export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
const head = player.getObjectByName('Head')!;
const point = new Vector3();
player.updateWorldMatrix(true, true);
const headTop = new Box3().setFromObject(head).max.y - head.getWorldPosition(point).y;
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'self-badge'; badge.className = 'self-badge';
badge.textContent = '나'; badge.textContent = '나';
@@ -14,17 +10,7 @@ export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
badge.hidden = true; badge.hidden = true;
document.querySelector('.hud')!.append(badge); document.querySelector('.hud')!.append(badge);
return { return {
update(camera: Camera, visible: boolean) { ...createCharacterOverlay(player, canvas, badge),
if (!visible) { badge.hidden = true; return; }
head.getWorldPosition(point);
point.y += headTop + CHARACTER.selfBadgeGap;
point.project(camera);
badge.hidden = point.z < -1 || point.z > 1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1;
if (badge.hidden) return;
const rect = canvas.getBoundingClientRect();
badge.style.left = `${rect.left + (point.x + 1) * rect.width / 2}px`;
badge.style.top = `${rect.top + (1 - point.y) * rect.height / 2}px`;
},
dispose() { badge.remove(); }, dispose() { badge.remove(); },
}; };
} }
+35
View File
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three';
import { createCharacterOverlay } from '../src/ui/characterOverlay.ts';
test('character menu follows its subject, stays inside the viewport and hides behind the camera', () => {
const player = new Group(), head = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial());
head.name = 'Head'; head.position.y = 1; player.add(head);
const camera = new PerspectiveCamera(60, 1, .1, 100);
camera.position.set(0, 2, 5); camera.lookAt(0, 1, 0); camera.updateMatrixWorld(true);
const canvas = { getBoundingClientRect: () => ({ left: 0, top: 0, right: 320, bottom: 320, width: 320, height: 320 }) } as unknown as HTMLCanvasElement;
const panel = { hidden: true, style: { left: '', top: '' }, offsetWidth: 220, offsetHeight: 80 } as unknown as HTMLElement;
const overlay = createCharacterOverlay(player, canvas, panel, 12);
overlay.update(camera, true);
assert.equal(panel.hidden, false);
assert.equal(parseFloat(panel.style.left), 160);
const firstTop = parseFloat(panel.style.top);
player.position.x = 1;
overlay.update(camera, true);
assert.ok(parseFloat(panel.style.left) > 160);
assert.ok(parseFloat(panel.style.left) + panel.offsetWidth / 2 <= 308);
player.position.y = 1;
overlay.update(camera, true);
assert.ok(parseFloat(panel.style.top) < firstTop);
assert.ok(parseFloat(panel.style.top) - panel.offsetHeight >= 12);
player.position.z = 10;
overlay.update(camera, true);
assert.equal(panel.hidden, true);
player.position.set(0, 0, 0);
overlay.update(camera, false);
assert.equal(panel.hidden, true);
overlay.update(camera, true);
assert.equal(panel.hidden, false);
head.geometry.dispose(); (head.material as MeshBasicMaterial).dispose();
});