first commit
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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('자이로스코프 활성'); }
|
||||
};
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user