Add sunrise Threads automation and improve capture and character interactions

This commit is contained in:
2026-11-02 03:28:58 +09:00
parent ff86bdb5b5
commit 850ac84ef7
34 changed files with 900 additions and 34 deletions
+6
View File
@@ -25,3 +25,9 @@ export function calculateSun(date: Date, observer: ObserverConfig): SunState {
standardSunrise: standard.sunrise, standardSunset: standard.sunset,
};
}
export function sunriseAzimuth(date: Date, observer: ObserverConfig): number {
const sunrise = calculateSun(date, observer).sunrise;
return sunrise && Number.isFinite(+sunrise)
? getPosition(sunrise, observer.latitude, observer.longitude).azimuth : 90;
}
+1
View File
@@ -1,6 +1,7 @@
export const FLASHLIGHT = {
url: '/characters/flashlight.glb?v=1',
lumens: 600,
poseTransitionSeconds: 0.35,
bloomStrengthScale: 0.08,
bloomThreshold: 4,
bloomRadius: 0.03,
+3 -1
View File
@@ -1,4 +1,6 @@
export const RAIN_GEAR = {
umbrellaUrl: '/characters/umbrella.glb?v=1',
umbrellaUrl: '/characters/umbrella.glb?v=2',
umbrellaTransitionSeconds: 0.45,
umbrellaGripDirection: [.05, -.45, .7],
raincoatUrl: '/characters/raincoat.glb?v=5',
} as const;
+3
View File
@@ -0,0 +1,3 @@
export const SCREENSHOT = {
objectUrlLifetimeMs: 60000,
} as const;
+15
View File
@@ -0,0 +1,15 @@
export const THREADS = {
siteUrl: 'https://sunrise.horoli.kr',
apiUrl: 'https://graph.threads.net',
prepareMs: 5 * 60_000,
maxLatenessMs: 30_000,
requestTimeoutMs: 30_000,
mediaPollMs: 5_000,
mediaPollAttempts: 24,
tokenRefreshMs: 7 * 86_400_000,
viewport: { width: 1080, height: 1728 },
angles: [
{ name: 'sunrise', offset: 0, pitch: 0, alt: '일출을 바라보는 캐릭터의 뒷모습과 관악산 풍경' },
{ name: 'portrait', offset: 200, pitch: 20, alt: '일출 시각 관악산 정상의 캐릭터 정면과 하늘' },
],
} as const;
+1 -1
View File
@@ -1,7 +1,7 @@
export const DIALOG_TITLES = {
menu: '메뉴', location: '장소', time: '날짜·시간', information: '관측 정보',
view: '지도·시점', session: '닉네임·접속 상태',
settings: '설정·조작 방법', sources: '출처·오픈소스',
settings: '설정·조작 방법', sources: '출처·오픈소스', screenshot: '화면 촬영',
} as const;
export const EQUIPMENT_NOTICE = {
+7 -2
View File
@@ -4,6 +4,7 @@ export type RainGearItem = 'umbrella' | 'raincoat' | 'hood';
export function createRainGearState() {
const worn = { umbrella: false, raincoat: false, hood: false };
let available = false;
let awaitingArrivalWeather = true;
return {
get available() { return available; },
get umbrella() { return worn.umbrella; },
@@ -14,8 +15,12 @@ export function createRainGearState() {
worn.raincoat = state.raincoat;
worn.hood = state.raincoat && state.hood;
},
updateWeather(rain: number, snowfall: number) {
available = rain > 0 || snowfall > 0;
updateWeather(rain: number | null, snowfall: number | null) {
available = (rain ?? 0) > 0 || (snowfall ?? 0) > 0;
if (awaitingArrivalWeather && rain !== null) {
if (rain > 0) { worn.raincoat = true; worn.hood = true; }
awaitingArrivalWeather = false;
}
if (!available) { worn.umbrella = false; worn.raincoat = false; worn.hood = false; }
},
toggle(item: RainGearItem) {
+15 -3
View File
@@ -1,7 +1,8 @@
import './style.css';
import { createDialogs } from './ui/dialogs.ts';
import { createScreenshot } from './ui/screenshot.ts';
import { loadSummitAssets } from './scene/summitAssets.ts';
import { calculateSun } from './astronomy/sunCalculator.ts';
import { calculateSun, sunriseAzimuth } from './astronomy/sunCalculator.ts';
import { createWorld } from './scene/world.ts';
import { createInformationPanel } from './ui/informationPanel.ts';
import { TimeController } from './ui/timeController.ts';
@@ -104,6 +105,7 @@ export async function start(options: StartOptions = {}) {
update();
}, coast, structures, reconstructSummit, summitGeometry, sheep);
const viewEvents = new AbortController();
const screenshot = createScreenshot(() => world.captureFrame());
document.querySelectorAll<HTMLButtonElement>('[data-look]').forEach(button => {
button.addEventListener('click', () => world.lookAtStructure(button.dataset.look!), { signal: viewEvents.signal });
});
@@ -151,6 +153,9 @@ export async function start(options: StartOptions = {}) {
update();
});
world.resetPosition();
const faceSunrise = () => world.lookAtSunrise(sunriseAzimuth(selectedDate, effectiveObserver));
faceSunrise();
let firstJoin = true;
const connectionStatus = document.getElementById('connection-status')!;
const modeButton = document.getElementById('session-mode') as HTMLButtonElement;
const retryButton = document.getElementById('retry-connection') as HTMLButtonElement;
@@ -166,7 +171,10 @@ export async function start(options: StartOptions = {}) {
nickname.value = `방문자-${visitorId.slice(-4)}`;
const multiplayer = createMultiplayerClient({
readPlayer: () => world.readPlayer(),
joined: player => world.applyPlayer(player),
joined: player => {
world.applyPlayer(player);
if (firstJoin) { faceSunrise(); firstJoin = false; }
},
correction: player => world.applyPlayer(player),
snapshot: (state, selfId) => world.synchronizePlayers(state.players, selfId),
weather: state => { if (sharedMode) applyWeather(state); },
@@ -230,9 +238,13 @@ export async function start(options: StartOptions = {}) {
document.querySelector<HTMLFieldSetElement>('#time-controls')!.disabled = false;
window.addEventListener('pagehide', (event) => {
multiplayer.disconnect();
if (!event.persisted) { dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
if (!event.persisted) { screenshot.dispose(); dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
});
window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); });
if (query.get('capture') === 'threads' && !sharedMode) {
const { installThreadsCapture } = await import('./ui/threadsCapture.ts');
installThreadsCapture(effectiveObserver, world);
}
options.ready?.();
} catch (error) {
status.textContent = '지형을 불러오지 못했습니다';
+34 -7
View File
@@ -44,6 +44,11 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
let foldedHood: Object3D | undefined;
if (gear) {
model.add(gear.umbrella, gear.raincoat);
// Preserve the authored overhead position in the held pose, then let the hand carry it.
joints.ArmRight.quaternion.setFromUnitVectors(new Vector3(0, -1, 0),
new Vector3(...RAIN_GEAR.umbrellaGripDirection).normalize()).multiply(rest.ArmRight);
joints.ArmRight.attach(gear.umbrella);
joints.ArmRight.quaternion.copy(rest.ArmRight);
hood = gear.raincoat.getObjectByName('CoatHood');
foldedHood = gear.raincoat.getObjectByName('CoatFoldedHood');
if (!hood || !foldedHood) throw new Error('우비 후드 모델 누락');
@@ -112,7 +117,10 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
const rigHood = copy(hood), rigFoldedHood = copy(foldedHood), rigUmbrella = copy(gear?.umbrella);
const rigFlashlight = copy(gear?.flashlight);
let flashlightOn = false;
let flashlightPose = 0;
let previousPoseTime: number | undefined;
let umbrellaOpen = false;
let umbrellaPose = 0;
const rotation = new Quaternion();
const down = new Vector3(0, -1, 0);
const aim = new Vector3();
@@ -128,9 +136,9 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
clone() { return bindRig(group.clone(true), false); },
setEquipment(umbrella: boolean, raincoat: boolean, hoodUp = false, flashlight = false) {
flashlightOn = flashlight;
if (rigFlashlight) rigFlashlight.visible = flashlight;
if (rigFlashlight) rigFlashlight.visible = flashlight || flashlightPose > 0;
umbrellaOpen = umbrella;
if (rigUmbrella) rigUmbrella.visible = umbrella;
if (rigUmbrella) rigUmbrella.visible = umbrella || umbrellaPose > 0;
rigCoatParts.forEach(part => { part.visible = raincoat; });
if (rigHood) rigHood.visible = raincoat && hoodUp;
if (rigFoldedHood) rigFoldedHood.visible = raincoat && !hoodUp;
@@ -144,13 +152,24 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
for (const [material, color] of baseColors) material.color.copy(color).multiplyScalar(brightness);
},
pose(emote: Emote, elapsed: number, moving: boolean, time: number) {
const changed = moving || emote !== 'idle' || previousMoving !== moving || previousEmote !== emote;
const delta = Math.max(0, time - (previousPoseTime ?? time));
const step = delta / FLASHLIGHT.poseTransitionSeconds;
previousPoseTime = time;
const previousFlashlightPose = flashlightPose;
flashlightPose = flashlightOn ? Math.min(1, flashlightPose + step) : Math.max(0, flashlightPose - step);
const previousUmbrellaPose = umbrellaPose;
const umbrellaStep = delta / RAIN_GEAR.umbrellaTransitionSeconds;
umbrellaPose = umbrellaOpen ? Math.min(1, umbrellaPose + umbrellaStep) : Math.max(0, umbrellaPose - umbrellaStep);
const carryingUmbrella = umbrellaOpen || umbrellaPose > 0;
const changed = 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]);
if (emote === 'wave') {
pointArm(umbrellaOpen ? 'ArmLeft' : 'ArmRight',
aim.set(umbrellaOpen ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
pointArm(carryingUmbrella ? 'ArmLeft' : 'ArmRight',
aim.set(carryingUmbrella ? -1 : 1, .35 + Math.sin(elapsed * 12) * .3, -.15));
} else if (emote === 'pray') {
pointArm('ArmLeft', aim.set(.68, -.22, .64));
pointArm('ArmRight', aim.set(-.68, -.22, .64));
@@ -163,8 +182,16 @@ export function createSheep(model: Group, gear?: { umbrella: Group; raincoat: Gr
joints.ArmRight.rotateX(stride * .6);
}
// Keep the grip stable while the free arm and legs perform their own animations.
if (umbrellaOpen) pointArm('ArmRight', aim.set(.05, -.45, .7));
if (flashlightOn) pointArm('ArmLeft', aim.set(-.08, -.22, 1));
if (umbrellaPose > 0) {
rotation.setFromUnitVectors(down, aim.set(...RAIN_GEAR.umbrellaGripDirection).normalize()).multiply(rest.ArmRight);
joints.ArmRight.quaternion.slerp(rotation, MathUtils.smoothstep(umbrellaPose, 0, 1));
}
if (rigUmbrella) rigUmbrella.visible = carryingUmbrella;
if (flashlightPose > 0) {
rotation.setFromUnitVectors(down, aim.set(-.08, -.22, 1).normalize()).multiply(rest.ArmLeft);
joints.ArmLeft.quaternion.slerp(rotation, MathUtils.smoothstep(flashlightPose, 0, 1));
}
if (rigFlashlight) rigFlashlight.visible = flashlightOn || flashlightPose > 0;
return changed;
},
dispose() {
+40 -1
View File
@@ -414,6 +414,30 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
});
const world = {
buildingCounts: buildings?.counts,
captureFrame(view?: PerspectiveCamera) {
const activeCamera = view ?? (overview ? overviewCamera : sheep ? followCamera : camera);
if (composer && !profile?.disablePost) {
renderPass.camera = activeCamera;
composer.render(0);
} else renderer.render(scene, activeCamera);
// Copy synchronously before WebGL clears the drawing buffer.
const frame = document.createElement('canvas');
frame.width = canvas.width;
frame.height = canvas.height;
frame.getContext('2d')!.drawImage(canvas, 0, 0);
return frame;
},
captureAngle(azimuth: number, pitch: number) {
// Orbit only the capture camera; keep the character facing the sunrise.
const view = camera.clone();
view.lookAt(view.position.clone().add(new Vector3(...directionFromAngles(azimuth, pitch))));
if (sheep) {
const orbit = view.clone();
updateThirdPersonCamera(orbit, view, (x, z) => harbour?.heightAt(x, z) ?? terrain.heightAt(x, z));
return world.captureFrame(orbit);
}
return world.captureFrame(view);
},
readPlayer(): PlayerUpdate {
const yaw = sheep?.group.rotation.y ?? camera.rotation.y;
return { x: camera.position.x, z: camera.position.z, yaw: Math.atan2(Math.sin(yaw), Math.cos(yaw)),
@@ -440,7 +464,7 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
clearPlayers() { remotePlayers?.clear(); rendering.invalidate(); },
setWeather(weather: WeatherSample | null) {
weatherTarget = weatherAppearance(weather);
rainGear.updateWeather(weatherTarget.rain, weatherTarget.snowfall);
rainGear.updateWeather(weather?.rain ?? null, weather?.snowfall ?? null);
sheep?.setEquipment(rainGear.umbrella, rainGear.raincoat, rainGear.hood, flashlightOn);
refreshGear();
// Do not blend an old precipitation type into the newly selected weather.
@@ -475,6 +499,21 @@ export function createWorld(canvas: HTMLCanvasElement, terrainData: Awaited<Retu
if (target) camera.lookAt(target.position.clone().add(new Vector3(0, id.includes('mast') ? 14 : id === 'weather-radar' ? 10 : 1, 0)));
},
lookEast() { restoreView(); camera.lookAt(camera.position.clone().add(new Vector3(1, 0, 0))); },
lookAtSunrise(azimuth: number) {
restoreView();
forward.set(...directionFromAngles(azimuth, 0));
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));
}
},
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));
},
setEyeHeight(meters: number) {
eyeHeight = meters;
placeObserver();
+4 -2
View File
@@ -29,6 +29,8 @@ button[aria-pressed=true]:hover, button.primary:hover { background: #f0cca0; }
.scene-toolbar { position: absolute; top: max(18px, env(safe-area-inset-top)); left: max(18px, env(safe-area-inset-left)); right: max(18px, env(safe-area-inset-right)); display: flex; justify-content: space-between; gap: 12px; }
.scene-toolbar button, .emote-controls button, #minimap-overlay { pointer-events: auto; box-shadow: 0 3px 14px #0003; }
.toolbar-end, .button-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
#screenshot-status { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: calc(100vw - 32px); margin: 0; padding: 12px 18px; border-radius: 14px; background: #18232ef2; color: #eef1f4; font-size: 13px; pointer-events: none; animation: screenshot-status 4s forwards; }
@keyframes screenshot-status { 0%, 80% { opacity: 1; } 100% { opacity: 0; } }
.emote-controls { position: absolute; right: max(18px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); display: flex; gap: 10px; }
.emote-controls button { flex-direction: column; gap: 6px; min-width: 70px; padding: 12px 14px; background: #eee5d6; border-color: #fff3dd80; color: #3a382f; font-size: 12px; }
.emote-controls button:hover:not(:disabled) { background: #fff0d8; }
@@ -170,11 +172,11 @@ summary { padding: 12px 0; cursor: pointer; font-size: 14px; }
.scene-toolbar { top: max(8px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); right: max(10px, env(safe-area-inset-right)); }
.scene-toolbar button { width: 44px; height: 44px; min-height: 44px; padding: 10px; border-radius: 12px; }
#current-location { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
#compass { top: calc(max(8px, env(safe-area-inset-top)) + 3px); width: calc(100vw - 140px); height: 52px; }
#compass { top: calc(max(8px, env(safe-area-inset-top)) + 3px); left: calc(50% - 26px); width: calc(100vw - 192px); height: 52px; }
.compass-scale { height: 34px; }
.compass-tick { padding-top: 15px; font-size: 10px; }
.compass-tick.cardinal { font-size: 12px; }
.compass-tick:not(.cardinal) { font-size: 0; }
.compass-tick:not(.cardinal) { font-size: 0; color: transparent; text-shadow: none; }
.compass-bearing { font-size: 10px; }
.emote-controls { right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); gap: 6px; }
.emote-controls button { min-width: 44px; min-height: 48px; padding: 5px 6px; gap: 3px; border-radius: 11px; font-size: 10px; }
+55
View File
@@ -0,0 +1,55 @@
import { SCREENSHOT } from '../constants/screenshot.ts';
export function createScreenshot(captureFrame: () => HTMLCanvasElement) {
const opener = document.querySelector<HTMLButtonElement>('#screenshot-open')!;
const dialog = document.querySelector<HTMLDialogElement>('#ui-dialog')!;
const status = document.getElementById('screenshot-status')!;
const events = new AbortController();
let busy = false;
opener.disabled = false;
opener.addEventListener('click', event => {
if (busy) event.stopPropagation();
}, { signal: events.signal });
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-screenshot]')) {
button.addEventListener('click', async () => {
if (busy) return;
busy = true;
dialog.close();
status.hidden = true;
try {
const includeUi = button.dataset.screenshot === 'ui';
const toCanvas = includeUi ? (await import('html-to-image')).toCanvas : null;
if (toCanvas) await document.fonts.ready;
const frame = captureFrame();
if (toCanvas) {
const overlay = await toCanvas(document.body, {
width: innerWidth, height: innerHeight,
canvasWidth: frame.width, canvasHeight: frame.height, pixelRatio: 1,
filter: node => !node.matches?.('#world, #lobby, #ui-dialog, #screenshot-status, script'),
});
frame.getContext('2d')!.drawImage(overlay, 0, 0, frame.width, frame.height);
}
const blob = await new Promise<Blob>((resolve, reject) => {
frame.toBlob(value => value ? resolve(value) : reject(new Error('PNG encoding failed')), 'image/png');
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `sunrise-${new Date().toISOString().replace(/[:.]/g, '-')}-${includeUi ? 'ui' : 'scene'}.png`;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), SCREENSHOT.objectUrlLifetimeMs);
status.textContent = 'PNG 이미지 다운로드를 시작했습니다.';
} catch (error) {
console.error('Screenshot failed', error);
status.textContent = '사진을 저장하지 못했습니다. 다시 시도해 주세요.';
} finally {
busy = false;
status.hidden = false;
}
}, { signal: events.signal });
}
return { dispose() { events.abort(); opener.disabled = true; } };
}
+31
View File
@@ -0,0 +1,31 @@
import type { ObserverConfig } from '../config/observer.ts';
import { calculateSun, sunriseAzimuth } from '../astronomy/sunCalculator.ts';
import { THREADS } from '../constants/threads.ts';
export interface ThreadsCapture {
sunrise(): string;
capture(): { images: { name: string; image: string }[]; capturedAt: string };
}
declare global { interface Window { sunriseThreads?: ThreadsCapture } }
export function installThreadsCapture(observer: ObserverConfig,
world: { update: (state: ReturnType<typeof calculateSun>) => void; lookAtSunrise: (azimuth: number) => void;
captureAngle: (azimuth: number, pitch: number) => HTMLCanvasElement }) {
window.sunriseThreads = {
sunrise() {
const sunrise = calculateSun(new Date(), observer).sunrise;
if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise');
return sunrise.toISOString();
},
capture() {
const now = new Date();
world.update(calculateSun(now, observer));
const azimuth = sunriseAzimuth(now, observer);
world.lookAtSunrise(azimuth);
const images = THREADS.angles.map(angle => ({ name: angle.name,
image: world.captureAngle((azimuth + angle.offset) % 360, angle.pitch).toDataURL('image/jpeg', 0.95) }));
return { images, capturedAt: now.toISOString() };
},
};
}