Enable flashlight at sunset and switch primary night action

This commit is contained in:
2026-10-31 23:27:14 +09:00
parent eb49ea4b84
commit ff86bdb5b5
5 changed files with 49 additions and 11 deletions
+1 -1
View File
@@ -79,7 +79,7 @@
</div>
</div>
<div class="emote-controls" aria-label="캐릭터 액션">
<button type="button" id="emote-wave" disabled aria-pressed="false" aria-describedby="emote-status" title="손 흔들기 · 단축키 1"><svg class="icon" aria-hidden="true"><use href="#icon-wave"/></svg>인사</button>
<button type="button" id="emote-wave" disabled aria-pressed="false" aria-describedby="emote-status" title="손 흔들기 · 단축키 1"><svg class="icon" aria-hidden="true"><use href="#icon-wave"/></svg><span id="primary-action-label">인사</span></button>
<button type="button" id="emote-pray" disabled aria-pressed="false" aria-describedby="emote-status" title="소원빌기 · 일출 전후 5분 · 단축키 2"><svg class="icon" aria-hidden="true"><use href="#icon-sunrise"/></svg>소원빌기</button>
<div class="equipment-menu">
<button type="button" id="equipment-toggle" aria-expanded="false" aria-controls="equipment-options"><svg class="icon" aria-hidden="true"><use href="#icon-umbrella"/></svg>장비</button>
+2 -1
View File
@@ -8,5 +8,6 @@ export const FLASHLIGHT = {
halfAngle: 28 * Math.PI / 180,
penumbra: 0.6,
color: '#fff2dc',
nightAltitude: 0,
// Apparent solar centre below the horizon by roughly one solar radius.
nightAltitude: -0.3,
} as const;
+7
View File
@@ -0,0 +1,7 @@
import { FLASHLIGHT } from '../constants/flashlight.ts';
/** Auto-enable only on night entry; preserve manual choices during the night. */
export function flashlightAfterSunUpdate(on: boolean, previousAltitude: number | undefined, altitude: number) {
if (altitude > FLASHLIGHT.nightAltitude) return false;
return previousAltitude === undefined || previousAltitude > FLASHLIGHT.nightAltitude ? true : on;
}
+25 -9
View File
@@ -52,6 +52,7 @@ import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
import { createSelfBadge } from '../ui/selfBadge.ts';
import { createFlashlight } from './flashlight.ts';
import { FLASHLIGHT } from '../constants/flashlight.ts';
import { flashlightAfterSunUpdate } from '../controls/flashlight.ts';
export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<ReturnType<typeof loadTerrain>>, buildingData: BuildingAssets | null,
onMove: (location: { latitude: number; longitude: number; elevation: number }) => void, coast?: LoadedCoast, structureAssets?: StructureAssets, reconstructSummit = false, summitGeometry?: BufferGeometry,
@@ -182,6 +183,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
const emotes = createEmoteState();
const emoteEvents = new AbortController();
const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
const primaryActionLabel = document.getElementById('primary-action-label')!;
const primaryActionIcon = waveButton.querySelector('use')!;
const prayButton = document.querySelector<HTMLButtonElement>('#emote-pray')!;
const emoteStatus = document.getElementById('emote-status')!;
const rainGear = createRainGearState();
@@ -233,19 +236,26 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (!sheep || overview || networkBlocked || !solarState || solarState.altitude > FLASHLIGHT.nightAltitude) return;
flashlightOn = !flashlightOn;
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
refreshGear(); rendering.invalidate();
refreshEmotes(); rendering.invalidate();
};
flashlightButton.addEventListener('click', toggleFlashlight, { signal: emoteEvents.signal });
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
const refreshEmotes = () => {
refreshGear();
waveButton.disabled = !sheep || overview;
const dark = Boolean(solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
waveButton.disabled = !sheep || overview || networkBlocked;
primaryActionLabel.textContent = dark ? (flashlightOn ? '끄기' : '켜기') : '인사';
primaryActionIcon.setAttribute('href', dark ? '#icon-flashlight' : '#icon-wave');
const primaryActionName = dark ? `손전등 ${flashlightOn ? '끄기' : '켜기'}` : '손 흔들기';
waveButton.setAttribute('aria-label', primaryActionName);
waveButton.title = `${primaryActionName} · 단축키 1`;
prayButton.disabled = !sheep || overview;
prayButton.setAttribute('aria-disabled', String(prayButton.disabled || !prayerAvailable()));
prayButton.dataset.unavailableReason = prayerAvailable() ? '' : PRAYER_NOTICE;
waveButton.setAttribute('aria-pressed', String(emotes.current === 'wave'));
waveButton.setAttribute('aria-pressed', String(dark ? flashlightOn : emotes.current === 'wave'));
prayButton.setAttribute('aria-pressed', String(emotes.current === 'pray'));
emoteStatus.textContent = overview ? '조감 보기에서는 감정표현을 사용할 수 없습니다'
: dark ? '1 손전등 켜기·끄기 / 2 소원빌기'
: emotes.current === 'wave' ? '손 흔드는 중 · 이동하면 종료'
: emotes.current === 'pray' ? '일출을 보며 소원 비는 중 · 이동하면 종료'
: prayerAvailable() ? '소원빌기 가능 · 1 인사 / 2 소원빌기 · 이동하면 종료'
@@ -260,7 +270,11 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
refreshEmotes();
rendering.invalidate();
};
waveButton.addEventListener('click', () => startEmote('wave'), { signal: emoteEvents.signal });
const primaryAction = () => {
if (solarState && solarState.altitude <= FLASHLIGHT.nightAltitude) toggleFlashlight();
else startEmote('wave');
};
waveButton.addEventListener('click', primaryAction, { signal: emoteEvents.signal });
prayButton.addEventListener('click', () => startEmote('pray'), { signal: emoteEvents.signal });
window.addEventListener('keydown', event => {
if (!controls.active() || event.repeat || !['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6'].includes(event.code)) return;
@@ -269,7 +283,8 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (['Digit3', 'Digit4', 'Digit5'].includes(event.code)) {
toggleGear(event.code === 'Digit3' ? 'umbrella' : event.code === 'Digit4' ? 'raincoat' : 'hood'); return;
}
startEmote(event.code === 'Digit1' ? 'wave' : 'pray');
if (event.code === 'Digit1') primaryAction();
else startEmote('pray');
}, { signal: emoteEvents.signal });
let eyeHeight = observer.eyeHeight;
let walkedDistance = 0;
@@ -415,7 +430,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
camera.position.x = state.x; camera.position.z = state.z;
if (sheep) sheep.group.rotation.y = state.yaw;
rainGear.apply(state.equipment);
flashlightOn = Boolean(state.equipment.flashlight && solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
flashlightOn = Boolean((state.equipment.flashlight ?? flashlightOn) && solarState && solarState.altitude <= FLASHLIGHT.nightAltitude);
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
placeObserver(); refreshEmotes(); rendering.invalidate();
},
@@ -465,10 +480,11 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
placeObserver();
},
update(state: SunState) {
const nextFlashlightOn = flashlightAfterSunUpdate(flashlightOn, solarState?.altitude, state.altitude);
solarState = state;
if (flashlightOn && state.altitude > FLASHLIGHT.nightAltitude) {
flashlightOn = false;
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, false);
if (flashlightOn !== nextFlashlightOn) {
flashlightOn = nextFlashlightOn;
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
}
emotes.update(0, false, prayerAvailable());
refreshEmotes();
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { flashlightAfterSunUpdate } from '../src/controls/flashlight.ts';
test('sunset enables the flashlight once, preserves manual off, and resets at dawn', () => {
assert.equal(flashlightAfterSunUpdate(false, undefined, 20), false);
assert.equal(flashlightAfterSunUpdate(false, 1, -0.1), false, 'wait until the solar disc has set');
assert.equal(flashlightAfterSunUpdate(false, -0.1, -1), true);
assert.equal(flashlightAfterSunUpdate(false, -1, -2), false, 'manual off survives time updates');
assert.equal(flashlightAfterSunUpdate(true, -2, -3), true);
assert.equal(flashlightAfterSunUpdate(true, -3, 1), false);
assert.equal(flashlightAfterSunUpdate(false, 1, -1), true, 'next night enables again');
assert.equal(flashlightAfterSunUpdate(false, undefined, -10), true, 'nighttime arrival enables the torch');
});