diff --git a/index.html b/index.html index ba95e1b..adc4e34 100644 --- a/index.html +++ b/index.html @@ -87,13 +87,13 @@ -
- +
diff --git a/src/constants/ui.ts b/src/constants/ui.ts index 2b3c583..de591c5 100644 --- a/src/constants/ui.ts +++ b/src/constants/ui.ts @@ -13,3 +13,4 @@ export const EQUIPMENT_NOTICE = { } as const; export const PRAYER_NOTICE = '일출 전후 5분에 사용할 수 있습니다'; +export const COMPANION_MENU = { margin: 12 } as const; diff --git a/src/scene/world.ts b/src/scene/world.ts index 488c989..9103823 100644 --- a/src/scene/world.ts +++ b/src/scene/world.ts @@ -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 | undefined; const directionButton = document.querySelector('#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('#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 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 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`; + }, + }; +} diff --git a/src/ui/selfBadge.ts b/src/ui/selfBadge.ts index 40db576..7897b3d 100644 --- a/src/ui/selfBadge.ts +++ b/src/ui/selfBadge.ts @@ -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(); }, }; } diff --git a/tests/character-overlay.test.ts b/tests/character-overlay.test.ts new file mode 100644 index 0000000..b74147e --- /dev/null +++ b/tests/character-overlay.test.ts @@ -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(); +});