Enable flashlight at sunset and switch primary night action
This commit is contained in:
+1
-1
@@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="emote-controls" aria-label="캐릭터 액션">
|
<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>
|
<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">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export const FLASHLIGHT = {
|
|||||||
halfAngle: 28 * Math.PI / 180,
|
halfAngle: 28 * Math.PI / 180,
|
||||||
penumbra: 0.6,
|
penumbra: 0.6,
|
||||||
color: '#fff2dc',
|
color: '#fff2dc',
|
||||||
nightAltitude: 0,
|
// Apparent solar centre below the horizon by roughly one solar radius.
|
||||||
|
nightAltitude: -0.3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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
@@ -52,6 +52,7 @@ import type { PlayerState, PlayerUpdate } from '../multiplayer/protocol.ts';
|
|||||||
import { createSelfBadge } from '../ui/selfBadge.ts';
|
import { createSelfBadge } from '../ui/selfBadge.ts';
|
||||||
import { createFlashlight } from './flashlight.ts';
|
import { createFlashlight } from './flashlight.ts';
|
||||||
import { FLASHLIGHT } from '../constants/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,
|
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,
|
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 emotes = createEmoteState();
|
||||||
const emoteEvents = new AbortController();
|
const emoteEvents = new AbortController();
|
||||||
const waveButton = document.querySelector<HTMLButtonElement>('#emote-wave')!;
|
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 prayButton = document.querySelector<HTMLButtonElement>('#emote-pray')!;
|
||||||
const emoteStatus = document.getElementById('emote-status')!;
|
const emoteStatus = document.getElementById('emote-status')!;
|
||||||
const rainGear = createRainGearState();
|
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;
|
if (!sheep || overview || networkBlocked || !solarState || solarState.altitude > FLASHLIGHT.nightAltitude) return;
|
||||||
flashlightOn = !flashlightOn;
|
flashlightOn = !flashlightOn;
|
||||||
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
sheep.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||||
refreshGear(); rendering.invalidate();
|
refreshEmotes(); rendering.invalidate();
|
||||||
};
|
};
|
||||||
flashlightButton.addEventListener('click', toggleFlashlight, { signal: emoteEvents.signal });
|
flashlightButton.addEventListener('click', toggleFlashlight, { signal: emoteEvents.signal });
|
||||||
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
|
const prayerAvailable = () => Boolean(solarState && canPray(solarState.date, solarState.sunrise));
|
||||||
const refreshEmotes = () => {
|
const refreshEmotes = () => {
|
||||||
refreshGear();
|
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.disabled = !sheep || overview;
|
||||||
prayButton.setAttribute('aria-disabled', String(prayButton.disabled || !prayerAvailable()));
|
prayButton.setAttribute('aria-disabled', String(prayButton.disabled || !prayerAvailable()));
|
||||||
prayButton.dataset.unavailableReason = prayerAvailable() ? '' : PRAYER_NOTICE;
|
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'));
|
prayButton.setAttribute('aria-pressed', String(emotes.current === 'pray'));
|
||||||
emoteStatus.textContent = overview ? '조감 보기에서는 감정표현을 사용할 수 없습니다'
|
emoteStatus.textContent = overview ? '조감 보기에서는 감정표현을 사용할 수 없습니다'
|
||||||
|
: dark ? '1 손전등 켜기·끄기 / 2 소원빌기'
|
||||||
: emotes.current === 'wave' ? '손 흔드는 중 · 이동하면 종료'
|
: emotes.current === 'wave' ? '손 흔드는 중 · 이동하면 종료'
|
||||||
: emotes.current === 'pray' ? '일출을 보며 소원 비는 중 · 이동하면 종료'
|
: emotes.current === 'pray' ? '일출을 보며 소원 비는 중 · 이동하면 종료'
|
||||||
: prayerAvailable() ? '소원빌기 가능 · 1 인사 / 2 소원빌기 · 이동하면 종료'
|
: prayerAvailable() ? '소원빌기 가능 · 1 인사 / 2 소원빌기 · 이동하면 종료'
|
||||||
@@ -260,7 +270,11 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
refreshEmotes();
|
refreshEmotes();
|
||||||
rendering.invalidate();
|
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 });
|
prayButton.addEventListener('click', () => startEmote('pray'), { signal: emoteEvents.signal });
|
||||||
window.addEventListener('keydown', event => {
|
window.addEventListener('keydown', event => {
|
||||||
if (!controls.active() || event.repeat || !['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6'].includes(event.code)) return;
|
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)) {
|
if (['Digit3', 'Digit4', 'Digit5'].includes(event.code)) {
|
||||||
toggleGear(event.code === 'Digit3' ? 'umbrella' : event.code === 'Digit4' ? 'raincoat' : 'hood'); return;
|
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 });
|
}, { signal: emoteEvents.signal });
|
||||||
let eyeHeight = observer.eyeHeight;
|
let eyeHeight = observer.eyeHeight;
|
||||||
let walkedDistance = 0;
|
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;
|
camera.position.x = state.x; camera.position.z = state.z;
|
||||||
if (sheep) sheep.group.rotation.y = state.yaw;
|
if (sheep) sheep.group.rotation.y = state.yaw;
|
||||||
rainGear.apply(state.equipment);
|
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);
|
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||||
placeObserver(); refreshEmotes(); rendering.invalidate();
|
placeObserver(); refreshEmotes(); rendering.invalidate();
|
||||||
},
|
},
|
||||||
@@ -465,10 +480,11 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
|
|||||||
placeObserver();
|
placeObserver();
|
||||||
},
|
},
|
||||||
update(state: SunState) {
|
update(state: SunState) {
|
||||||
|
const nextFlashlightOn = flashlightAfterSunUpdate(flashlightOn, solarState?.altitude, state.altitude);
|
||||||
solarState = state;
|
solarState = state;
|
||||||
if (flashlightOn && state.altitude > FLASHLIGHT.nightAltitude) {
|
if (flashlightOn !== nextFlashlightOn) {
|
||||||
flashlightOn = false;
|
flashlightOn = nextFlashlightOn;
|
||||||
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, false);
|
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
|
||||||
}
|
}
|
||||||
emotes.update(0, false, prayerAvailable());
|
emotes.update(0, false, prayerAvailable());
|
||||||
refreshEmotes();
|
refreshEmotes();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user