Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1581b7a64a | ||
|
|
db945a41cb | ||
|
|
8b6d407507 |
@@ -10,6 +10,7 @@ fi
|
||||
|
||||
# Check the service account before replacing dist.
|
||||
sunrise_group=$(id -gn sunrise)
|
||||
git pull --ff-only
|
||||
npm run build
|
||||
install -d -o sunrise -g "$sunrise_group" -m 755 dist/threads-captures
|
||||
systemctl restart sunrise-threads.service
|
||||
|
||||
+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>
|
||||
|
||||
@@ -25,5 +25,14 @@ export const CHARACTER = {
|
||||
torsoDepth: 0.014,
|
||||
headLift: 0.018,
|
||||
shoulderLift: 0.008,
|
||||
fidget: {
|
||||
minimumWaitSeconds: 8,
|
||||
maximumWaitSeconds: 15,
|
||||
scratchSeconds: 2.4,
|
||||
hopIntervalSeconds: 60,
|
||||
hopSeconds: 1,
|
||||
hopHeight: 0.085,
|
||||
hopCrouch: 0.025,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -13,3 +13,4 @@ export const EQUIPMENT_NOTICE = {
|
||||
} as const;
|
||||
|
||||
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))
|
||||
.map(part => ({ part, position: part.position.clone(), scale: part.scale.clone() }));
|
||||
const animateIdle = typeof matchMedia === 'undefined' || !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const modelRestPosition = model.position.clone();
|
||||
const originals: Object3D[] = [];
|
||||
group.traverse(object => originals.push(object));
|
||||
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 rigBreathingParts = breathingParts.map(rest => ({ ...rest, part: copy(rest.part)! }));
|
||||
const rigBody = copy(body), rigCoat = copy(gear?.raincoat);
|
||||
const rigModel = copy(model)!;
|
||||
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 flashlightOn = false;
|
||||
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) {
|
||||
const delta = Math.max(0, time - (previousPoseTime ?? time));
|
||||
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 idleStep = delta / CHARACTER.idle.transitionSeconds;
|
||||
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.ArmLeft.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.
|
||||
if (umbrellaPose > 0) {
|
||||
|
||||
+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