diff --git a/index.html b/index.html index 3082658..032fa97 100644 --- a/index.html +++ b/index.html @@ -79,7 +79,7 @@
- +
diff --git a/src/constants/flashlight.ts b/src/constants/flashlight.ts index 01cd01c..ed8a77d 100644 --- a/src/constants/flashlight.ts +++ b/src/constants/flashlight.ts @@ -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; diff --git a/src/controls/flashlight.ts b/src/controls/flashlight.ts new file mode 100644 index 0000000..4cba1e1 --- /dev/null +++ b/src/controls/flashlight.ts @@ -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; +} diff --git a/src/scene/world.ts b/src/scene/world.ts index e0e9eb5..da92453 100644 --- a/src/scene/world.ts +++ b/src/scene/world.ts @@ -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>, 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('#emote-wave')!; + const primaryActionLabel = document.getElementById('primary-action-label')!; + const primaryActionIcon = waveButton.querySelector('use')!; const prayButton = document.querySelector('#emote-pray')!; const emoteStatus = document.getElementById('emote-status')!; const rainGear = createRainGearState(); @@ -233,19 +236,26 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited 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 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 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(); diff --git a/tests/flashlight.test.ts b/tests/flashlight.test.ts new file mode 100644 index 0000000..ee0fb75 --- /dev/null +++ b/tests/flashlight.test.ts @@ -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'); +});