1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-13 15:15:45 +02:00

controller

This commit is contained in:
syuilo
2026-04-14 21:30:07 +09:00
parent 7e0b5ff8be
commit cf9349f29c
7 changed files with 533 additions and 302 deletions

View File

@@ -6,47 +6,47 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root" class="_pageScrollable">
<div :class="[$style.screen, { [$style.zen]: isZenMode }]">
<canvas ref="canvas" :class="$style.canvas" @keydown="onKeydown" @wheel="onWheel"></canvas>
<canvas ref="canvas" :class="$style.canvas" tabindex="-1"></canvas>
<template v-if="!isZenMode">
<div v-if="engine != null" class="_buttonsCenter" :class="$style.overlayControls">
<template v-if="isEditMode">
<MkButton v-if="engine.ui.isGrabbing" @click="endGrabbing"><i class="ti ti-check"></i> (E)</MkButton>
<MkButton v-else-if="engine.ui.isGrabbingForInstall" @click="endGrabbing"><i class="ti ti-check"></i> (E)</MkButton>
<MkButton v-else-if="engine.selected.value != null" @click="beginSelectedInstalledObjectGrabbing"><i class="ti ti-hand-grab"></i> (E)</MkButton>
<div v-if="controller.isReady.value" class="_buttonsCenter" :class="$style.overlayControls">
<template v-if="controller.isEditMode.value">
<MkButton v-if="controller.grabbing.value && !controller.grabbing.value.forInstall" @click="endGrabbing"><i class="ti ti-check"></i> (E)</MkButton>
<MkButton v-else-if="controller.grabbing.value && controller.grabbing.value.forInstall" @click="endGrabbing"><i class="ti ti-check"></i> (E)</MkButton>
<MkButton v-else-if="controller.selected.value != null" @click="beginSelectedInstalledObjectGrabbing"><i class="ti ti-hand-grab"></i> (E)</MkButton>
<MkButton v-if="engine.ui.isGrabbing || engine.ui.isGrabbingForInstall" @click="rotate"><i class="ti ti-view-360-arrow"></i> (R)</MkButton>
<MkButton v-if="controller.grabbing.value" @click="rotate"><i class="ti ti-view-360-arrow"></i> (R)</MkButton>
<MkButton :primary="engine.enableGridSnapping.value" @click="showSnappingMenu">Grid Snap: {{ engine.enableGridSnapping.value ? 'on' : 'off' }}</MkButton>
<MkButton :primary="controller.gridSnapping.value.enabled" @click="showSnappingMenu">Grid Snap: {{ controller.gridSnapping.value.enabled ? 'on' : 'off' }}</MkButton>
<MkButton v-if="!engine.ui.isGrabbing && engine.selected.value != null" @click="removeSelectedObject"><i class="ti ti-trash"></i> (X)</MkButton>
<MkButton v-if="!controller.grabbing.value && controller.selected.value != null" @click="removeSelectedObject"><i class="ti ti-trash"></i> (X)</MkButton>
</template>
<MkButton v-if="engine.isSitting.value" @click="engine.standUp()">降りる (Q)</MkButton>
<MkButton v-if="controller.isSitting.value" @click="controller.standUp()">降りる (Q)</MkButton>
<template v-for="interaction in interacions" :key="interaction.id">
<MkButton inline @click="interaction.fn()">{{ interaction.label }}{{ interaction.isPrimary ? ' (E)' : '' }}</MkButton>
</template>
</div>
<div v-if="engine != null && isEditMode && engine.selected.value != null" :key="engine.selected.value.objectId" class="_panel" :class="$style.overlayObjectInfoPanel">
{{ engine.selected.value.objectDef.name }}
<div v-if="controller.isReady.value && controller.isEditMode.value && controller.selected.value != null" :key="controller.selected.value.objectId" class="_panel" :class="$style.overlayObjectInfoPanel">
{{ controller.selected.value.objectDef.name }}
<div class="_gaps">
<div v-for="[k, s] in Object.entries(engine.selected.value.objectDef.options.schema)" :key="k">
<div v-for="[k, s] in Object.entries(controller.selected.value.objectDef.options.schema)" :key="k">
<div>{{ s.label }}</div>
<div v-if="s.type === 'color'">
<MkInput :modelValue="getHex(engine.selected.value.objectState.options[k])" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) engine.updateObjectOption(engine.selected.value.objectId, k, c); }"></MkInput>
<MkInput :modelValue="getHex(controller.selected.value.objectState.options[k])" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) controller.updateObjectOption(controller.selected.value.objectId, k, c); }"></MkInput>
</div>
<div v-else-if="s.type === 'boolean'">
<MkSwitch :modelValue="engine.selected.value.objectState.options[k]" @update:modelValue="v => engine.updateObjectOption(engine.selected.value.objectId, k, v)"></MkSwitch>
<MkSwitch :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkSwitch>
</div>
<div v-else-if="s.type === 'enum'">
<MkSelect :items="s.enum.map(e => ({ label: e, value: e }))" :modelValue="engine.selected.value.objectState.options[k]" @update:modelValue="v => engine.updateObjectOption(engine.selected.value.objectId, k, v)"></MkSelect>
<MkSelect :items="s.enum.map(e => ({ label: e, value: e }))" :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkSelect>
</div>
<div v-else-if="s.type === 'range'">
<MkRange :continuousUpdate="true" :min="s.min" :max="s.max" :step="s.step" :modelValue="engine.selected.value.objectState.options[k]" @update:modelValue="v => engine.updateObjectOption(engine.selected.value.objectId, k, v)"></MkRange>
<MkRange :continuousUpdate="true" :min="s.min" :max="s.max" :step="s.step" :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkRange>
</div>
<div v-else-if="s.type === 'image'">
<MkInput type="text" :modelValue="engine.selected.value.objectState.options[k]" @update:modelValue="v => engine.updateObjectOption(engine.selected.value.objectId, k, v)"></MkInput>
<MkInput type="text" :modelValue="controller.selected.value.objectState.options[k]" @update:modelValue="v => controller.updateObjectOption(controller.selected.value.objectId, k, v)"></MkInput>
</div>
</div>
</div>
@@ -55,13 +55,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<template v-if="!isZenMode">
<div v-if="engine != null" class="_buttons" :class="$style.controls">
<div v-if="controller.isReady.value" class="_buttons" :class="$style.controls">
<!--<MkButton v-for="action in actions" :key="action.key" @click="action.fn">{{ action.label }}{{ hotkeyToLabel(action.hotkey) }}</MkButton>-->
<MkButton @click="toggleLight">Toggle Light</MkButton>
<MkButton v-if="isEditMode" primary @click="save">Save</MkButton>
<MkButton v-if="isEditMode" @click="exitEditMode">Exit edit mode</MkButton>
<MkButton v-if="!isEditMode" @click="enterEditMode">Edit mode</MkButton>
<MkButton v-if="isEditMode" @click="addObject">addObject</MkButton>
<MkButton v-if="controller.isEditMode.value" primary @click="save">Save</MkButton>
<MkButton v-if="controller.isEditMode.value" @click="exitEditMode">Exit edit mode</MkButton>
<MkButton v-if="!controller.isEditMode.value" @click="enterEditMode">Edit mode</MkButton>
<MkButton v-if="controller.isEditMode.value" @click="addObject">addObject</MkButton>
<MkButton @click="expor">Export</MkButton>
<MkButton @click="impor">Import</MkButton>
</div>
@@ -75,20 +75,15 @@ import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i';
import MkButton from '@/components/MkButton.vue';
import { createRoomEngine, RoomEngine } from '@/utility/room/engine.js';
import { getObjectDef, OBJECT_DEFS } from '@/utility/room/object-defs.js';
import MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import { RoomController } from '@/utility/room/controller.js';
const canvas = useTemplateRef('canvas');
const engine = shallowRef<RoomEngine | null>(null);
const isEditMode = ref(false);
const interacions = shallowRef<{
id: string;
label: string;
@@ -97,123 +92,11 @@ const interacions = shallowRef<{
}[]>([]);
function resize() {
if (engine.value != null) engine.value.resize();
controller.resize();
}
type Action = {
key: string;
label: string;
fn: () => void;
hotkey?: string;
};
function hotkeyToLabel(hotkey: string) {
if (hotkey.startsWith('Key')) {
return hotkey.slice(3);
} else if (hotkey.startsWith('Digit')) {
return hotkey.slice(5);
} else {
return hotkey;
}
}
const actions = computed<Action[]>(() => {
if (engine.value == null) return [];
const actions: Action[] = [];
if (isEditMode.value) {
actions.push({
key: 'grab',
label: 'Grab',
fn: () => {
engine.value!.beginSelectedInstalledObjectGrabbing();
canvas.value!.focus();
},
hotkey: 'KeyE',
});
}
if (engine.value.isSitting.value) {
actions.push({
key: 'standUp',
label: 'Stand Up',
fn: () => engine.value!.standUp(),
hotkey: 'KeyQ',
});
}
return actions;
});
const isZenMode = ref(false);
function onKeydown(ev: KeyboardEvent) {
if (engine.value == null) return;
console.log(ev.code);
if (ev.code === 'KeyE') {
ev.preventDefault();
ev.stopPropagation();
if (isEditMode.value) {
if (engine.value.ui.isGrabbing || engine.value.ui.isGrabbingForInstall) {
endGrabbing();
} else {
beginSelectedInstalledObjectGrabbing();
}
} else if (engine.value.selected.value != null) {
engine.value.interact(engine.value.selected.value.objectId);
}
} else if (ev.code === 'KeyR') {
ev.preventDefault();
ev.stopPropagation();
if (engine.value.ui.isGrabbing || engine.value.ui.isGrabbingForInstall) {
rotate();
}
} else if (ev.code === 'KeyQ') {
ev.preventDefault();
ev.stopPropagation();
if (engine.value.isSitting.value) {
engine.value.standUp();
}
} else if (ev.code === 'Tab') {
ev.preventDefault();
ev.stopPropagation();
if (isEditMode.value) {
engine.value.exitEditMode();
isEditMode.value = false;
} else {
engine.value.enterEditMode();
isEditMode.value = true;
}
} else if (ev.code === 'KeyZ') {
ev.preventDefault();
ev.stopPropagation();
isZenMode.value = !isZenMode.value;
nextTick(() => {
resize();
});
}
}
function onWheel(ev: WheelEvent) {
if (engine.value == null) return;
if (engine.value.ui.isGrabbing || engine.value.ui.isGrabbingForInstall) {
ev.preventDefault();
ev.stopPropagation();
engine.value.changeGrabbingDistance(ev.deltaY * 0.025);
} else {
ev.preventDefault();
ev.stopPropagation();
engine.value.camera.fov += ev.deltaY * 0.001;
engine.value.camera.fov = Math.max(0.25, Math.min(1, engine.value.camera.fov));
}
}
const data = localStorage.getItem('roomData') != null ? { ...JSON.parse(localStorage.getItem('roomData')!), ...{
heya: {
type: 'simple',
@@ -281,90 +164,151 @@ const data = localStorage.getItem('roomData') != null ? { ...JSON.parse(localSto
installedObjects: [],
};
onMounted(async () => {
engine.value = await createRoomEngine(data, canvas.value!);
const controller = new RoomController(data);
engine.value.init();
function onKeydown(ev: KeyboardEvent) {
if (ev.code === 'KeyE') {
ev.preventDefault();
ev.stopPropagation();
if (controller.isEditMode.value) {
if (controller.grabbing.value) {
endGrabbing();
} else {
beginSelectedInstalledObjectGrabbing();
}
} else if (controller.selected.value != null) {
controller.interact(controller.selected.value.objectId);
}
} else if (ev.code === 'KeyR') {
ev.preventDefault();
ev.stopPropagation();
if (controller.grabbing.value) {
rotate();
}
} else if (ev.code === 'KeyQ') {
ev.preventDefault();
ev.stopPropagation();
if (controller.isSitting.value) {
controller.standUp();
}
} else if (ev.code === 'Tab') {
ev.preventDefault();
ev.stopPropagation();
if (controller.isEditMode.value) {
controller.exitEditMode();
controller.isEditMode.value = false;
} else {
controller.enterEditMode();
controller.isEditMode.value = true;
}
} else if (ev.code === 'KeyZ') {
ev.preventDefault();
ev.stopPropagation();
isZenMode.value = !isZenMode.value;
nextTick(() => {
resize();
});
}
}
function onWheel(ev: WheelEvent) {
if (controller.grabbing.value) {
ev.preventDefault();
ev.stopPropagation();
controller.changeGrabbingDistance(ev.deltaY * 0.025);
} else {
ev.preventDefault();
ev.stopPropagation();
controller.camera.fov += ev.deltaY * 0.001;
controller.camera.fov = Math.max(0.25, Math.min(1, controller.camera.fov));
}
}
onMounted(async () => {
controller.init(canvas.value!);
canvas.value!.focus();
window.addEventListener('resize', resize);
watch(engine.value.selected, (v) => {
if (v == null) {
interacions.value = [];
} else {
interacions.value = Object.entries(v.objectEntity.instance.interactions).map(([interactionId, interactionInfo]) => ({
id: interactionId,
label: interactionInfo.label,
isPrimary: v.objectEntity.instance.primaryInteraction === interactionId,
fn: interactionInfo.fn,
}));
}
});
//watch(controller.selected, (v) => {
// if (v == null) {
// interacions.value = [];
// } else {
// interacions.value = Object.entries(v.objectEntity.instance.interactions).map(([interactionId, interactionInfo]) => ({
// id: interactionId,
// label: interactionInfo.label,
// isPrimary: v.objectEntity.instance.primaryInteraction === interactionId,
// fn: interactionInfo.fn,
// }));
// }
//});
});
onUnmounted(() => {
engine.value.destroy();
controller.destroy();
window.removeEventListener('resize', resize);
});
function beginSelectedInstalledObjectGrabbing() {
engine.value.beginSelectedInstalledObjectGrabbing();
controller.beginSelectedInstalledObjectGrabbing();
canvas.value!.focus();
}
function endGrabbing() {
engine.value.endGrabbing();
controller.endGrabbing();
canvas.value!.focus();
}
function toggleLight() {
engine.value.toggleRoomLight();
controller.toggleRoomLight();
canvas.value!.focus();
}
function showSnappingMenu(ev: PointerEvent) {
if (engine.value == null) return;
os.popupMenu([{
type: 'switch',
text: i18n.ts._room.snapToGrid,
ref: engine.value.enableGridSnapping,
ref: computed({
get: () => controller.gridSnapping.value.enabled,
set: v => controller.setGridSnapping({ ...controller.gridSnapping.value, enabled: v }),
}),
}, {
type: 'radioOption',
text: '1cm',
active: computed(() => engine.value!.gridSnappingScale.value === 1),
action: () => engine.value!.gridSnappingScale.value = 1,
active: computed(() => controller.gridSnapping.value.scale === 1),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 1 }),
}, {
type: 'radioOption',
text: '2cm',
active: computed(() => engine.value!.gridSnappingScale.value === 2),
action: () => engine.value!.gridSnappingScale.value = 2,
active: computed(() => controller.gridSnapping.value.scale === 2),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 2 }),
}, {
type: 'radioOption',
text: '4cm',
active: computed(() => engine.value!.gridSnappingScale.value === 4),
action: () => engine.value!.gridSnappingScale.value = 4,
active: computed(() => controller.gridSnapping.value.scale === 4),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 4 }),
}, {
type: 'radioOption',
text: '8cm',
active: computed(() => engine.value!.gridSnappingScale.value === 8),
action: () => engine.value!.gridSnappingScale.value = 8,
active: computed(() => controller.gridSnapping.value.scale === 8),
action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 8 }),
}], ev.currentTarget ?? ev.target);
}
function rotate() {
engine.value.changeGrabbingRotationY(Math.PI / 8);
controller.changeGrabbingRotationY(Math.PI / 8);
canvas.value!.focus();
}
async function addObject(ev: PointerEvent) {
if (engine.value == null) return;
const { dispose } = await os.popupAsyncWithDialog(import('./room.add-object-dialog.vue').then(x => x.default), {
}, {
ok: async (res) => {
engine.value?.addObject(res);
controller.addObject(res);
canvas.value!.focus();
},
closed: () => dispose(),
@@ -372,23 +316,16 @@ async function addObject(ev: PointerEvent) {
}
function removeSelectedObject() {
engine.value?.removeSelectedObject();
canvas.value!.focus();
}
function showBoundingBox() {
engine.value?.showBoundingBox();
controller.removeSelectedObject();
canvas.value!.focus();
}
function enterEditMode() {
engine.value?.enterEditMode();
isEditMode.value = true;
controller.enterEditMode();
}
function exitEditMode() {
engine.value?.exitEditMode();
isEditMode.value = false;
controller.exitEditMode();
}
function getHex(c: [number, number, number]) {
@@ -410,13 +347,11 @@ function getRgb(hex: string | number): [number, number, number] | null {
}
function save() {
if (engine.value == null) return;
localStorage.setItem('roomData', JSON.stringify(engine.value.roomState));
localStorage.setItem('roomData', JSON.stringify(controller.roomState));
}
function expor() {
if (engine.value == null) return;
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(engine.value.roomState));
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(controller.roomState));
const dlAnchorElem = window.document.createElement('a');
dlAnchorElem.setAttribute('href', dataStr);
dlAnchorElem.setAttribute('download', 'room.json');
@@ -424,7 +359,6 @@ function expor() {
}
function impor() {
if (engine.value == null) return;
const inputElem = window.document.createElement('input');
inputElem.setAttribute('type', 'file');
inputElem.setAttribute('accept', 'application/json');

View File

@@ -0,0 +1,194 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { reactive, ref, shallowRef, triggerRef, watch } from 'vue';
import * as BABYLON from '@babylonjs/core';
import RoomWorker from './worker?worker';
import { RoomEngine } from './engine.js';
import type { ShallowRef } from 'vue';
import type { ObjectDef, RoomState, RoomStateObject } from './engine.js';
import * as sound from '@/utility/sound.js';
// 抽象化レイヤー
export class RoomController {
private worker: Worker | null = null;
private engine: RoomEngine | null = null;
private canvas: HTMLCanvasElement | null = null;
public isReady = ref(false);
public isSitting = ref(false);
public isEditMode = ref(false);
public grabbing = ref<{ forInstall: boolean } | null>(null);
public gridSnapping = ref({ enabled: true, scale: 4 });
public selected = ref<{
objectId: string;
objectState: RoomStateObject;
objectDef: ObjectDef;
} | null>(null);
public roomState: ShallowRef<RoomState>;
constructor(roomState: RoomState) {
this.roomState = shallowRef(roomState);
}
public async init(canvas: HTMLCanvasElement, workerMode = false) {
this.canvas = canvas;
this.canvas.width = canvas.clientWidth;
this.canvas.height = canvas.clientHeight;
if (workerMode) {
const offscreen = canvas.transferControlToOffscreen();
this.worker = new RoomWorker();
this.worker.postMessage({ type: 'init', canvas: offscreen, roomState: this.roomState.value }, [offscreen]);
this.isReady.value = true;
} else {
const babylonEngine = new BABYLON.WebGPUEngine(canvas);
babylonEngine.compatibilityMode = false;
babylonEngine.initAsync().then(() => {
this.engine = new RoomEngine(this.roomState.value, { canvas, engine: babylonEngine });
this.engine.init();
this.isReady.value = true;
this.engine.on('changeGrabbingState', ({ grabbing }) => {
this.grabbing.value = grabbing;
});
this.engine.on('changeEditMode', ({ isEditMode }) => {
this.isEditMode.value = isEditMode;
});
this.engine.on('changeGridSnapping', ({ gridSnapping }) => {
this.gridSnapping.value = gridSnapping;
});
this.engine.on('changeSelectedState', ({ selected }) => {
this.selected.value = selected;
});
this.engine.on('changeRoomState', ({ roomState }) => {
this.roomState.value = roomState;
});
this.engine.on('playSfxUrl', ({ url, options }) => {
sound.playUrl(url, options);
});
});
}
this.canvas.addEventListener('keydown', (ev) => {
if (this.worker != null) this.worker.postMessage({ type: 'dom:keydown', ev: { code: ev.code, shiftKey: ev.shiftKey } });
ev.preventDefault();
ev.stopPropagation();
return false;
});
this.canvas.addEventListener('keyup', (ev) => {
if (this.worker != null) this.worker.postMessage({ type: 'dom:keyup', ev: { code: ev.code, shiftKey: ev.shiftKey } });
ev.preventDefault();
ev.stopPropagation();
return false;
});
this.canvas.addEventListener('pointerdown', (ev) => {
if (this.worker != null) this.worker.postMessage({ type: 'dom:pointerdown', ev: { button: ev.button, shiftKey: ev.shiftKey } });
ev.preventDefault();
ev.stopPropagation();
return false;
});
}
public enterEditMode() {
if (this.worker != null) {
this.worker.postMessage({ type: 'enterEditMode' });
} else if (this.engine != null) {
this.engine.enterEditMode();
}
}
public exitEditMode() {
if (this.worker != null) {
this.worker.postMessage({ type: 'exitEditMode' });
} else if (this.engine != null) {
this.engine.exitEditMode();
}
}
public setGridSnapping(gridSnapping: { enabled: boolean; scale: number }) {
if (this.worker != null) {
this.worker.postMessage({ type: 'setGridSnapping', gridSnapping });
} else if (this.engine != null) {
this.engine.gridSnapping = gridSnapping;
}
}
public updateObjectOption(objectId: string, key: string, value: any) {
if (this.worker != null) {
this.worker.postMessage({ type: 'updateObjectOption', objectId, key, value });
} else if (this.engine != null) {
this.engine.updateObjectOption(objectId, key, value);
}
}
public beginSelectedInstalledObjectGrabbing() {
if (this.worker != null) {
this.worker.postMessage({ type: 'beginSelectedInstalledObjectGrabbing' });
} else if (this.engine != null) {
this.engine.beginSelectedInstalledObjectGrabbing();
}
}
public removeSelectedObject() {
if (this.worker != null) {
this.worker.postMessage({ type: 'removeSelectedObject' });
} else if (this.engine != null) {
this.engine.removeSelectedObject();
}
}
public addObject(type: string) {
if (this.worker != null) {
this.worker.postMessage({ type: 'addObject', objectType: type });
} else if (this.engine != null) {
this.engine.addObject(type);
}
}
public endGrabbing() {
if (this.worker != null) {
this.worker.postMessage({ type: 'endGrabbing' });
} else if (this.engine != null) {
this.engine.endGrabbing();
}
}
public toggleRoomLight() {
if (this.worker != null) {
this.worker.postMessage({ type: 'toggleRoomLight' });
} else if (this.engine != null) {
this.engine.toggleRoomLight();
}
}
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();
}
}
public destroy() {
if (this.worker != null) {
this.worker.terminate();
this.worker = null;
}
if (this.engine != null) {
this.engine.destroy();
this.engine = null;
}
}
}

View File

@@ -35,28 +35,54 @@
// TODO: 近くのオブジェクトの端にスナップオプション
// TODO: 近くのオブジェクトの原点に軸を揃えるオプション
const BAKE_TRANSFORM = false; // 実験的
const SNAPSHOT_RENDERING = false; // 実験的
const SNAPSHOT_RENDERING_NON_SUPPORTED_OBJECTS = ['tv', 'aquarium', 'lavaLamp'];
const IGNORE_OBJECTS: string[] = []; // for debug
const SYSTEM_MESH_NAMES = ['__TOP__', '__SIDE__', '__PICK__', '__COLLISION__', '__COLLISION_AUTO_GENERATED_INTERNALY__'];
import * as BABYLON from '@babylonjs/core';
import { AxesViewer } from '@babylonjs/core/Debug/axesViewer';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic';
import { BoundingBoxRenderer } from '@babylonjs/core/Rendering/boundingBoxRenderer';
import { GridMaterial } from '@babylonjs/materials';
import { ShowInspector } from '@babylonjs/inspector';
import { reactive, ref, shallowRef, triggerRef, watch } from 'vue';
import { EventEmitter } from 'eventemitter3';
import { genId } from '../id.js';
import { deepClone } from '../clone.js';
import { getObjectDef } from './object-defs.js';
import { HorizontalCameraKeyboardMoveInput, applyMorphTargetsToMesh, camelToKebab, findMaterial, scaleMorph } from './utility.js';
import * as sound from '@/utility/sound.js';
const BAKE_TRANSFORM = false; // 実験的
const SNAPSHOT_RENDERING = false; // 実験的
const SNAPSHOT_RENDERING_NON_SUPPORTED_OBJECTS = ['tv', 'aquarium', 'lavaLamp'];
const IGNORE_OBJECTS: string[] = []; // for debug
const SYSTEM_MESH_NAMES = ['__TOP__', '__SIDE__', '__PICK__', '__COLLISION__', '__COLLISION_AUTO_GENERATED_INTERNALY__'];
const USE_GLOW = true; // ドローコールが増えて重い
const IN_WEB_WORKER = typeof window === 'undefined';
const TIME_MAP = {
0: 2,
1: 2,
2: 2,
3: 2,
4: 2,
5: 1,
6: 1,
7: 0,
8: 0,
9: 0,
10: 0,
11: 0,
12: 0,
13: 0,
14: 0,
15: 0,
16: 1,
17: 1,
18: 2,
19: 2,
20: 2,
21: 2,
22: 2,
23: 2,
} as const;
// babylonのドメイン知識は持たない
type RoomStateObject<Options = any> = {
export type RoomStateObject<Options = any> = {
id: string;
type: string;
position: [number, number, number];
@@ -96,7 +122,7 @@ type Heya = {
type: 'japanese';
};
type RoomState = {
export type RoomState = {
heya: Heya;
installedObjects: RoomStateObject<any>[];
};
@@ -302,7 +328,7 @@ class ModelManager {
}
}
type ObjectDef<OpSc extends OptionsSchema = OptionsSchema> = {
export type ObjectDef<OpSc extends OptionsSchema = OptionsSchema> = {
id: string;
name: string;
path?: string;
@@ -352,35 +378,6 @@ function getMeshesBoundingBox(meshes: BABYLON.Mesh[]): BABYLON.BoundingBox {
return new BABYLON.BoundingBox(min.subtract(new BABYLON.Vector3(10000, 10000, 10000)), max.subtract(new BABYLON.Vector3(10000, 10000, 10000)));
}
const TIME_MAP = {
0: 2,
1: 2,
2: 2,
3: 2,
4: 2,
5: 1,
6: 1,
7: 0,
8: 0,
9: 0,
10: 0,
11: 0,
12: 0,
13: 0,
14: 0,
15: 0,
16: 1,
17: 1,
18: 2,
19: 2,
20: 2,
21: 2,
22: 2,
23: 2,
} as const;
const USE_GLOW = true; // ドローコールが増えて重い
function enableObjectCollision(meshes: BABYLON.Mesh[]) {
let hasCollisionMesh = false;
for (const mesh of meshes) {
@@ -420,15 +417,28 @@ function enableObjectCollision(meshes: BABYLON.Mesh[]) {
}
}
export async function createRoomEngine(roomState: RoomState, canvas: HTMLCanvasElement) {
const babylonEngine = new BABYLON.WebGPUEngine(canvas);
babylonEngine.compatibilityMode = false;
await babylonEngine.initAsync();
//const babylonEngine = new BABYLON.Engine(canvas, false, { alpha: false, antialias: false });
return new RoomEngine(roomState, { canvas, engine: babylonEngine });
}
export type RoomEngineEvents = {
'changeSelectedState': (ctx: {
selected: {
objectId: string;
objectState: RoomStateObject<any>;
objectDef: ObjectDef<any>;
} | null;
}) => void;
'changeGrabbingState': (ctx: { grabbing: { forInstall: boolean } | null }) => void;
'changeEditMode': (ctx: { isEditMode: boolean }) => void;
'changeGridSnapping': (ctx: { gridSnapping: { enabled: boolean; scale: number } }) => void;
'changeRoomState': (ctx: { roomState: RoomState }) => void;
'playSfxUrl': (ctx: {
url: string;
options: {
volume: number;
playbackRate: number;
};
}) => void;
};
export class RoomEngine {
export class RoomEngine extends EventEmitter<RoomEngineEvents> {
private canvas: HTMLCanvasElement;
private engine: BABYLON.WebGPUEngine;
public scene: BABYLON.Scene;
@@ -444,9 +454,12 @@ export class RoomEngine {
instance: RoomObjectInstance<any>;
model: ModelManager;
}> = new Map();
private grabbingCtx: {
// TODO: たぶんオブジェクト内の値のmutateはsetで検知できないので、そのような操作を実際に行うようになった & それを検知する必要性が出てきたら専用の設定関数などを新設してそれを使わせる
private _grabbingCtx: {
objectId: string;
objectType: string;
forInstall: boolean;
mesh: BABYLON.TransformNode;
originalDiffOfPosition: BABYLON.Vector3;
originalDiffOfRotationY: number;
@@ -458,17 +471,42 @@ export class RoomEngine {
onCancel?: () => void;
onDone?: () => void;
} | null = null;
public selected = shallowRef<{
get grabbingCtx() {
return this._grabbingCtx;
}
set grabbingCtx(v) {
this._grabbingCtx = v;
this.emit('changeGrabbingState', { grabbing: v == null ? null : { forInstall: v.forInstall } });
}
// TODO: たぶんオブジェクト内の値のmutateはsetで検知できないので、そのような操作を実際に行うようになった & それを検知する必要性が出てきたら専用の設定関数などを新設してそれを使わせる
private _selected: {
objectId: string;
objectEntity: RoomEngine['objectEntities'] extends Map<string, infer V> ? V : never;
objectState: RoomStateObject<any>;
objectDef: ObjectDef;
} | null>(null);
} | null = null;
get selected() {
return this._selected;
}
set selected(v) {
this._selected = v;
this.emit('changeSelectedState', { selected: v == null ? null : { objectId: v.objectId, objectState: v.objectState, objectDef: v.objectDef } });
}
private time: 0 | 1 | 2 = 0; // 0: 昼, 1: 夕, 2: 夜
private roomCollisionMeshes: BABYLON.AbstractMesh[] = [];
public roomState: RoomState;
public enableGridSnapping = ref(true);
public gridSnappingScale = ref(4/*cm*/);
private _gridSnapping = { enabled: true, scale: 4/*cm*/ };
get gridSnapping() {
return this._gridSnapping;
}
set gridSnapping(v) {
this._gridSnapping = v;
this.emit('changeGridSnapping', { gridSnapping: v });
}
private putParticleSystem: BABYLON.ParticleSystem;
private envMapIndoor: BABYLON.CubeTexture;
private envMapOutdoor: BABYLON.CubeTexture;
@@ -478,20 +516,27 @@ export class RoomEngine {
private yGridPreviewPlane: BABYLON.Mesh;
private zGridPreviewPlane: BABYLON.Mesh;
private selectionOutlineLayer: BABYLON.SelectionOutlineLayer;
public isEditMode = false;
public isSitting = ref(false);
public ui = reactive({
isGrabbing: false,
isGrabbingForInstall: false,
});
private fps = 30;
private _isEditMode = false;
get isEditMode() {
return this._isEditMode;
}
set isEditMode(v) {
this._isEditMode = v;
this.emit('changeEditMode', { isEditMode: v });
}
public isSitting = false;
private fps = 60;
constructor(roomState: RoomState, options: {
canvas: HTMLCanvasElement;
engine: BABYLON.WebGPUEngine;
}) {
super();
this.roomState = {
...roomState,
...JSON.parse(JSON.stringify(roomState)),
installedObjects: roomState.installedObjects.map(o => ({
...o,
options: { ...getObjectDef(o.type).options.default, ...o.options },
@@ -672,9 +717,10 @@ export class RoomEngine {
gridMaterial.mainColor = new BABYLON.Color3(0, 0, 0);
gridMaterial.minorUnitVisibility = 1;
gridMaterial.opacity = 0.5;
watch(this.gridSnappingScale, (v) => {
gridMaterial.gridRatio = v;
}, { immediate: true });
// todo
//watch(this.gridSnappingScale, (v) => {
// gridMaterial.gridRatio = v;
//}, { immediate: true });
this.xGridPreviewPlane = BABYLON.MeshBuilder.CreatePlane('xGridPreviewPlane', { width: 1000/*cm*/, height: 1000/*cm*/ }, this.scene);
this.xGridPreviewPlane.rotation = new BABYLON.Vector3(0, 0, Math.PI / 2);
@@ -766,9 +812,13 @@ export class RoomEngine {
axes.zAxis.position = new BABYLON.Vector3(0, 30, 0);
}
(window as any).showBabylonInspector = () => {
ShowInspector(this.scene);
};
if (!IN_WEB_WORKER) {
(window as any).showBabylonInspector = () => {
import('@babylonjs/inspector').then(({ ShowInspector }) => {
ShowInspector(this.scene);
});
};
}
}
}
@@ -818,9 +868,9 @@ export class RoomEngine {
}
public selectObject(objectId: string | null) {
const currentSelected = this.selected.value;
const currentSelected = this.selected;
if (currentSelected != null) {
this.selected.value = null;
this.selected = null;
this.clearHighlight();
currentSelected.objectEntity.model.bakeMesh();
}
@@ -831,7 +881,7 @@ export class RoomEngine {
entity.model.unbakeMesh();
this.highlightMeshes(entity.rootMesh.getChildMeshes());
const state = this.roomState.installedObjects.find(o => o.id === objectId)!;
this.selected.value = {
this.selected = {
objectId,
objectEntity: entity,
objectState: state,
@@ -853,10 +903,10 @@ export class RoomEngine {
grabbing.ghost.position = newPos.clone();
grabbing.ghost.rotation = newRotation.clone();
if (this.enableGridSnapping.value) {
newPos.x = Math.round(newPos.x / this.gridSnappingScale.value) * this.gridSnappingScale.value;
newPos.y = Math.round(newPos.y / this.gridSnappingScale.value) * this.gridSnappingScale.value;
newPos.z = Math.round(newPos.z / this.gridSnappingScale.value) * this.gridSnappingScale.value;
if (this.gridSnapping.enabled) {
newPos.x = Math.round(newPos.x / this.gridSnapping.scale) * this.gridSnapping.scale;
newPos.y = Math.round(newPos.y / this.gridSnapping.scale) * this.gridSnapping.scale;
newPos.z = Math.round(newPos.z / this.gridSnapping.scale) * this.gridSnapping.scale;
newRotation.y = Math.round(newRotation.y / (Math.PI / 8)) * (Math.PI / 8);
}
@@ -905,14 +955,14 @@ export class RoomEngine {
} else if (placement === 'ceiling') {
newPos.y = 250/*cm*/;
if (this.enableGridSnapping.value) {
if (this.gridSnapping.enabled) {
this.yGridPreviewPlane.position = new BABYLON.Vector3(grabbing.mesh.position.x, 250/*cm*/ - 0.1/*cm*/, grabbing.mesh.position.z);
this.yGridPreviewPlane.isVisible = true;
}
} else if (placement === 'floor') {
newPos.y = 0;
if (this.enableGridSnapping.value) {
if (this.gridSnapping.enabled) {
this.yGridPreviewPlane.position = new BABYLON.Vector3(grabbing.mesh.position.x, 0.1/*cm*/, grabbing.mesh.position.z);
this.yGridPreviewPlane.isVisible = true;
}
@@ -926,7 +976,7 @@ export class RoomEngine {
}
if (newPos.y < 0) newPos.y = 0;
if (this.enableGridSnapping.value) {
if (this.gridSnapping.enabled) {
this.yGridPreviewPlane.position = new BABYLON.Vector3(grabbing.mesh.position.x, grabbing.mesh.position.y + 0.1/*cm*/, grabbing.mesh.position.z);
this.yGridPreviewPlane.isVisible = true;
}
@@ -1257,7 +1307,7 @@ export class RoomEngine {
root.metadata = metadata;
const model = new ModelManager(BAKE_TRANSFORM ? root : subRoot, loaderResult.meshes.filter(m => m.name !== subRoot), (meshes) => {
if (this.selected.value?.objectId === args.id) {
if (this.selected?.objectId === args.id) {
this.highlightMeshes(meshes);
}
@@ -1367,9 +1417,9 @@ export class RoomEngine {
}
public beginSelectedInstalledObjectGrabbing() {
if (this.selected.value == null) return;
if (this.selected == null) return;
const selectedObject = this.selected.value.objectEntity.rootMesh;
const selectedObject = this.selected.objectEntity.rootMesh;
this.clearHighlight();
// 子から先に適用していく
@@ -1414,11 +1464,10 @@ export class RoomEngine {
let sticky: string | null;
this.ui.isGrabbing = true;
this.grabbingCtx = {
objectId: selectedObject.metadata.objectId,
objectType: selectedObject.metadata.objectType,
forInstall: false,
mesh: selectedObject,
originalDiffOfPosition: selectedObject.position.subtract(this.camera.position.add(dir.scale(distance))),
originalDiffOfRotationY: selectedObject.rotation.subtract(this.camera.rotation).y,
@@ -1430,16 +1479,13 @@ export class RoomEngine {
sticky = info.sticky;
},
onCancel: () => {
this.ui.isGrabbing = false;
// todo: initialPositionなどを復元
},
onDone: () => { // todo: sticky状態などを引数でもらうようにしたい
this.ui.isGrabbing = false;
this.putParticleSystem.emitter = selectedObject.position.clone();
this.putParticleSystem.start();
sound.playUrl('/client-assets/room/sfx/put.mp3', {
this.playSfxUrl('/client-assets/room/sfx/put.mp3', {
volume: 1,
playbackRate: 1,
});
@@ -1488,17 +1534,19 @@ export class RoomEngine {
this.roomState.installedObjects.find(o => o.id === selectedObject.metadata.objectId)!.sticky = sticky;
this.roomState.installedObjects.find(o => o.id === selectedObject.metadata.objectId)!.position = [pos.x, pos.y, pos.z];
this.roomState.installedObjects.find(o => o.id === selectedObject.metadata.objectId)!.rotation = [rotation.x, rotation.y, rotation.z];
this.emit('changeRoomState', { roomState: this.roomState });
});
},
};
const intervalId = window.setInterval(() => {
const intervalId = setInterval(() => {
this.handleGrabbing();
}, 10);
this.intervalIds.push(intervalId);
sound.playUrl('/client-assets/room/sfx/grab.mp3', {
this.playSfxUrl('/client-assets/room/sfx/grab.mp3', {
volume: 1,
playbackRate: 1,
});
@@ -1532,7 +1580,7 @@ export class RoomEngine {
}
public sitChair(objectId: string) {
this.isSitting.value = true;
this.isSitting = true;
this.fixedCamera.parent = this.objectMeshs.get(objectId);
this.fixedCamera.position = new BABYLON.Vector3(0, 120/*cm*/, 0);
this.fixedCamera.rotation = new BABYLON.Vector3(0, 0, 0);
@@ -1541,7 +1589,7 @@ export class RoomEngine {
}
public standUp() {
this.isSitting.value = false;
this.isSitting = false;
this.scene.activeCamera = this.camera;
this.fixedCamera.parent = null;
}
@@ -1640,11 +1688,10 @@ export class RoomEngine {
let sticky: string | null;
this.ui.isGrabbingForInstall = true;
this.grabbingCtx = {
objectId: id,
objectType: type,
forInstall: true,
mesh: root,
originalDiffOfPosition: new BABYLON.Vector3(0, 0, 0),
originalDiffOfRotationY: Math.PI,
@@ -1656,8 +1703,6 @@ export class RoomEngine {
sticky = info.sticky;
},
onCancel: () => {
this.ui.isGrabbingForInstall = false;
// todo
},
onDone: () => { // todo: sticky状態などを引数でもらうようにしたい
@@ -1665,15 +1710,13 @@ export class RoomEngine {
enableObjectCollision(root.getChildMeshes());
}
this.ui.isGrabbingForInstall = false;
const pos = root.position.clone();
const rotation = root.rotation.clone();
this.putParticleSystem.emitter = pos;
this.putParticleSystem.start();
sound.playUrl('/client-assets/room/sfx/put.mp3', {
this.playSfxUrl('/client-assets/room/sfx/put.mp3', {
volume: 1,
playbackRate: 1,
});
@@ -1705,16 +1748,18 @@ export class RoomEngine {
sticky,
options,
});
this.emit('changeRoomState', { roomState: this.roomState });
},
};
const intervalId = window.setInterval(() => {
const intervalId = setInterval(() => {
this.handleGrabbing();
}, 10);
this.intervalIds.push(intervalId);
sound.playUrl('/client-assets/room/sfx/grab.mp3', {
this.playSfxUrl('/client-assets/room/sfx/grab.mp3', {
volume: 1,
playbackRate: 1,
});
@@ -1778,9 +1823,9 @@ export class RoomEngine {
}
public removeSelectedObject() {
if (this.selected.value == null) return;
if (this.selected == null) return;
const objectId = this.selected.value.objectId;
const objectId = this.selected.objectId;
this.objectEntities.get(objectId)?.rootMesh.dispose();
this.objectEntities.delete(objectId);
@@ -1788,9 +1833,10 @@ export class RoomEngine {
for (const o of this.roomState.installedObjects.filter(o => o.sticky === objectId)) {
o.sticky = null;
}
this.selected.value = null;
this.emit('changeRoomState', { roomState: this.roomState });
this.selected = null;
sound.playUrl('/client-assets/room/sfx/remove.mp3', {
this.playSfxUrl('/client-assets/room/sfx/remove.mp3', {
volume: 1,
playbackRate: 1,
});
@@ -1812,12 +1858,15 @@ export class RoomEngine {
if (options == null) return;
options[key] = value;
this.emit('changeRoomState', { roomState: this.roomState });
const entity = this.objectEntities.get(objectId);
if (entity == null) return;
entity.instance.onOptionsUpdated?.([key, value]);
if (this.selected.value?.objectId === objectId) {
triggerRef(this.selected);
if (this.selected?.objectId === objectId) {
// TODO
//triggerRef(this.selected);
}
}
@@ -1829,6 +1878,10 @@ export class RoomEngine {
}
}
private playSfxUrl(url: string, options: { volume: number; playbackRate: number }) {
this.emit('playSfxUrl', { url, options });
}
public resize() {
this.engine.resize();
}

View File

@@ -100,7 +100,7 @@ export const tabletopDigitalClock = defineObject({
applyBodyColor();
applyLcdColor();
room.intervalIds.push(window.setInterval(() => {
room.intervalIds.push(setInterval(() => {
const onMeshes = get7segMeshesOfCurrentTime(segmentMeshes);
for (const mesh of Object.values(segmentMeshes)) {

View File

@@ -39,7 +39,7 @@ export const wallClock = defineObject({
return {
onInited: () => {
room.intervalIds.push(window.setInterval(() => {
room.intervalIds.push(setInterval(() => {
const now = new Date();
const hours = now.getHours() % 12;
const minutes = now.getMinutes();

View File

@@ -83,8 +83,6 @@ export class HorizontalCameraKeyboardMoveInput extends BABYLON.BaseCameraPointer
if (index < 0) { // 存在しなかったら追加する
this.codes.push({ code });
}
event.preventDefault();
(event as KeyboardEvent).stopPropagation();
}
} else {
if (this.codesUp.indexOf(code) >= 0 ||
@@ -95,8 +93,6 @@ export class HorizontalCameraKeyboardMoveInput extends BABYLON.BaseCameraPointer
if (index >= 0) { // 存在したら削除する
this.codes.splice(index, 1);
}
event.preventDefault();
(event as KeyboardEvent).stopPropagation();
}
}
});

View File

@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core';
import { RoomEngine } from './engine.js';
import type { RoomState } from './engine.js';
let engine: RoomEngine | null = null;
let canvas: HTMLCanvasElement | null = null;
onmessage = async (event) => {
//console.log('Worker received message:', event.data);
switch (event.data?.type) {
case 'init': {
const roomState = event.data.roomState as RoomState;
canvas = event.data.canvas as HTMLCanvasElement;
const babylonEngine = new BABYLON.WebGPUEngine(canvas);
babylonEngine.compatibilityMode = false;
await babylonEngine.initAsync();
engine = new RoomEngine(roomState, { canvas, engine: babylonEngine });
await engine.init();
break;
}
case 'resize': {
canvas.width = event.data.width;
canvas.height = event.data.height;
if (engine != null) engine.resize();
break;
}
case 'dom:keydown': {
if (engine == null) break;
engine.scene.onKeyboardObservable.notifyObservers({ type: BABYLON.KeyboardEventTypes.KEYDOWN, event: event.data.ev });
break;
}
case 'dom:keyup': {
if (engine == null) break;
engine.scene.onKeyboardObservable.notifyObservers({ type: BABYLON.KeyboardEventTypes.KEYUP, event: event.data.ev });
break;
}
case 'dom:pointerdown': {
if (engine == null) break;
event.data.ev.preventDefault = () => {};
event.data.ev.stopPropagation = () => {};
engine.scene.onPointerObservable.notifyObservers({ type: BABYLON.PointerEventTypes.POINTERDOWN, event: event.data.ev });
break;
}
default: {
console.warn('Unrecognized message type:', event.data?.type);
}
}
};