Compare commits

..
8 Commits
26 changed files with 1944 additions and 159 deletions
+3 -1
View File
@@ -10,8 +10,10 @@ 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
pm2 restart sunrise
systemctl restart sunrise-threads.service
echo 'Build complete; capture permissions restored and sunrise-threads.service restarted.'
echo 'Build complete; capture permissions restored, PM2 sunrise and sunrise-threads.service restarted.'
@@ -0,0 +1,12 @@
# Sheep walk implementation plan
**Goal:** Apply the approved comparison-preview gait with strength 0.4 (the supplied slider position), then commit.
**Architecture:** Extend the existing procedural pose in `src/scene/sheep.ts`; keep tuning in `src/constants/character.ts`. Preserve gear grips and independent cloned rigs. No model or movement API changes.
**Tech stack:** TypeScript, Three.js, existing Node tests.
- [x] Update existing character checks for gradual start/stop, torso/head motion, and authored rest restoration.
- [x] Add preview gait constants, damp walking weight, restore animated transforms per frame, and carry the coat around the torso pivot.
- [x] Run character/gear tests and production build; inspect visual output and review the diff.
- [x] Commit only the related production changes, tests, and plan.
Validation: 142 tests passed, 1 skipped; production build passed. Browser rendering checked with and without rain gear and flashlight; no page errors. Review found the shared original torso position could influence cloned coats; a failing independence assertion reproduced it, and using the authored torso position fixed it.
+1 -6
View File
@@ -87,13 +87,8 @@
</fieldset>
</div>
</div>
<button type="button" id="stand-beside" hidden>나란히 서기</button>
<div class="movement-controls" aria-label="위치와 방향 조절">
<div id="companion-selection" 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>
<p id="direction-hint" role="status" hidden>좌우로 드래그하면 내 캐릭터만 돌아갑니다</p>
<button type="button" id="direction-toggle" aria-pressed="false" aria-describedby="direction-hint" disabled>방향 맞추기</button>
</div>
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ export const BUILDINGS = {
assetManifestUrl: '/buildings/gwacheon.baked.json',
assetBufferUrl: '/buildings/gwacheon.baked.bin',
assetVersion: 1,
tileSize: 1000,
metersPerLevel: 3,
// Visual placeholders only: not a claim about actual floors.
fallbackHeight: { apartments: 15, residential: 9, house: 6, commercial: 12, greenhouse: 3, roof: 3, school: 12, default: 9 } as Record<string, number>,
+25
View File
@@ -1,6 +1,7 @@
export const CHARACTER = {
url: '/characters/red-sheep.glb?v=rounded-v10',
scale: 0.62,
visibilityRadius: 3, // Includes raised arms, umbrella and idle hops around the ground anchor.
selfBadgeGap: 0.12,
outline: 0.018,
ink: '#352724',
@@ -18,6 +19,23 @@ export const CHARACTER = {
cameraLift: 1.15,
cameraClearance: 0.35,
turnSpeed: 4,
walk: {
strength: 0.4,
rate: 9,
stride: 0.36,
transitionSeconds: 0.25,
sway: 0.026,
rise: 0.018,
roll: 0.035,
twist: 0.055,
headLag: 0.45,
headFollow: 0.65,
headRoll: 0.4,
headNod: 0.018,
armLag: 0.18,
armSwing: 0.65,
footLift: 0.035,
},
idle: {
periodSeconds: 3.8,
transitionSeconds: 0.3,
@@ -25,5 +43,12 @@ export const CHARACTER = {
torsoDepth: 0.014,
headLift: 0.018,
shoulderLift: 0.008,
fidget: {
intervalSeconds: 30,
scratchSeconds: 2.4,
hopSeconds: 1,
hopHeight: 0.085,
hopCrouch: 0.025,
},
},
} as const;
+1
View File
@@ -1,4 +1,5 @@
export const RENDER_QUALITY = {
mobileFps: 30,
mobileCap: 1.5,
mobilePixelBudget: 1_500_000,
levels: [0.75, 1, 1.25, 1.5, 1.75, 2] as readonly number[],
+24 -8
View File
@@ -1,11 +1,14 @@
import { type Object3D, type Raycaster, MathUtils, Vector3 } from 'three';
import { type Camera, type Object3D, type Raycaster, Frustum, Matrix4, MathUtils, Sphere, Vector3 } from 'three';
import type { Sheep } from '../scene/sheep.ts';
import type { PlayerState } from './protocol.ts';
import { MULTIPLAYER } from '../constants/multiplayer.ts';
import { MOVEMENT } from '../constants/movement.ts';
import { CHARACTER } from '../constants/character.ts';
export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt: (x: number, z: number) => number) {
const players = new Map<string, { sheep: Sheep; state: PlayerState; from: Vector3; yaw: number; elapsed: number }>();
const frustum = new Frustum(), projection = new Matrix4();
const bounds = new Sphere(new Vector3(), CHARACTER.visibilityRadius);
return {
get(id: string) { return players.get(id)?.sheep.group; },
pick(ray: Raycaster, maxDistance: number) {
@@ -54,18 +57,31 @@ export function createRemotePlayers(template: Sheep, scene: Object3D, heightAt:
entry.sheep.setEquipment(state.equipment.umbrella, state.equipment.raincoat, state.equipment.hood, state.equipment.flashlight);
}
},
update(delta: number, serverTime: number) {
update(delta: number, serverTime: number, camera?: Camera) {
if (camera) {
camera.updateMatrixWorld(true);
frustum.setFromProjectionMatrix(projection.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse));
}
let changed = false;
for (const entry of players.values()) {
const { sheep, state, from } = entry;
const duration = MULTIPLAYER.interpolationMs / 1000;
const interpolating = entry.elapsed < duration;
entry.elapsed += Math.max(0, delta);
const fraction = Math.min(1, entry.elapsed / duration);
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
sheep.group.position.set(x, heightAt(x, z), z);
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
sheep.group.rotation.y = entry.yaw + angle * fraction;
if (interpolating) {
entry.elapsed = Math.min(duration, entry.elapsed + Math.max(0, delta));
const fraction = Math.min(1, entry.elapsed / duration);
const x = MathUtils.lerp(from.x, state.x, fraction), z = MathUtils.lerp(from.z, state.z, fraction);
if (x !== sheep.group.position.x || z !== sheep.group.position.z) {
sheep.group.position.set(x, heightAt(x, z), z);
}
const angle = Math.atan2(Math.sin(state.yaw - entry.yaw), Math.cos(state.yaw - entry.yaw));
sheep.group.rotation.y = entry.yaw + angle * fraction;
}
bounds.center.copy(sheep.group.position);
const visible = !camera || frustum.intersectsSphere(bounds);
changed = changed || sheep.group.visible !== visible;
sheep.group.visible = visible;
if (!visible) continue;
const active = sheep.pose(state.emote, Math.max(0, serverTime - state.emoteStartedAt) / 1000,
state.moving && serverTime - state.updatedAt < MULTIPLAYER.staleMovementMs, serverTime / 1000);
changed = changed || interpolating || active;
+15 -11
View File
@@ -69,7 +69,7 @@ export async function loadBuildings(): Promise<BuildingData> {
export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<typeof loadTerrain>>) {
const group = new Group();
const geometries: Record<'tagged' | 'levels' | 'fallback', ExtrudeGeometry[]> = { tagged: [], levels: [], fallback: [] };
const geometries = { tagged: new Map<string, ExtrudeGeometry[]>(), levels: new Map<string, ExtrudeGeometry[]>(), fallback: new Map<string, ExtrudeGeometry[]>() };
const counts = { tagged: 0, levels: 0, fallback: 0 };
const lightSources: BuildingLightSource[] = [];
const surface = (x: number, z: number) => terrainHeightAt(terrain.metadata, terrain.heights, x, z) - terrain.metadata.observerSurfaceElevation - (x * x + z * z) / (2 * TERRAIN.earthRadiusMeters);
@@ -107,19 +107,23 @@ export function createBuildings(data: BuildingData, terrain: Awaited<ReturnType<
geometry.setAttribute('buildingLightHeight', new BufferAttribute(lightHeights, 1));
geometry.rotateX(-Math.PI / 2);
geometry.translate(x, base, z);
geometries[source].push(geometry);
const tile = `${Math.floor(x / BUILDINGS.tileSize)},${Math.floor(z / BUILDINGS.tileSize)}`;
const bucket = geometries[source].get(tile) ?? [];
bucket.push(geometry);
geometries[source].set(tile, bucket);
counts[source]++;
}
for (const source of ['tagged', 'levels', 'fallback'] as const) {
if (!geometries[source].length) continue;
const merged = mergeGeometries(geometries[source], false)!;
geometries[source].forEach(g => g.dispose());
// Lighting uses world XZ and local height, not UVs. Retain normals and
// lighting heights when sharing vertices so facade/roof seams stay sharp.
merged.deleteAttribute('uv');
const indexed = indexBuildingGeometry(merged);
merged.dispose();
group.add(createBuildingMesh(source, indexed));
for (const bucket of geometries[source].values()) {
const merged = mergeGeometries(bucket, false)!;
bucket.forEach(g => g.dispose());
// Lighting uses world XZ and local height, not UVs. Retain normals and
// lighting heights when sharing vertices so facade/roof seams stay sharp.
merged.deleteAttribute('uv');
const indexed = indexBuildingGeometry(merged);
merged.dispose();
group.add(createBuildingMesh(source, indexed));
}
}
const target = structurePosition(BUILDINGS.target.latitude, BUILDINGS.target.longitude, terrain.metadata.origin);
target.y = surface(target.x, target.z) + 20;
+17
View File
@@ -0,0 +1,17 @@
/** Pace the whole update loop while retaining real elapsed time between accepted frames. */
export function createFrameClock(fps: number) {
const interval = 1000 / fps;
let previous: number | undefined;
let next = 0;
return {
reset() { previous = undefined; next = 0; },
tick(now: number): number | undefined {
if (previous !== undefined && now + .001 < next) return;
const delta = previous === undefined ? 0 : Math.max(0, now - previous) / 1000;
next = previous === undefined || !interval ? now + interval
: next + (Math.floor(Math.max(0, now + .001 - next) / interval) + 1) * interval;
previous = now;
return delta;
},
};
}
+42 -23
View File
@@ -13,6 +13,9 @@ import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { SCENE } from '../constants/scene.ts';
import { TERRAIN } from '../constants/terrain.ts';
import { LOBBY_CHARACTER, LOBBY_SUNRISE } from '../constants/lobby.ts';
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
import { createFrameClock } from './frameClock.ts';
import { renderPixelRatio } from './adaptiveResolution.ts';
/** Same Gwanaksan DSM, summit correction, sky and solar disc as the observation scene. */
export function createLobbySunrise(canvas: HTMLCanvasElement) {
@@ -60,26 +63,33 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
foreground.add(sheep.group);
const head = sheep.group.getObjectByName('Head')!;
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const mobile = matchMedia('(pointer: coarse)').matches;
const frameClock = createFrameClock(mobile ? RENDER_QUALITY.mobileFps : Infinity);
let progress = reducedMotion ? 1 : 0;
let frame = 0;
let elapsed = 0;
let lastFrame = 0;
let previousProgress = -1;
let gazeAngle = 0;
const render = () => {
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
const state = calculateSun(date, observer);
direction.set(...directionFromAngles(state.azimuth, state.altitude));
sky.update(direction, state.altitude);
sun.update(direction, state.altitude);
nightSky.update(date, observer, state.altitude);
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
if (progress !== previousProgress) {
previousProgress = progress;
const date = new Date(LOBBY_SUNRISE.from + (LOBBY_SUNRISE.to - LOBBY_SUNRISE.from) * progress);
const state = calculateSun(date, observer);
direction.set(...directionFromAngles(state.azimuth, state.altitude));
sky.update(direction, state.altitude);
sun.update(direction, state.altitude);
nightSky.update(date, observer, state.altitude);
const moonAltitude = getMoonPosition(date, observer.latitude, observer.longitude).altitude;
const gazeAltitude = MathUtils.lerp(moonAltitude, state.altitude, MathUtils.smoothstep(state.altitude, -8, 0));
gazeAngle = -MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28));
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
}
sheep.pose('idle', 0, false, elapsed / 1000);
head.rotateX(-MathUtils.degToRad(MathUtils.clamp(gazeAltitude, 8, 28)));
sheep.updateLighting(Math.max(LOBBY_CHARACTER.minimumLightingAltitude, state.altitude), 1);
ambient.intensity = MathUtils.lerp(0.12, 1.8, MathUtils.smoothstep(state.altitude, -8, 15));
fog.color.copy(night).lerp(dawn, MathUtils.smoothstep(state.altitude, -8, 0));
fog.color.lerp(day, MathUtils.smoothstep(state.altitude, 0, 15));
renderer.toneMappingExposure = MathUtils.lerp(0.12, LOBBY_SUNRISE.maxExposure, MathUtils.smoothstep(state.altitude, -12, 5));
head.rotateX(gazeAngle);
renderer.render(scene, camera);
const { clientWidth: width, clientHeight: height } = canvas;
const size = Math.min(LOBBY_CHARACTER.maxSize, width * LOBBY_CHARACTER.widthFraction, height * LOBBY_CHARACTER.heightFraction);
@@ -94,19 +104,28 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
const { clientWidth: width, clientHeight: height } = canvas;
camera.aspect = width / Math.max(1, height);
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio));
renderer.setPixelRatio(renderPixelRatio(devicePixelRatio, LOBBY_SUNRISE.maxPixelRatio, width, height, mobile));
renderer.setSize(width, height, false);
render();
};
const tick = (now: number) => {
if (disposed) return;
if (!document.hidden && lastFrame) elapsed += now - lastFrame;
lastFrame = now;
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
if (!document.hidden) render();
if (disposed || document.hidden) return;
const delta = frameClock.tick(now);
if (delta !== undefined) {
elapsed += delta * 1000;
progress = MathUtils.clamp((elapsed - LOBBY_SUNRISE.nightHoldMs) / LOBBY_SUNRISE.durationMs, 0, 1);
render();
}
if (!reducedMotion) frame = requestAnimationFrame(tick);
};
const visible = () => { lastFrame = 0; if (!document.hidden) render(); };
const visible = () => {
cancelAnimationFrame(frame);
frameClock.reset();
if (!document.hidden) {
render();
if (!reducedMotion) frame = requestAnimationFrame(tick);
}
};
cleanup = () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
@@ -126,7 +145,7 @@ export function createLobbySunrise(canvas: HTMLCanvasElement) {
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', visible);
resize();
if (progress < 1) frame = requestAnimationFrame(tick);
if (!document.hidden && progress < 1) frame = requestAnimationFrame(tick);
})();
return { ready, dispose() { disposed = true; cleanup?.(); cleanup = undefined; } };
}
+113 -9
View File
@@ -107,8 +107,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const body = model.getObjectByName('Body');
const breathingParts = [body, gear?.raincoat, joints.Head, joints.ArmLeft, joints.ArmRight]
.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(), quaternion: part.quaternion.clone() }));
const legPositions = [joints.LegLeft.position.clone(), joints.LegRight.position.clone()];
const bodyRestPosition = body?.position.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 {
@@ -118,13 +121,23 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const rigJoints = Object.fromEntries(['Head', 'ArmLeft', 'ArmRight', 'LegLeft', 'LegRight']
.map(name => [name, group.getObjectByName(name)!]));
const joints = rigJoints;
const jointEntries = Object.entries(joints);
const rigCoatParts = coatParts.map(part => copy(part)!);
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
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;
let fidgetWait: number = fidget.intervalSeconds;
let fidgetTime = 0;
let fidgetAction: 'scratch' | 'hop' | undefined;
let idleWeight = 0;
let walkWeight = 0;
const walk = CHARACTER.walk;
const torsoRotation = new Quaternion();
const coatOffset = new Vector3();
let flashlightOn = false;
let flashlightPose = 0;
let previousPoseTime: number | undefined;
@@ -162,7 +175,31 @@ 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 previousWalkWeight = walkWeight;
walkWeight = emote === 'idle' ? MathUtils.damp(walkWeight, Number(moving), 1 / walk.transitionSeconds, delta) : 0;
if (!moving && walkWeight < .001) walkWeight = 0;
const idle = animateIdle && !moving && emote === 'idle';
rigModel.position.copy(modelRestPosition);
if (!idle) {
fidgetWait = fidget.intervalSeconds;
fidgetTime = 0;
fidgetAction = undefined;
} else {
// Do not fast-forward gestures after a background tab resumes.
const idleDelta = Math.min(delta, .1);
fidgetWait -= idleDelta;
if (fidgetWait <= 0) {
fidgetAction = Math.random() < .5 ? 'scratch' : 'hop';
fidgetTime = 0;
fidgetWait += fidget.intervalSeconds;
} else if (fidgetAction) {
fidgetTime += idleDelta;
const duration = fidgetAction === 'scratch' ? fidget.scratchSeconds : fidget.hopSeconds;
if (fidgetTime >= duration) {
fidgetAction = undefined;
}
}
}
const previousIdleWeight = idleWeight;
const idleStep = delta / CHARACTER.idle.transitionSeconds;
idleWeight = idle ? Math.min(1, idleWeight + idleStep) : Math.max(0, idleWeight - idleStep);
@@ -173,10 +210,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
part.position.copy(rest.position);
part.scale.copy(rest.scale);
if (part === rigBody || part === rigCoat) {
part.quaternion.copy(rest.quaternion);
part.scale.y *= 1 + breath * CHARACTER.idle.torsoRise;
part.scale.z *= 1 + breath * CHARACTER.idle.torsoDepth;
// The coat is authored in model coordinates; expand about the torso's pivot.
if (part === rigCoat && body) part.position.y += -body.position.y * breath * CHARACTER.idle.torsoRise;
if (part === rigCoat && bodyRestPosition) part.position.y += -bodyRestPosition.y * breath * CHARACTER.idle.torsoRise;
} else {
part.position.y += breath * (part === joints.Head ? CHARACTER.idle.headLift : CHARACTER.idle.shoulderLift);
}
@@ -190,12 +228,15 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
const changed = idle || idleWeight > 0 || previousIdleWeight > 0
|| walkWeight > 0 || previousWalkWeight > 0
|| moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote
|| previousFlashlightPose !== flashlightPose || flashlightPose !== Number(flashlightOn)
|| previousUmbrellaPose !== umbrellaPose || umbrellaPose !== Number(umbrellaOpen);
previousMoving = moving;
previousEmote = emote;
for (const [name, joint] of Object.entries(joints)) joint.quaternion.copy(rest[name]);
for (const [name, joint] of jointEntries) joint.quaternion.copy(rest[name]);
joints.LegLeft.position.copy(legPositions[0]);
joints.LegRight.position.copy(legPositions[1]);
if (emote === 'wave') {
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
@@ -203,12 +244,75 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
pointArm('ArmLeft', aim.set(.68, -.22, .64));
pointArm('ArmRight', aim.set(-.68, -.22, .64));
joints.Head.rotateX(.16);
} else if (moving) {
const stride = Math.sin(time * 9) * .4;
joints.LegLeft.rotateX(stride);
joints.LegRight.rotateX(-stride);
joints.ArmLeft.rotateX(-stride * .6);
joints.ArmRight.rotateX(stride * .6);
} else if (moving || walkWeight > 0) {
const phase = time * walk.rate;
const swing = Math.sin(phase);
const amplitude = walkWeight * walk.strength;
const sway = swing * walk.sway * amplitude;
const rise = (1 - Math.cos(phase * 2)) * walk.rise * amplitude;
if (rigBody) {
rigBody.position.x += sway;
rigBody.position.y += rise;
const bodyRest = rigBreathingParts.find(({ part }) => part === rigBody)!;
torsoRotation.copy(bodyRest.quaternion).invert();
rigBody.rotateZ(-swing * walk.roll * amplitude);
rigBody.rotateY(swing * walk.twist * amplitude);
torsoRotation.premultiply(rigBody.quaternion);
if (rigCoat && bodyRestPosition) {
// The coat is authored at the model origin; rotate it about the torso pivot.
coatOffset.copy(rigCoat.position).sub(bodyRestPosition).applyQuaternion(torsoRotation);
rigCoat.position.copy(coatOffset).add(bodyRestPosition);
rigCoat.position.x += sway;
rigCoat.position.y += rise;
rigCoat.quaternion.premultiply(torsoRotation);
}
}
for (const [side, sign] of [['Left', 1], ['Right', -1]] as const) {
const leg = joints[`Leg${side}`], arm = joints[`Arm${side}`];
leg.rotateX(swing * sign * walk.stride * walkWeight);
leg.position.y += Math.max(0, -Math.cos(phase) * sign) ** 2 * walk.footLift * amplitude;
arm.position.x += sway;
arm.position.y += rise;
arm.rotateX(-Math.sin(phase - walk.armLag) * sign * walk.stride * walk.armSwing * walkWeight);
arm.rotateZ(-swing * walk.roll * amplitude);
}
joints.Head.position.x += Math.sin(phase - walk.headLag) * walk.sway * walk.headFollow * amplitude;
joints.Head.position.y += (1 - Math.cos(phase * 2 - walk.headLag)) * walk.rise * walk.headFollow * amplitude;
joints.Head.rotateZ(Math.sin(phase - walk.headLag) * walk.roll * walk.headRoll * amplitude);
joints.Head.rotateX(Math.sin(phase * 2 - walk.headLag) * walk.headNod * amplitude);
} 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 && bodyRestPosition) part.position.y += bodyRestPosition.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) {
+51 -24
View File
@@ -31,6 +31,9 @@ import { COAST } from '../constants/coast.ts';
import { GISAMUN } from '../constants/gisamun.ts';
import { createHarbour, isGisamun } from './harbour.ts';
import { createRenderInvalidation } from './renderInvalidation.ts';
import { createFrameClock } from './frameClock.ts';
import { renderPixelRatio } from './adaptiveResolution.ts';
import { RENDER_QUALITY } from '../constants/renderQuality.ts';
import { createTerrainMinimap } from '../ui/terrainMinimap.ts';
import { SUMMIT_TERRAIN } from '../constants/summitTerrain.ts';
import { createSummitWalking } from '../controls/summitWalking.ts';
@@ -51,6 +54,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';
@@ -79,6 +83,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
let sharedNow: (() => number) | null = null;
let playerMoving = false;
const profiling = import.meta.env.DEV && new URLSearchParams(location.search).has('profile');
const mobile = matchMedia('(pointer: coarse)').matches;
const frameClock = createFrameClock(mobile && !profiling ? RENDER_QUALITY.mobileFps : Infinity);
let profile: ReturnType<typeof createRenderProfile> | undefined;
let disposed = false;
const summitStarted = performance.now();
@@ -170,18 +176,27 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
const destinationRay = new Raycaster();
const pointer = new Vector2();
let cameraDistance: number = CHARACTER.cameraDistance;
const followPosition = new Vector3();
const followRotation = camera.quaternion.clone();
let previousCameraDistance = -1;
const updateFollowCamera = () => {
if (previousCameraDistance === cameraDistance && followPosition.equals(camera.position)
&& followRotation.equals(camera.quaternion)) return;
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
followPosition.copy(camera.position);
followRotation.copy(camera.quaternion);
previousCameraDistance = 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; besideButton.hidden = true; };
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);
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
if (sheep) updateFollowCamera();
const view = sheep ? followCamera : camera;
view.updateMatrixWorld(true);
destinationRay.setFromCamera(pointer, view);
@@ -212,8 +227,9 @@ 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)!;
companionAnchor = createCharacterOverlay(player, canvas, besideButton);
rendering.invalidate();
return true;
},
});
@@ -223,7 +239,6 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
directionButton.addEventListener('click', () => {
if (sheep && !networkBlocked && !overview) controls.setTurning(!controls.turning);
}, { signal: emoteEvents.signal });
document.getElementById('companion-close')!.addEventListener('click', closeCompanion, { signal: emoteEvents.signal });
besideButton.addEventListener('click', () => {
if (!selectedCompanion || networkBlocked || overview) return;
const spot = remotePlayers?.beside(selectedCompanion, camera.position, point => {
@@ -240,10 +255,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
}
return true;
});
if (!spot) { companionStatus.textContent = '바로 걸어갈 수 있는 빈자리가 없어요. 조금 더 가까이 이동해 주세요.'; return; }
closeCompanion();
if (!spot) return;
emotes.cancel(); refreshEmotes();
controls.moveTo(spot.position, spot.yaw);
companionStatus.textContent = '옆자리로 이동 중';
}, { signal: emoteEvents.signal });
const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
const primaryActionLabel = document.getElementById('primary-action-label')!;
@@ -373,7 +388,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
overviewCamera.updateProjectionMatrix();
followCamera.aspect = camera.aspect;
followCamera.updateProjectionMatrix();
renderer.setPixelRatio(profile?.forcedDpr ?? Math.min(devicePixelRatio, SCENE.maxPixelRatio));
renderer.setPixelRatio(profile?.forcedDpr ?? renderPixelRatio(devicePixelRatio,
mobile ? RENDER_QUALITY.mobileCap : SCENE.maxPixelRatio, innerWidth, innerHeight, mobile));
renderer.setSize(innerWidth, innerHeight);
composer?.setPixelRatio(renderer.getPixelRatio());
composer?.setSize(innerWidth, innerHeight);
@@ -381,13 +397,13 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
};
window.addEventListener('resize', resize);
resize();
let previousTime = performance.now();
const forward = new Vector3();
const travel = new Vector3();
renderer.setAnimationLoop((now) => {
const renderFrame = (now: number) => {
if (document.hidden) return;
const delta = frameClock.tick(now);
if (delta === undefined) return;
const cpuStart = profile?.active() ? performance.now() : 0;
const delta = (now - previousTime) / 1000;
previousTime = now;
const replayPosition = profile?.step(now);
let moving = false;
let turning = false;
@@ -421,14 +437,12 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (arrived || (!moving && !turning)) {
if (controls.arrivalYaw !== undefined) {
if (arrived && sheep) sheep.group.rotation.y = controls.arrivalYaw;
companionStatus.textContent = arrived ? '나란히 섰어요. 드래그로 구도를 맞춰 보세요.' : '이동이 막혔어요. 조금 더 가까이 이동해 주세요.';
}
controls.stop();
}
}
}
playerMoving = moving;
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now())) rendering.invalidate();
ocean?.animate(now / 1000);
let weatherChanged = false;
const blend = 1 - Math.exp(-Math.min(delta, 0.1) / WEATHER.transitionSeconds);
@@ -453,9 +467,11 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
faceTravel(sheep.group, travel, delta);
}
if (sheep.pose(emotes.current, emotes.elapsed, moving, now / 1000)) rendering.invalidate();
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
updateFollowCamera();
}
const activeCamera = overview ? overviewCamera : sheep ? followCamera : camera;
if (remotePlayers?.update(delta, sharedNow ? sharedNow() : Date.now(), activeCamera)) rendering.invalidate();
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);
@@ -476,7 +492,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
bloom.threshold = torchActive ? FLASHLIGHT.bloomThreshold : BUILDINGS.lighting.bloomThreshold;
bloom.radius = torchActive ? FLASHLIGHT.bloomRadius : BUILDINGS.lighting.bloomRadius;
}
if (composer && !profile?.disablePost) {
if (composer && bloom?.enabled && !profile?.disablePost) {
renderPass.camera = activeCamera;
composer.render(delta);
} else renderer.render(scene, activeCamera);
@@ -484,12 +500,21 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (cpuStart) profile?.end(now, { update: updateEnd - cpuStart, minimap: minimapEnd - updateEnd,
submit: performance.now() - minimapEnd });
if (profile?.observe(now, true)) resize();
});
};
const onVisibility = () => {
frameClock.reset();
rendering.invalidate();
renderer.setAnimationLoop(document.hidden ? null : renderFrame);
};
document.addEventListener('visibilitychange', onVisibility);
onVisibility();
const world = {
buildingCounts: buildings?.counts,
captureFrame(view?: PerspectiveCamera) {
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
if (composer && !profile?.disablePost) {
remotePlayers?.update(0, sharedNow ? sharedNow() : Date.now(), activeCamera);
rendering.invalidate();
if (composer && bloom?.enabled && !profile?.disablePost) {
renderPass.camera = activeCamera;
composer.render(0);
} else renderer.render(scene, activeCamera);
@@ -585,14 +610,14 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
camera.lookAt(camera.position.clone().add(forward));
if (sheep) {
sheep.group.rotation.y = Math.atan2(forward.x, forward.z);
updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
updateFollowCamera();
}
},
lookAtSun() {
if (!solarState) return;
restoreView();
camera.lookAt(camera.position.clone().add(new Vector3(...directionFromAngles(solarState.azimuth, 0))));
if (sheep) updateThirdPersonCamera(followCamera, camera, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z), cameraDistance);
if (sheep) updateFollowCamera();
},
setEyeHeight(meters: number) {
eyeHeight = meters;
@@ -620,8 +645,10 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
},
dispose() {
disposed = true;
closeCompanion();
profile?.dispose();
renderer.setAnimationLoop(null);
document.removeEventListener('visibilitychange', onVisibility);
minimap.dispose();
compass.dispose();
selfBadge?.dispose();
@@ -652,7 +679,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (import.meta.env.DEV && profiling) void import('../dev/renderProfile.ts').then(({ createRenderProfile }) => {
if (disposed) return;
profile = createRenderProfile(renderer, scene, camera, () => world.resetPosition(), target, () =>
composer && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
composer && bloom?.enabled && !profile?.disablePost ? composer.passes.filter(pass => pass.enabled).map(pass => pass.constructor.name) : ['WebGLRenderer'], resize);
renderer.info.autoReset = false;
resize();
});
+3 -6
View File
@@ -16,7 +16,7 @@ button[aria-pressed=true], button.primary { background: #e3ba86; color: #28251f;
button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
:focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; }
.hud { position: fixed; inset: 0; pointer-events: none; }
.self-badge { position: fixed; transform: translate(-50%, -100%); padding: 3px 9px; border: 1px solid #ffffff80; border-radius: 10px; background: #172731e8; color: #fff; font-size: 12px; font-weight: 700; line-height: 18px; box-shadow: 0 2px 6px #0004; pointer-events: none; user-select: none; }
.self-badge, #stand-beside { position: fixed; transform: translate(-50%, -100%); padding: 3px 9px; border: 1px solid #ffffff80; border-radius: 10px; background: #172731e8; color: #fff; font-size: 12px; font-weight: 700; line-height: 18px; box-shadow: 0 2px 6px #0004; pointer-events: none; user-select: none; }
#compass { position: absolute; top: max(18px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); width: min(460px, calc(100vw - 360px)); height: 66px; color: #f4f2e9; text-shadow: 0 1px 3px #000, 0 0 6px #0009; pointer-events: none; }
.compass-scale { position: relative; height: 43px; overflow: hidden; mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); }
.compass-tick { position: absolute; top: 0; transform: translateX(-50%); height: 39px; padding-top: 18px; font-size: 11px; font-variant-numeric: tabular-nums; }
@@ -57,11 +57,8 @@ button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.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-name { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#companion-status { flex-basis: 100%; margin: 0; }
#companion-status:empty { display: none; }
#direction-hint { margin: 0; padding: 10px 12px; border: 1px solid #ffffff26; border-radius: 14px; background: #18232ef2; font-size: 12px; line-height: 1.5; }
#stand-beside { min-width: 0; min-height: 0; width: max-content; pointer-events: auto; }
@media (max-width: 600px) {
.movement-controls { right: max(10px, env(safe-area-inset-right)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 62px); max-width: calc(100vw - 20px); }
#direction-toggle { padding: 8px 12px; font-size: 12px; background: #18232ee6; }
+31
View File
@@ -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
View File
@@ -6,6 +6,7 @@ export function createCompass() {
const scale = root.querySelector<HTMLElement>('.compass-scale')!;
const bearing = root.querySelector<HTMLElement>('.compass-bearing')!;
const direction = new Vector3();
let previousHeading: number | undefined;
const ticks = Array.from({ length: 360 / COMPASS.tickDegrees }, (_, index) => {
const degrees = index * COMPASS.tickDegrees;
const tick = document.createElement('span');
@@ -19,6 +20,8 @@ export function createCompass() {
update(camera: Camera) {
camera.getWorldDirection(direction);
const heading = (Math.atan2(direction.x, -direction.z) * 180 / Math.PI + 360) % 360;
if (heading === previousHeading) return;
previousHeading = heading;
for (const { tick, degrees } of ticks) {
const offset = (degrees - heading + 540) % 360 - 180;
tick.hidden = Math.abs(offset) > COMPASS.visibleDegrees / 2;
+3 -17
View File
@@ -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(); },
};
}
+1 -1
View File
@@ -85,7 +85,7 @@ export class TimeController {
};
private sync = () => {
if (this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
if (!document.hidden && this.mode === 'LIVE') this.render(this.sharedClock ? this.sharedClock() : new Date());
};
private render(date: Date) {
+2 -1
View File
@@ -51,7 +51,8 @@ test('downloaded footprints are deduplicated, closed, and generate finite merged
const city=createBuildings(data,terrain);
assert.equal(Object.values(city.counts).reduce((a,b)=>a+b,0),data.features.length);
assert.ok(city.counts.tagged>0 && city.counts.levels>0 && city.counts.fallback>0);
assert.ok(city.group.children.length<=3);
assert.ok(city.group.children.length > 3, 'separate city districts can be culled independently');
assert.ok(city.group.children.length < 100, 'batch buildings instead of one draw call per building');
for(const mesh of city.group.children as Mesh[]) assert.ok(Array.from(mesh.geometry.attributes.position.array).every(Number.isFinite));
city.dispose();
});
+35
View File
@@ -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();
});
+12 -2
View File
@@ -108,8 +108,12 @@ test('exported sheep has working joints and renders the final idle pose after wa
const leg = sheep.group.getObjectByName('LegLeft')!;
const rest = leg.quaternion.clone();
assert.equal(sheep.pose('idle', 0, true, 1), true);
sheep.pose('idle', 0, true, 1.2);
assert.ok(leg.quaternion.angleTo(rest) > .01);
assert.equal(sheep.pose('idle', 0, false, 1), true, 'stopping must invalidate one frame');
const walking = leg.quaternion.clone();
assert.equal(sheep.pose('idle', 0, false, 1.2), true, 'stopping must invalidate one frame');
assert.ok(leg.quaternion.angleTo(walking) < 1e-6, 'stopping blends instead of snapping');
sheep.pose('idle', 0, false, 4);
assert.ok(leg.quaternion.angleTo(rest) < 1e-6);
assert.equal(sheep.pose('idle', 0, false, 1), true, 'idle breathing keeps rendering');
const arm = sheep.group.getObjectByName('ArmRight')!;
@@ -146,7 +150,13 @@ test('idle breathing moves torso and head while feet stay planted and clones ret
assert.ok(!body.scale.equals(bodyRest), 'breathing blends out instead of snapping');
sheep.pose('idle', 0, true, 1.2);
assert.deepEqual(body.scale, bodyRest);
assert.deepEqual(head.position, headRest);
assert.ok(head.position.distanceTo(headRest) > .001, 'walking carries the head');
assert.ok(body.position.x !== 0, 'walking shifts the torso weight');
const walkingHead = head.position.clone();
sheep.pose('idle', 0, true, 1.2);
assert.deepEqual(head.position, walkingHead, 'walking offsets cannot accumulate');
sheep.pose('pray', 0, false, 1.5);
assert.deepEqual(head.position, headRest, 'emotes clear walking offsets');
remote.dispose(); sheep.dispose();
});
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createFrameClock } from '../src/scene/frameClock.ts';
test('30 fps pacing retains elapsed simulation time on 60 and 120 Hz displays', () => {
for (const hz of [60, 120]) {
const clock = createFrameClock(30);
let frames = 0, elapsed = 0;
for (let i = 0; i <= hz * 10; i++) {
const delta = clock.tick(i * 1000 / hz);
if (delta !== undefined) { frames++; elapsed += delta; }
}
assert.equal(frames, 301);
assert.ok(Math.abs(elapsed - 10) < 1e-8);
}
});
test('resume resets elapsed time instead of simulating the hidden interval', () => {
const clock = createFrameClock(30);
clock.tick(0); clock.tick(100);
clock.reset();
assert.equal(clock.tick(60000), 0);
assert.equal(clock.tick(60001), undefined);
});
+27 -2
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three';
import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PerspectiveCamera, Raycaster, Vector3 } from 'three';
import { createServerClock } from '../src/multiplayer/clock.ts';
import { createRemotePlayers } from '../src/multiplayer/remotePlayers.ts';
import type { Sheep } from '../src/scene/sheep.ts';
@@ -16,6 +16,27 @@ test('server clock uses round trip midpoint and monotonic elapsed time', () => {
assert.equal(clock.now(), 1001020);
});
test('offscreen players skip posing but keep movement current and reappear when the camera turns', () => {
const scene = new Group(), camera = new PerspectiveCamera(60, 1, .1, 100);
let poses = 0;
const template = { group: new Group(), clone() { return { group: new Group(), setEquipment() {},
pose() { poses++; return true; }, dispose() {} } as unknown as Sheep; } } as Sheep;
const remote = createRemotePlayers(template, scene, () => 0);
const player: PlayerState = { id: 'other', nickname: '친구', x: 0, z: 10, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: false, raincoat: false, hood: false }, updatedAt: 1000, emoteStartedAt: 1000 };
remote.synchronize([player], 'self');
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1000 + i * 1000 / 30, camera);
assert.equal(poses, 0);
assert.equal(remote.get('other')!.visible, false);
remote.synchronize([{ ...player, x: 2, updatedAt: 11000 }], 'self');
remote.update(.2, 11200, camera);
assert.equal(remote.get('other')!.position.x, 2);
camera.lookAt(2, 0, 10);
assert.equal(remote.update(.03, 11230, camera), true);
assert.equal(poses, 1);
assert.equal(remote.get('other')!.visible, true);
});
test('companion selection picks visible players and chooses the nearest reachable unoccupied side', () => {
const scene = new Group();
const geometry = new BoxGeometry(1, 2, 1), material = new MeshBasicMaterial();
@@ -51,7 +72,8 @@ test('remote snapshot reconciles membership, equipment and interpolated motion w
let disposed = 0;
const template = { clone() { return { group: new Group(), setEquipment: (...v: boolean[]) => equipment.push(v),
pose: () => false, dispose() { this.group.removeFromParent(); disposed++; } } as unknown as Sheep; } } as Sheep;
const remote = createRemotePlayers(template, scene, () => 3);
let heightSamples = 0;
const remote = createRemotePlayers(template, scene, () => { heightSamples++; return 3; });
const player: PlayerState = { id: 'other', nickname: '방문자', x: 0, z: 0, yaw: 0, moving: false,
emote: 'idle', equipment: { umbrella: true, raincoat: true, hood: false, flashlight: true }, updatedAt: 1000, emoteStartedAt: 1000 };
remote.synchronize([player, { ...player, id: 'self' }], 'self');
@@ -63,6 +85,9 @@ test('remote snapshot reconciles membership, equipment and interpolated motion w
remote.update(.2, 1300);
assert.equal(scene.children[0].position.x, 1);
assert.equal(scene.children[0].position.y, 3);
const samplesAfterMoving = heightSamples;
for (let i = 0; i < 300; i++) remote.update(1 / 30, 1300 + i * 1000 / 30);
assert.equal(heightSamples, samplesAfterMoving, 'stationary players reuse their terrain height');
remote.synchronize([], 'self');
assert.equal(scene.children.length, 0);
assert.equal(disposed, 1);
+10
View File
@@ -11,6 +11,7 @@ import { Vector3 } from 'three';
test('idle breathing carries the coat and hood without lifting the feet or changing the umbrella grip', async () => {
const [model, umbrella, raincoat] = await Promise.all(['red-sheep', 'umbrella', 'raincoat'].map(asset));
raincoat.name = 'TestRaincoat';
const sheep = createSheep(model, { umbrella, raincoat });
sheep.setEquipment(true, true, true);
const body = model.getObjectByName('Body')!, head = model.getObjectByName('Head')!;
@@ -26,6 +27,15 @@ test('idle breathing carries the coat and hood without lifting the feet or chang
sheep.pose('idle', 0, false, 2);
assert.ok(arm.quaternion.angleTo(grip) < 1e-8);
assert.ok(foot.getWorldPosition(new Vector3()).distanceTo(footPosition) < 1e-8);
const remote = sheep.clone();
remote.pose('idle', 0, false, 0);
remote.pose('idle', 0, false, 1);
const remoteCoat = remote.group.getObjectByName('TestRaincoat')!;
const remoteCoatPosition = remoteCoat.position.clone();
sheep.pose('idle', 0, true, 3);
remote.pose('idle', 0, false, 1);
assert.deepEqual(remoteCoat.position, remoteCoatPosition, 'local walking cannot change a remote coat pivot');
remote.dispose();
sheep.dispose();
});