Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1581b7a64a | ||
|
|
db945a41cb | ||
|
|
8b6d407507 |
@@ -10,6 +10,7 @@ fi
|
|||||||
|
|
||||||
# Check the service account before replacing dist.
|
# Check the service account before replacing dist.
|
||||||
sunrise_group=$(id -gn sunrise)
|
sunrise_group=$(id -gn sunrise)
|
||||||
|
git pull --ff-only
|
||||||
npm run build
|
npm run build
|
||||||
install -d -o sunrise -g "$sunrise_group" -m 755 dist/threads-captures
|
install -d -o sunrise -g "$sunrise_group" -m 755 dist/threads-captures
|
||||||
systemctl restart sunrise-threads.service
|
systemctl restart sunrise-threads.service
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
|
|||||||
@@ -25,5 +25,14 @@ export const CHARACTER = {
|
|||||||
torsoDepth: 0.014,
|
torsoDepth: 0.014,
|
||||||
headLift: 0.018,
|
headLift: 0.018,
|
||||||
shoulderLift: 0.008,
|
shoulderLift: 0.008,
|
||||||
|
fidget: {
|
||||||
|
minimumWaitSeconds: 8,
|
||||||
|
maximumWaitSeconds: 15,
|
||||||
|
scratchSeconds: 2.4,
|
||||||
|
hopIntervalSeconds: 60,
|
||||||
|
hopSeconds: 1,
|
||||||
|
hopHeight: 0.085,
|
||||||
|
hopCrouch: 0.025,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
.filter((part): part is Object3D => Boolean(part))
|
.filter((part): part is Object3D => Boolean(part))
|
||||||
.map(part => ({ part, position: part.position.clone(), scale: part.scale.clone() }));
|
.map(part => ({ part, position: part.position.clone(), scale: part.scale.clone() }));
|
||||||
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const modelRestPosition = model.position.clone();
|
||||||
const originals: Object3D[] = [];
|
const originals: Object3D[] = [];
|
||||||
group.traverse(object => originals.push(object));
|
group.traverse(object => originals.push(object));
|
||||||
function bindRig(group: Group, ownsAssets: boolean): Sheep {
|
function bindRig(group: Group, ownsAssets: boolean): Sheep {
|
||||||
@@ -123,7 +124,14 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
const rigFlashlight = copy(gear?.flashlight);
|
const rigFlashlight = copy(gear?.flashlight);
|
||||||
const rigBreathingParts = breathingParts.map(rest => ({ ...rest, part: copy(rest.part)! }));
|
const rigBreathingParts = breathingParts.map(rest => ({ ...rest, part: copy(rest.part)! }));
|
||||||
const rigBody = copy(body), rigCoat = copy(gear?.raincoat);
|
const rigBody = copy(body), rigCoat = copy(gear?.raincoat);
|
||||||
|
const rigModel = copy(model)!;
|
||||||
const breathPhase = Math.random() * Math.PI * 2;
|
const breathPhase = Math.random() * Math.PI * 2;
|
||||||
|
const fidget = CHARACTER.idle.fidget;
|
||||||
|
const nextFidgetDelay = () => MathUtils.lerp(fidget.minimumWaitSeconds, fidget.maximumWaitSeconds, Math.random());
|
||||||
|
let fidgetWait = nextFidgetDelay();
|
||||||
|
let hopWait: number = fidget.hopIntervalSeconds;
|
||||||
|
let fidgetTime = 0;
|
||||||
|
let fidgetAction: 'scratch' | 'hop' | undefined;
|
||||||
let idleWeight = 0;
|
let idleWeight = 0;
|
||||||
let flashlightOn = false;
|
let flashlightOn = false;
|
||||||
let flashlightPose = 0;
|
let flashlightPose = 0;
|
||||||
@@ -163,6 +171,35 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
|
||||||
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
||||||
const idle = animateIdle && !moving && emote === 'idle';
|
const idle = animateIdle && !moving && emote === 'idle';
|
||||||
|
rigModel.position.copy(modelRestPosition);
|
||||||
|
if (!idle) {
|
||||||
|
fidgetWait = nextFidgetDelay();
|
||||||
|
hopWait = fidget.hopIntervalSeconds;
|
||||||
|
fidgetTime = 0;
|
||||||
|
fidgetAction = undefined;
|
||||||
|
} else {
|
||||||
|
// Do not fast-forward gestures after a background tab resumes.
|
||||||
|
const idleDelta = Math.min(delta, .1);
|
||||||
|
hopWait -= idleDelta;
|
||||||
|
if (hopWait <= 0) {
|
||||||
|
fidgetAction = 'hop';
|
||||||
|
fidgetTime = 0;
|
||||||
|
hopWait += fidget.hopIntervalSeconds;
|
||||||
|
} else if (!fidgetAction) {
|
||||||
|
fidgetWait -= idleDelta;
|
||||||
|
if (fidgetWait <= 0) {
|
||||||
|
fidgetAction = 'scratch';
|
||||||
|
fidgetTime = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fidgetTime += idleDelta;
|
||||||
|
const duration = fidgetAction === 'scratch' ? fidget.scratchSeconds : fidget.hopSeconds;
|
||||||
|
if (fidgetTime >= duration) {
|
||||||
|
fidgetAction = undefined;
|
||||||
|
fidgetWait = nextFidgetDelay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const previousIdleWeight = idleWeight;
|
const previousIdleWeight = idleWeight;
|
||||||
const idleStep = delta / CHARACTER.idle.transitionSeconds;
|
const idleStep = delta / CHARACTER.idle.transitionSeconds;
|
||||||
idleWeight = idle ? Math.min(1, idleWeight + idleStep) : Math.max(0, idleWeight - idleStep);
|
idleWeight = idle ? Math.min(1, idleWeight + idleStep) : Math.max(0, idleWeight - idleStep);
|
||||||
@@ -209,6 +246,39 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
|
|||||||
joints.LegRight.rotateX(-stride);
|
joints.LegRight.rotateX(-stride);
|
||||||
joints.ArmLeft.rotateX(-stride * .6);
|
joints.ArmLeft.rotateX(-stride * .6);
|
||||||
joints.ArmRight.rotateX(stride * .6);
|
joints.ArmRight.rotateX(stride * .6);
|
||||||
|
} else if (fidgetAction === 'scratch') {
|
||||||
|
const blend = MathUtils.smoothstep(fidgetTime, 0, .4)
|
||||||
|
* (1 - MathUtils.smoothstep(fidgetTime, fidget.scratchSeconds - .4, fidget.scratchSeconds));
|
||||||
|
const left = carryingUmbrella;
|
||||||
|
const arm = joints[left ? 'ArmLeft' : 'ArmRight'];
|
||||||
|
// Only scratch with an empty hand, including while equipment blends out.
|
||||||
|
if (!left || (!flashlightOn && flashlightPose === 0)) {
|
||||||
|
pointArm(left ? 'ArmLeft' : 'ArmRight', aim.set(left ? -.3 : .3, .95, .12 + Math.sin(fidgetTime * 22) * .12));
|
||||||
|
arm.quaternion.slerp(rest[left ? 'ArmLeft' : 'ArmRight'], 1 - blend);
|
||||||
|
joints.Head.rotateZ((left ? -.1 : .1) * blend);
|
||||||
|
}
|
||||||
|
} else if (fidgetAction === 'hop') {
|
||||||
|
const progress = fidgetTime / fidget.hopSeconds * 2;
|
||||||
|
const phase = progress % 1;
|
||||||
|
const strength = progress < 1 ? 1 : .7;
|
||||||
|
// Each small hop has a planted preparation, a short flight and a soft landing.
|
||||||
|
const flight = MathUtils.clamp((phase - .24) / .48, 0, 1);
|
||||||
|
const bounce = 4 * flight * (1 - flight) * strength;
|
||||||
|
const crouch = (phase < .24 ? Math.sin(phase / .24 * Math.PI)
|
||||||
|
: phase > .72 ? Math.sin((phase - .72) / .28 * Math.PI) : 0) * strength;
|
||||||
|
rigModel.position.y += bounce * fidget.hopHeight;
|
||||||
|
for (const { part } of rigBreathingParts) {
|
||||||
|
if (part === rigBody || part === rigCoat) {
|
||||||
|
part.scale.y *= 1 - crouch * fidget.hopCrouch;
|
||||||
|
if (part === rigCoat && body) part.position.y += body.position.y * crouch * fidget.hopCrouch;
|
||||||
|
} else {
|
||||||
|
part.position.y -= crouch * fidget.hopCrouch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
joints.LegLeft.rotateX(-bounce * .1);
|
||||||
|
joints.LegRight.rotateX(-bounce * .1);
|
||||||
|
joints.ArmLeft.rotateZ(-bounce * .08);
|
||||||
|
joints.ArmRight.rotateZ(bounce * .08);
|
||||||
}
|
}
|
||||||
// Keep the grip stable while the free arm and legs perform their own animations.
|
// Keep the grip stable while the free arm and legs perform their own animations.
|
||||||
if (umbrellaPose > 0) {
|
if (umbrellaPose > 0) {
|
||||||
|
|||||||
+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