feat: anchor companion menu above selected character
This commit is contained in:
+2
-2
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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 { 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(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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