first commit

This commit is contained in:
2026-10-06 12:16:46 +09:00
commit 619d980098
415 changed files with 22283 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
import * as THREE from 'three';
export class CameraController {
readonly target = new THREE.Vector2(); readonly current = new THREE.Vector2();
constructor(private readonly camera: THREE.PerspectiveCamera, private maxOffset = 0.5) {}
setMaxOffset(value: number): void { this.maxOffset = THREE.MathUtils.clamp(value, 0, 0.9); }
add(x: number, y: number): void { this.target.set(THREE.MathUtils.clamp(this.target.x + x, -1, 1), THREE.MathUtils.clamp(this.target.y + y, -1, 1)); }
reset(): void { this.target.set(0, 0); this.current.set(0, 0); }
update(dt: number, sensitivity: number): THREE.Vector2 { this.current.lerp(this.target, 1 - Math.exp(-8 * dt)); const view = this.current.clone().multiplyScalar(sensitivity); view.x = THREE.MathUtils.clamp(view.x, -1, 1); view.y = THREE.MathUtils.clamp(view.y, -1, 1); this.camera.position.set(view.x * this.maxOffset, view.y * this.maxOffset, 3); this.camera.lookAt(0, 0, 0); return view; }
}
+29
View File
@@ -0,0 +1,29 @@
export class GyroController {
private enabled = false;
private pending = false;
private generation = 0;
private hasReading = false;
private base: { beta: number; gamma: number } | null = null;
constructor(private readonly setView: (x: number, y: number) => void, private readonly status: (message: string) => void) {}
async toggle(): Promise<boolean> {
if (this.enabled || this.pending) { this.dispose(); return false; }
if (!window.isSecureContext || !('DeviceOrientationEvent' in window)) { this.status('자이로스코프를 사용할 수 없습니다.'); return false; }
const generation = ++this.generation; this.pending = true;
try {
const device = window.DeviceOrientationEvent as typeof DeviceOrientationEvent & { requestPermission?: () => Promise<string> };
this.status('센서 권한을 기다리는 중…');
if (device.requestPermission && await device.requestPermission() !== 'granted') throw new Error('권한 거절됨');
if (generation !== this.generation) return false;
this.base = null; this.hasReading = false; this.pending = false; this.enabled = true; window.addEventListener('deviceorientation', this.onOrientation); this.status('센서값을 기다리는 중…'); return true;
} catch (error) { this.pending = false; if (generation === this.generation) this.status(`자이로스코프를 사용할 수 없습니다 (${(error as Error).message})`); return false; }
}
reset(): void { this.base = null; }
dispose(): void { ++this.generation; this.pending = false; this.enabled = false; this.base = null; window.removeEventListener('deviceorientation', this.onOrientation); }
private onOrientation = (event: DeviceOrientationEvent): void => {
if (!this.enabled || event.beta == null || event.gamma == null) return;
if (!this.base) this.base = { beta: event.beta, gamma: event.gamma };
const angle = (screen.orientation?.angle ?? 0) * Math.PI / 180;
const x = event.gamma - this.base.gamma; const y = event.beta - this.base.beta;
this.setView((x * Math.cos(angle) + y * Math.sin(angle)) / 35, (y * Math.cos(angle) - x * Math.sin(angle)) / 35); if (!this.hasReading) { this.hasReading = true; this.status('자이로스코프 활성'); }
};
}
+77
View File
@@ -0,0 +1,77 @@
import * as THREE from 'three';
export type MediaReady = (texture: THREE.Texture, aspect: number, video: HTMLVideoElement | null) => void;
export class MediaLoader {
private token = 0;
private activeUrl = '';
private pendingUrl = '';
private pendingVideo: HTMLVideoElement | null = null;
private activeVideo: HTMLVideoElement | null = null;
private retryVideo: HTMLVideoElement | null = null;
constructor(private readonly onReady: MediaReady, private readonly onStatus: (message: string) => void) {}
cancelPending(): void { ++this.token; this.clearPending(); this.retryVideo = null; }
load(file: File): void {
const token = ++this.token;
this.clearPending();
this.retryVideo = null;
const url = URL.createObjectURL(file);
this.pendingUrl = url;
if (file.type.startsWith('image/')) {
const image = new Image();
image.onload = () => {
if (token !== this.token) return this.disposeUrl(url);
if (this.pendingUrl === url) this.pendingUrl = '';
const texture = new THREE.Texture(image);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
this.replaceActive(url, null);
this.onReady(texture, image.width / image.height, null);
if (token === this.token) this.onStatus('');
};
image.onerror = () => { if (token === this.token) this.onStatus('이미지를 읽지 못했습니다.'); this.disposeUrl(url); };
image.src = url;
return;
}
const video = document.createElement('video');
this.pendingVideo = video;
video.muted = true; video.loop = true; video.playsInline = true; video.preload = 'auto'; video.src = url;
const stale = () => {
video.onloadeddata = null;
video.onerror = null;
video.pause();
video.removeAttribute('src');
video.load();
if (this.pendingVideo === video) this.pendingVideo = null;
if (this.pendingUrl === url) this.pendingUrl = '';
this.disposeUrl(url);
};
video.onerror = () => { if (token === this.token) this.onStatus('동영상을 읽지 못했습니다.'); stale(); };
video.onloadeddata = () => {
if (token !== this.token) return stale();
video.onloadeddata = null;
video.onerror = null;
if (this.pendingUrl === url) this.pendingUrl = '';
if (this.pendingVideo === video) this.pendingVideo = null;
const texture = new THREE.VideoTexture(video); texture.colorSpace = THREE.SRGBColorSpace;
this.replaceActive(url, video);
this.retryVideo = video;
this.onReady(texture, video.videoWidth / video.videoHeight, video);
if (token === this.token && this.activeVideo === video) this.onStatus('');
video.play().catch(() => {
if (token === this.token && video === this.activeVideo) this.onStatus('자동 재생이 차단되었습니다. 화면을 눌러 재생하세요.');
});
};
video.load();
}
retry(): void { const video = this.retryVideo; const token = this.token; video?.play().catch(() => { if (token === this.token && video === this.activeVideo) this.onStatus('재생할 수 없습니다.'); }); }
dispose(): void { ++this.token; this.clearPending(); this.retryVideo = null; if (this.activeVideo) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); this.activeVideo = null; } this.disposeUrl(this.activeUrl); this.activeUrl = ''; }
private replaceActive(url: string, video: HTMLVideoElement | null): void { if (this.activeVideo && this.activeVideo !== video) { this.activeVideo.pause(); this.activeVideo.removeAttribute('src'); this.activeVideo.load(); } if (this.activeUrl && this.activeUrl !== url) this.disposeUrl(this.activeUrl); this.activeUrl = url; this.activeVideo = video; this.retryVideo = video; }
private clearPending(): void { if (this.pendingVideo) { this.pendingVideo.onloadeddata = null; this.pendingVideo.onerror = null; this.pendingVideo.pause(); this.pendingVideo.removeAttribute('src'); this.pendingVideo.load(); this.pendingVideo = null; } if (this.pendingUrl) this.disposeUrl(this.pendingUrl); this.pendingUrl = ''; }
private disposeUrl(url: string): void { if (url) URL.revokeObjectURL(url); }
}
+10
View File
@@ -0,0 +1,10 @@
export class PointerController {
private activeId: number | null = null;
private lastX = 0; private lastY = 0;
private hasDragged = false;
constructor(private readonly element: HTMLElement, private readonly setView: (x: number, y: number) => void, private readonly onDrag?: () => void) { element.addEventListener('pointerdown', this.down); element.addEventListener('pointermove', this.move); element.addEventListener('pointerup', this.end); element.addEventListener('pointercancel', this.end); }
private down = (event: PointerEvent): void => { this.activeId = event.pointerId; this.lastX = event.clientX; this.lastY = event.clientY; this.element.setPointerCapture(event.pointerId); };
private move = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; const dx = event.clientX - this.lastX; const dy = event.clientY - this.lastY; if (dx === 0 && dy === 0) return; if (!this.hasDragged) { this.hasDragged = true; this.element.classList.add('is-dragging'); this.onDrag?.(); } const r = this.element.getBoundingClientRect(); this.setView(dx / r.width, -dy / r.height); this.lastX = event.clientX; this.lastY = event.clientY; };
private end = (event: PointerEvent): void => { if (event.pointerId !== this.activeId) return; this.activeId = null; this.hasDragged = false; this.element.classList.remove('is-dragging'); if (this.element.hasPointerCapture(event.pointerId)) this.element.releasePointerCapture(event.pointerId); };
dispose(): void { this.element.removeEventListener('pointerdown', this.down); this.element.removeEventListener('pointermove', this.move); this.element.removeEventListener('pointerup', this.end); this.element.removeEventListener('pointercancel', this.end); }
}