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();
+});