/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { reactive, ref, shallowRef, triggerRef, watch } from 'vue'; import { EventEmitter } from 'eventemitter3'; import type { EngineBase, EngineBaseEvents } from 'frontend-misskey-world-engine/src/EngineBase.js'; import type { PlayerProfile, PlayerState } from 'misskey-world-engine/src/PlayerContainer.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; // TODO: multiplayer関連は全ての子クラスで必要とは限らない(preview用など)ため、このクラスを継承する別のabstract classに分離 export type EngineControllerBaseOptions = { workerMode?: boolean; graphicsQuality: number; fps: number | null; resolution: number; antialias: boolean; }; type EngineEventsOf = T extends EngineBase ? X : EngineBaseEvents; type ControllerEvents = EventEmitter.ValidEventTypes; export class WASD { private isWPressing = false; private isSPressing = false; private isAPressing = false; private isDPressing = false; private isDashing = false; private setCameraMoveVector: (vec: { x: number; y: number }, dash: boolean) => void; constructor(options: { setCameraMoveVector: WASD['setCameraMoveVector']; }) { this.setCameraMoveVector = options.setCameraMoveVector; } private calcWasdVec() { const vec = { x: 0, y: 0 }; if (this.isWPressing) vec.y -= 1; if (this.isSPressing) vec.y += 1; if (this.isAPressing) vec.x -= 1; if (this.isDPressing) vec.x += 1; return vec; } public keydown(ev: KeyboardEvent) { if (ev.repeat) return; switch (ev.code) { case 'KeyW': this.isWPressing = true; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'KeyS': this.isSPressing = true; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'KeyA': this.isAPressing = true; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'KeyD': this.isDPressing = true; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'ShiftLeft': case 'ShiftRight': this.isDashing = true; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; } } public keyup(ev: KeyboardEvent) { switch (ev.code) { case 'KeyW': this.isWPressing = false; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'KeyS': this.isSPressing = false; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'KeyA': this.isAPressing = false; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'KeyD': this.isDPressing = false; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; case 'ShiftLeft': case 'ShiftRight': this.isDashing = false; this.setCameraMoveVector(this.calcWasdVec(), this.isDashing); break; } } } // UIとエンジンの間に挟まり抽象化を行うレイヤー。 // UIからは、エンジンが直で動いててもワーカーで動いてても同じように操作できるように見える export abstract class EngineControllerBase, E extends EventEmitter.ValidEventTypes = EventEmitter.ValidEventTypes> extends EventEmitter { private worker: Worker | null = null; private engine: T | null = null; private canvas: HTMLCanvasElement | null = null; protected options: EngineControllerBaseOptions; private returnHooks = new Map void>(); public isReady = ref(false); public initializeProgress = ref(0); private pointerDownPosition: { x: number; y: number } | null = null; private wasd: WASD | null = null; private abortController = new AbortController(); private destroyed = false; constructor(options: EngineControllerBaseOptions, wasd?: WASD) { super(); this.options = options; this.wasd = wasd ?? null; } protected async _init_(canvas: HTMLCanvasElement, params: { createWorker: (offscreen: OffscreenCanvas) => Promise; createEngine: () => Promise; }) { this.canvas = canvas; // 最低でも4pxないとなんかエラーになる this.canvas.width = canvas.clientWidth > 4 ? canvas.clientWidth : 4; this.canvas.height = canvas.clientHeight > 4 ? canvas.clientHeight : 4; const engineEvents = new EventEmitter>(); engineEvents.on('loadingProgress', ({ progress }) => { this.initializeProgress.value = progress; }); engineEvents.on('contextlost', ({ reason, message }) => { this.onContextLost({ reason, message }); }); if (this.options.workerMode) { const offscreen = canvas.transferControlToOffscreen(); this.worker = await params.createWorker(offscreen); this.worker.onmessage = (event) => { switch (event.data?.type) { case 'inited': { this.initializeProgress.value = 1; this.isReady.value = true; break; } case 'ev': { const { type, ctx } = event.data.ev; engineEvents.emit(type, ctx); break; } case 'return': { const { id, value } = event.data; const hook = this.returnHooks.get(id); if (hook != null) { hook(value); this.returnHooks.delete(id); } break; } default: { console.warn('Unrecognized message from worker:', event.data?.type); } } }; } else { this.engine = await params.createEngine(); this.engine!.on('ev', ({ type, ctx }) => { engineEvents.emit(type, ctx); }); await this.engine!.init(); this.initializeProgress.value = 1; this.isReady.value = true; } this.canvas.addEventListener('keydown', (ev) => { if (this.wasd != null) this.wasd.keydown(ev); if (this.worker != null) { this.worker.postMessage({ type: 'input:keydown', ev: { code: ev.code, shiftKey: ev.shiftKey } }); } else if (this.engine != null) { this.engine.inputs.emit('keydown', { code: ev.code, shiftKey: ev.shiftKey }); } ev.preventDefault(); ev.stopPropagation(); return false; }, { signal: this.abortController.signal }); this.canvas.addEventListener('keyup', (ev) => { if (this.wasd != null) this.wasd.keyup(ev); if (this.worker != null) { this.worker.postMessage({ type: 'input:keyup', ev: { code: ev.code, shiftKey: ev.shiftKey } }); } else if (this.engine != null) { this.engine.inputs.emit('keyup', { code: ev.code, shiftKey: ev.shiftKey }); } ev.preventDefault(); ev.stopPropagation(); return false; }, { signal: this.abortController.signal }); this.canvas.addEventListener('wheel', (ev) => { if (this.worker != null) { this.worker.postMessage({ type: 'input:wheel', ev: { deltaY: ev.deltaY } }); } else if (this.engine != null) { this.engine.inputs.emit('wheel', { deltaY: ev.deltaY }); } ev.preventDefault(); ev.stopPropagation(); return false; }, { signal: this.abortController.signal }); const pointerEventCache = new Map(); let pointerVec = { x: 0, y: 0 }; this.canvas.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); this.canvas!.focus(); pointerEventCache.set(ev.pointerId, ev); this.canvas!.setPointerCapture(ev.pointerId); pointerVec = { x: ev.clientX, y: ev.clientY }; this.pointerDownPosition = { x: ev.offsetX, y: ev.offsetY }; return false; }, { signal: this.abortController.signal }); let prevTwoTouchPointsDistance = 0; this.canvas!.addEventListener('pointermove', (ev) => { ev.preventDefault(); ev.stopPropagation(); if (pointerEventCache.size === 0) { return; } pointerEventCache.set(ev.pointerId, ev); if (pointerEventCache.size > 1) { const a = Array.from(pointerEventCache.values())[0]; const b = Array.from(pointerEventCache.values())[1]; const distance = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); if (prevTwoTouchPointsDistance > 0) { const delta = distance - prevTwoTouchPointsDistance; if (this.worker != null) { this.worker.postMessage({ type: 'input:zoom', ev: { delta: delta } }); } else if (this.engine != null) { this.engine.inputs.emit('zoom', { delta: delta }); } } prevTwoTouchPointsDistance = distance; pointerVec = { x: 0, y: 0 }; if (this.worker != null) { this.worker.postMessage({ type: 'input:pointer', ev: { x: 0, y: 0 } }); } else if (this.engine != null) { this.engine.inputs.emit('pointer', { x: 0, y: 0 }); } return; } prevTwoTouchPointsDistance = 0; const before = pointerVec; const after = { x: ev.clientX, y: ev.clientY }; if (pointerVec.x !== 0 || pointerVec.y !== 0) { if (this.worker != null) { this.worker.postMessage({ type: 'input:pointer', ev: { x: after.x - before.x, y: after.y - before.y, } }); } else if (this.engine != null) { this.engine.inputs.emit('pointer', { x: after.x - before.x, y: after.y - before.y, }); } } pointerVec.x = after.x; pointerVec.y = after.y; return false; }, { signal: this.abortController.signal }); this.canvas.addEventListener('pointerup', (ev) => { pointerEventCache.delete(ev.pointerId); this.canvas!.releasePointerCapture(ev.pointerId); prevTwoTouchPointsDistance = 0; if (pointerEventCache.size > 0) { return; } pointerVec.x = 0; pointerVec.y = 0; if (this.worker != null) { this.worker.postMessage({ type: 'input:pointer', ev: pointerVec }); } else if (this.engine != null) { this.engine.inputs.emit('pointer', pointerVec); } if (this.pointerDownPosition != null) { const dx = Math.abs(ev.offsetX - this.pointerDownPosition.x); const dy = Math.abs(ev.offsetY - this.pointerDownPosition.y); if (dx < 10 && dy < 10) { const median = { x: (ev.offsetX + this.pointerDownPosition.x) / 2, y: (ev.offsetY + this.pointerDownPosition.y) / 2 }; if (this.worker != null) { this.worker.postMessage({ type: 'input:click', ev: { x: median.x, y: median.y } }); } else if (this.engine != null) { this.engine.inputs.emit('click', { x: median.x, y: median.y }); } } } this.pointerDownPosition = null; }, { signal: this.abortController.signal }); // reset経由で_init_が呼ばれた場合destoryedフラグはまだtrueのままなのでfalseに戻す this.destroyed = false; return { engineEvents, }; } protected async _reset_() { this.destroy(); this.abortController = new AbortController(); this.isReady.value = false; this.initializeProgress.value = 0; } private onContextLost(info) { // iOSの場合、OS側の都合でlostさせられた場合でもreasonがdestroyedになる場合があるので、destroyedだからといって正常な終了として無視することはできない console.log('Context lost:', info); // そのため正常な終了かどうかのフラグは自前で用意してそれを見る if (this.destroyed) return; os.alert({ type: 'error', title: i18n.ts.somethingHappened, text: i18n.ts._miWorld.crushed_description + '\nERR: ' + info.message + ' (' + info.reason + ')', }); } private callCounter = 0; protected call(fn: FN, args: Parameters = [] as any): void { if (!this.isReady.value) { throw new Error('Engine is not initialized'); } if (this.worker != null) { this.worker.postMessage({ type: 'call', fn, args }); } else if (this.engine != null) { this.engine[fn](...args); } else { throw new Error('Engine is not initialized'); } } protected callAndWaitReturn(fn: FN, args: Parameters = [] as any): ReturnType extends Promise ? ReturnType : Promise> { if (!this.isReady.value) { throw new Error('Engine is not initialized'); } if (this.worker != null) { return new Promise((resolve) => { const id = this.callCounter++; this.returnHooks.set(id, (value) => { resolve(value); }); this.worker!.postMessage({ type: 'call', fn, args, needReturnValue: true, id }); }); } else if (this.engine != null) { return new Promise((resolve) => { resolve(this.engine![fn](...args)); }); } else { throw new Error('Engine is not initialized'); } } protected set(key: K, value: T[K]) { if (!this.isReady.value) { throw new Error('Engine is not initialized'); } if (this.worker != null) { this.worker.postMessage({ type: 'set', key, value }); } else if (this.engine != null) { this.engine[key] = value; } else { throw new Error('Engine is not initialized'); } } public pauseRender() { this.call('pauseRender'); } public resumeRender() { this.call('resumeRender'); } public sit() { this.call('sit'); } public lyingDown() { this.call('lyingDown'); } public standUp() { this.call('standUp'); } public updatePlayerProfiles(profiles: Record) { this.call('updatePlayerProfiles', [profiles]); } public updatePlayerStates(states: Record) { this.call('updatePlayerStates', [states]); } public clearPlayers() { this.call('clearPlayers'); } public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) { this.call('updateAvatarDisplayOptions', [options]); } public takeScreenshot() { return this.callAndWaitReturn('takeScreenshot'); } public async downloadScreenshot() { //const blob = await this.takeScreenshot(); const blob = await new Promise((resolve, reject) => { if (this.canvas == null) return reject(new Error('Canvas is not initialized')); this.canvas.toBlob((blob) => { if (blob == null) return reject(new Error('Failed to create blob from canvas')); resolve(blob); }); }); const url = URL.createObjectURL(blob); const a = window.document.createElement('a'); a.href = url; a.download = `misskey-world-${Date.now()}.png`; a.style.display = 'none'; window.document.body.appendChild(a); a.click(); window.document.body.removeChild(a); URL.revokeObjectURL(url); } public resize() { if (this.canvas == null) return; const width = this.canvas.clientWidth; const height = this.canvas.clientHeight; if (this.worker != null) { this.worker.postMessage({ type: 'resize', width, height }); } else if (this.engine != null) { this.engine.resize(); } } // TODO: isReadyになる前に呼ばれたらメモリリークしそうな気もするから調査の上いい感じにする public destroy() { this.destroyed = true; this.abortController.abort(); if (this.worker != null) { this.worker.terminate(); this.worker = null; } if (this.engine != null) { this.engine.destroy(); this.engine = null; } } }