feat: simplify companion action and randomize idle gestures every 30 seconds

This commit is contained in:
2026-11-02 12:30:26 +09:00
parent 60437790a8
commit ad5a5b0cfb
6 changed files with 16 additions and 44 deletions
+1 -6
View File
@@ -87,12 +87,7 @@
</fieldset> </fieldset>
</div> </div>
</div> </div>
<div id="companion-selection" role="group" aria-label="선택한 캐릭터" hidden> <button type="button" id="stand-beside" hidden>나란히 서기</button>
<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="위치와 방향 조절"> <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>
+1 -3
View File
@@ -26,10 +26,8 @@ export const CHARACTER = {
headLift: 0.018, headLift: 0.018,
shoulderLift: 0.008, shoulderLift: 0.008,
fidget: { fidget: {
minimumWaitSeconds: 8, intervalSeconds: 30,
maximumWaitSeconds: 15,
scratchSeconds: 2.4, scratchSeconds: 2.4,
hopIntervalSeconds: 60,
hopSeconds: 1, hopSeconds: 1,
hopHeight: 0.085, hopHeight: 0.085,
hopCrouch: 0.025, hopCrouch: 0.025,
-1
View File
@@ -13,4 +13,3 @@ 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;
+7 -17
View File
@@ -127,9 +127,7 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const rigModel = copy(model)!; const rigModel = copy(model)!;
const breathPhase = Math.random() * Math.PI * 2; const breathPhase = Math.random() * Math.PI * 2;
const fidget = CHARACTER.idle.fidget; const fidget = CHARACTER.idle.fidget;
const nextFidgetDelay = () => MathUtils.lerp(fidget.minimumWaitSeconds, fidget.maximumWaitSeconds, Math.random()); let fidgetWait: number = fidget.intervalSeconds;
let fidgetWait = nextFidgetDelay();
let hopWait: number = fidget.hopIntervalSeconds;
let fidgetTime = 0; let fidgetTime = 0;
let fidgetAction: 'scratch' | 'hop' | undefined; let fidgetAction: 'scratch' | 'hop' | undefined;
let idleWeight = 0; let idleWeight = 0;
@@ -173,30 +171,22 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const idle = animateIdle && !moving && emote === 'idle'; const idle = animateIdle && !moving && emote === 'idle';
rigModel.position.copy(modelRestPosition); rigModel.position.copy(modelRestPosition);
if (!idle) { if (!idle) {
fidgetWait = nextFidgetDelay(); fidgetWait = fidget.intervalSeconds;
hopWait = fidget.hopIntervalSeconds;
fidgetTime = 0; fidgetTime = 0;
fidgetAction = undefined; fidgetAction = undefined;
} else { } else {
// Do not fast-forward gestures after a background tab resumes. // Do not fast-forward gestures after a background tab resumes.
const idleDelta = Math.min(delta, .1); const idleDelta = Math.min(delta, .1);
hopWait -= idleDelta; fidgetWait -= idleDelta;
if (hopWait <= 0) { if (fidgetWait <= 0) {
fidgetAction = 'hop'; fidgetAction = Math.random() < .5 ? 'scratch' : 'hop';
fidgetTime = 0; fidgetTime = 0;
hopWait += fidget.hopIntervalSeconds; fidgetWait += fidget.intervalSeconds;
} else if (!fidgetAction) { } else if (fidgetAction) {
fidgetWait -= idleDelta;
if (fidgetWait <= 0) {
fidgetAction = 'scratch';
fidgetTime = 0;
}
} else {
fidgetTime += idleDelta; fidgetTime += idleDelta;
const duration = fidgetAction === 'scratch' ? fidget.scratchSeconds : fidget.hopSeconds; const duration = fidgetAction === 'scratch' ? fidget.scratchSeconds : fidget.hopSeconds;
if (fidgetTime >= duration) { if (fidgetTime >= duration) {
fidgetAction = undefined; fidgetAction = undefined;
fidgetWait = nextFidgetDelay();
} }
} }
} }
+4 -11
View File
@@ -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 { COMPANION_MENU, EQUIPMENT_NOTICE, PRAYER_NOTICE } from '../constants/ui.ts'; import { 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';
@@ -175,11 +175,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
let companionAnchor: ReturnType<typeof createCharacterOverlay> | 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 companionName = document.getElementById('companion-name')!;
const companionStatus = document.getElementById('companion-status')!;
const besideButton = document.querySelector<HTMLButtonElement>('#stand-beside')!; const besideButton = document.querySelector<HTMLButtonElement>('#stand-beside')!;
const closeCompanion = () => { selectedCompanion = undefined; companionAnchor = undefined; companionPanel.hidden = true; companionStatus.textContent = ''; }; const closeCompanion = () => { selectedCompanion = undefined; companionAnchor = undefined; besideButton.hidden = true; };
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);
@@ -215,8 +212,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (!id) return false; if (!id) return false;
selectedCompanion = id; selectedCompanion = id;
const player = remotePlayers!.get(id)!; const player = remotePlayers!.get(id)!;
companionName.textContent = player.name; companionAnchor = createCharacterOverlay(player, canvas, besideButton);
companionAnchor = createCharacterOverlay(player, canvas, companionPanel, COMPANION_MENU.margin);
rendering.invalidate(); rendering.invalidate();
return true; return true;
}, },
@@ -227,7 +223,6 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
directionButton.addEventListener('click', () => { directionButton.addEventListener('click', () => {
if (sheep && !networkBlocked && !overview) controls.setTurning(!controls.turning); if (sheep && !networkBlocked && !overview) controls.setTurning(!controls.turning);
}, { signal: emoteEvents.signal }); }, { signal: emoteEvents.signal });
document.getElementById('companion-close')!.addEventListener('click', closeCompanion, { signal: emoteEvents.signal });
besideButton.addEventListener('click', () => { besideButton.addEventListener('click', () => {
if (!selectedCompanion || networkBlocked || overview) return; if (!selectedCompanion || networkBlocked || overview) return;
const spot = remotePlayers?.beside(selectedCompanion, camera.position, point => { const spot = remotePlayers?.beside(selectedCompanion, camera.position, point => {
@@ -244,10 +239,9 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
} }
return true; return true;
}); });
if (!spot) { companionStatus.textContent = '바로 걸어갈 수 있는 빈자리가 없어요. 조금 더 가까이 이동해 주세요.'; return; } if (!spot) return;
emotes.cancel(); refreshEmotes(); emotes.cancel(); refreshEmotes();
controls.moveTo(spot.position, spot.yaw); controls.moveTo(spot.position, spot.yaw);
companionStatus.textContent = '옆자리로 이동 중';
}, { signal: emoteEvents.signal }); }, { signal: emoteEvents.signal });
const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!; const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
const primaryActionLabel = document.getElementById('primary-action-label')!; const primaryActionLabel = document.getElementById('primary-action-label')!;
@@ -425,7 +419,6 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (arrived || (!moving && !turning)) { if (arrived || (!moving && !turning)) {
if (controls.arrivalYaw !== undefined) { if (controls.arrivalYaw !== undefined) {
if (arrived && sheep) sheep.group.rotation.y = controls.arrivalYaw; if (arrived && sheep) sheep.group.rotation.y = controls.arrivalYaw;
companionStatus.textContent = arrived ? '나란히 섰어요. 드래그로 구도를 맞춰 보세요.' : '이동이 막혔어요. 조금 더 가까이 이동해 주세요.';
} }
controls.stop(); controls.stop();
} }
+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; } button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
:focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; } :focus-visible { outline: 2px solid #f0c48e; outline-offset: 3px; }
.hud { position: fixed; inset: 0; pointer-events: none; } .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 { 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-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; } .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; } .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 { 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 { margin: 0; padding: 10px 12px; border: 1px solid #ffffff26; border-radius: 14px; background: #18232ef2; font-size: 12px; line-height: 1.5; }
#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; } #stand-beside { min-width: 0; min-height: 0; width: max-content; 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; }
@media (max-width: 600px) { @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); } .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; } #direction-toggle { padding: 8px 12px; font-size: 12px; background: #18232ee6; }