feat: anchor companion menu above selected character
This commit is contained in:
+2
-2
@@ -87,13 +87,13 @@
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div class="movement-controls" aria-label="위치와 방향 조절">
|
||||
<div id="companion-selection" hidden>
|
||||
<div id="companion-selection" role="group" aria-label="선택한 캐릭터" hidden>
|
||||
<span id="companion-name"></span>
|
||||
<button type="button" id="stand-beside">옆에 서기</button>
|
||||
<button type="button" id="companion-close" aria-label="선택 닫기">×</button>
|
||||
<p id="companion-status" role="status"></p>
|
||||
</div>
|
||||
<div class="movement-controls" aria-label="위치와 방향 조절">
|
||||
<p id="direction-hint" role="status" hidden>좌우로 드래그하면 내 캐릭터만 돌아갑니다</p>
|
||||
<button type="button" id="direction-toggle" aria-pressed="false" aria-describedby="direction-hint" disabled>방향 맞추기</button>
|
||||
</div>
|
||||
|
||||
@@ -13,3 +13,4 @@ export const EQUIPMENT_NOTICE = {
|
||||
} as const;
|
||||
|
||||
export const PRAYER_NOTICE = '일출 전후 5분에 사용할 수 있습니다';
|
||||
export const COMPANION_MENU = { margin: 12 } as const;
|
||||
|
||||
+10
-4
@@ -7,7 +7,7 @@ import type { SunState } from '../astronomy/sunCalculator.ts';
|
||||
import { directionFromAngles } from '../astronomy/direction.ts';
|
||||
import { observer } from '../config/observer.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 { CHARACTER } from '../constants/character.ts';
|
||||
import { createFpsCameraController } from '../controls/fpsCameraController.ts';
|
||||
@@ -51,6 +51,7 @@ import { faceTravel, updateThirdPersonCamera } from '../controls/thirdPersonCame
|
||||
import { createRemotePlayers } from '../multiplayer/remotePlayers.ts';
|
||||
import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
|
||||
import { createSelfBadge } from '../ui/selfBadge.ts';
|
||||
import { createCharacterOverlay } from '../ui/characterOverlay.ts';
|
||||
import { createFlashlight } from './flashlight.ts';
|
||||
import { FLASHLIGHT } from '../constants/flashlight.ts';
|
||||
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
|
||||
@@ -171,13 +172,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
const pointer = new Vector2();
|
||||
let cameraDistance: number = CHARACTER.cameraDistance;
|
||||
let selectedCompanion: string | undefined;
|
||||
let companionAnchor: ReturnType<typeof createCharacterOverlay> | undefined;
|
||||
const directionButton = document.querySelector<HTMLButtonElement>('#direction-toggle')!;
|
||||
const directionHint = document.getElementById('direction-hint')!;
|
||||
const companionPanel = document.getElementById('companion-selection')!;
|
||||
const companionName = document.getElementById('companion-name')!;
|
||||
const companionStatus = document.getElementById('companion-status')!;
|
||||
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 bounds = canvas.getBoundingClientRect();
|
||||
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();
|
||||
if (!id) return false;
|
||||
selectedCompanion = id;
|
||||
companionName.textContent = remotePlayers!.get(id)!.name;
|
||||
companionPanel.hidden = false;
|
||||
const player = remotePlayers!.get(id)!;
|
||||
companionName.textContent = player.name;
|
||||
companionAnchor = createCharacterOverlay(player, canvas, companionPanel, COMPANION_MENU.margin);
|
||||
rendering.invalidate();
|
||||
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);
|
||||
}
|
||||
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
|
||||
companionAnchor?.update(activeCamera, !overview);
|
||||
flashlight?.update(flashlightOn && !overview);
|
||||
const precipitating = precipitation.update(delta, activeCamera.position, atmosphere.rain, atmosphere.snowfall,
|
||||
MathUtils.smoothstep(solarState?.altitude ?? -12, -8, 15), canvas.height);
|
||||
@@ -620,6 +625,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
closeCompanion();
|
||||
profile?.dispose();
|
||||
renderer.setAnimationLoop(null);
|
||||
minimap.dispose();
|
||||
|
||||
+1
-1
@@ -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 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; }
|
||||
#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-status { flex-basis: 100%; margin: 0; }
|
||||
#companion-status:empty { display: none; }
|
||||
|
||||
@@ -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
@@ -1,12 +1,8 @@
|
||||
import { Box3, Vector3, type Camera, type Object3D } from 'three';
|
||||
import { CHARACTER } from '../constants/character.ts';
|
||||
import { type Object3D } from 'three';
|
||||
import { createCharacterOverlay } from './characterOverlay.ts';
|
||||
|
||||
/** A screen-sized label attached only to the local player's head. */
|
||||
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');
|
||||
badge.className = 'self-badge';
|
||||
badge.textContent = '나';
|
||||
@@ -14,17 +10,7 @@ export function createSelfBadge(player: Object3D, canvas: HTMLCanvasElement) {
|
||||
badge.hidden = true;
|
||||
document.querySelector('.hud')!.append(badge);
|
||||
return {
|
||||
update(camera: Camera, visible: boolean) {
|
||||
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`;
|
||||
},
|
||||
...createCharacterOverlay(player, canvas, badge),
|
||||
dispose() { badge.remove(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user