1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-26 09:04:23 +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> <template>
<div :class="$style.root" class="_pageScrollable"> <div :class="$style.root" class="_pageScrollable">
<div :class="[$style.screen, { [$style.zen]: isZenMode }]"> <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"> <template v-if="!isZenMode">
<div v-if="engine != null" class="_buttonsCenter" :class="$style.overlayControls"> <div v-if="controller.isReady.value" class="_buttonsCenter" :class="$style.overlayControls">
<template v-if="isEditMode"> <template v-if="controller.isEditMode.value">
<MkButton v-if="engine.ui.isGrabbing" @click="endGrabbing"><i class="ti ti-check"></i> (E)</MkButton> <MkButton v-if="controller.grabbing.value && !controller.grabbing.value.forInstall" @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="controller.grabbing.value && controller.grabbing.value.forInstall" @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> <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> </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"> <template v-for="interaction in interacions" :key="interaction.id">
<MkButton inline @click="interaction.fn()">{{ interaction.label }}{{ interaction.isPrimary ? ' (E)' : '' }}</MkButton> <MkButton inline @click="interaction.fn()">{{ interaction.label }}{{ interaction.isPrimary ? ' (E)' : '' }}</MkButton>
</template> </template>
</div> </div>
<div v-if="engine != null && isEditMode && engine.selected.value != null" :key="engine.selected.value.objectId" class="_panel" :class="$style.overlayObjectInfoPanel"> <div v-if="controller.isReady.value && controller.isEditMode.value && controller.selected.value != null" :key="controller.selected.value.objectId" class="_panel" :class="$style.overlayObjectInfoPanel">
{{ engine.selected.value.objectDef.name }} {{ controller.selected.value.objectDef.name }}
<div class="_gaps"> <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>{{ s.label }}</div>
<div v-if="s.type === 'color'"> <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>
<div v-else-if="s.type === 'boolean'"> <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>
<div v-else-if="s.type === 'enum'"> <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>
<div v-else-if="s.type === 'range'"> <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>
<div v-else-if="s.type === 'image'"> <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> </div>
</div> </div>
@@ -55,13 +55,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<template v-if="!isZenMode"> <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 v-for="action in actions" :key="action.key" @click="action.fn">{{ action.label }}{{ hotkeyToLabel(action.hotkey) }}</MkButton>-->
<MkButton @click="toggleLight">Toggle Light</MkButton> <MkButton @click="toggleLight">Toggle Light</MkButton>
<MkButton v-if="isEditMode" primary @click="save">Save</MkButton> <MkButton v-if="controller.isEditMode.value" primary @click="save">Save</MkButton>
<MkButton v-if="isEditMode" @click="exitEditMode">Exit edit mode</MkButton> <MkButton v-if="controller.isEditMode.value" @click="exitEditMode">Exit edit mode</MkButton>
<MkButton v-if="!isEditMode" @click="enterEditMode">Edit mode</MkButton> <MkButton v-if="!controller.isEditMode.value" @click="enterEditMode">Edit mode</MkButton>
<MkButton v-if="isEditMode" @click="addObject">addObject</MkButton> <MkButton v-if="controller.isEditMode.value" @click="addObject">addObject</MkButton>
<MkButton @click="expor">Export</MkButton> <MkButton @click="expor">Export</MkButton>
<MkButton @click="impor">Import</MkButton> <MkButton @click="impor">Import</MkButton>
</div> </div>
@@ -75,20 +75,15 @@ import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i'; import { ensureSignin } from '@/i';
import MkButton from '@/components/MkButton.vue'; 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 MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import { RoomController } from '@/utility/room/controller.js';
const canvas = useTemplateRef('canvas'); const canvas = useTemplateRef('canvas');
const engine = shallowRef<RoomEngine | null>(null);
const isEditMode = ref(false);
const interacions = shallowRef<{ const interacions = shallowRef<{
id: string; id: string;
label: string; label: string;
@@ -97,123 +92,11 @@ const interacions = shallowRef<{
}[]>([]); }[]>([]);
function resize() { 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); 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')!), ...{ const data = localStorage.getItem('roomData') != null ? { ...JSON.parse(localStorage.getItem('roomData')!), ...{
heya: { heya: {
type: 'simple', type: 'simple',
@@ -281,90 +164,151 @@ const data = localStorage.getItem('roomData') != null ? { ...JSON.parse(localSto
installedObjects: [], installedObjects: [],
}; };
onMounted(async () => { const controller = new RoomController(data);
engine.value = await createRoomEngine(data, canvas.value!);
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(); canvas.value!.focus();
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
watch(engine.value.selected, (v) => { //watch(controller.selected, (v) => {
if (v == null) { // if (v == null) {
interacions.value = []; // interacions.value = [];
} else { // } else {
interacions.value = Object.entries(v.objectEntity.instance.interactions).map(([interactionId, interactionInfo]) => ({ // interacions.value = Object.entries(v.objectEntity.instance.interactions).map(([interactionId, interactionInfo]) => ({
id: interactionId, // id: interactionId,
label: interactionInfo.label, // label: interactionInfo.label,
isPrimary: v.objectEntity.instance.primaryInteraction === interactionId, // isPrimary: v.objectEntity.instance.primaryInteraction === interactionId,
fn: interactionInfo.fn, // fn: interactionInfo.fn,
})); // }));
} // }
}); //});
}); });
onUnmounted(() => { onUnmounted(() => {
engine.value.destroy(); controller.destroy();
window.removeEventListener('resize', resize); window.removeEventListener('resize', resize);
}); });
function beginSelectedInstalledObjectGrabbing() { function beginSelectedInstalledObjectGrabbing() {
engine.value.beginSelectedInstalledObjectGrabbing(); controller.beginSelectedInstalledObjectGrabbing();
canvas.value!.focus(); canvas.value!.focus();
} }
function endGrabbing() { function endGrabbing() {
engine.value.endGrabbing(); controller.endGrabbing();
canvas.value!.focus(); canvas.value!.focus();
} }
function toggleLight() { function toggleLight() {
engine.value.toggleRoomLight(); controller.toggleRoomLight();
canvas.value!.focus(); canvas.value!.focus();
} }
function showSnappingMenu(ev: PointerEvent) { function showSnappingMenu(ev: PointerEvent) {
if (engine.value == null) return;
os.popupMenu([{ os.popupMenu([{
type: 'switch', type: 'switch',
text: i18n.ts._room.snapToGrid, 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', type: 'radioOption',
text: '1cm', text: '1cm',
active: computed(() => engine.value!.gridSnappingScale.value === 1), active: computed(() => controller.gridSnapping.value.scale === 1),
action: () => engine.value!.gridSnappingScale.value = 1, action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 1 }),
}, { }, {
type: 'radioOption', type: 'radioOption',
text: '2cm', text: '2cm',
active: computed(() => engine.value!.gridSnappingScale.value === 2), active: computed(() => controller.gridSnapping.value.scale === 2),
action: () => engine.value!.gridSnappingScale.value = 2, action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 2 }),
}, { }, {
type: 'radioOption', type: 'radioOption',
text: '4cm', text: '4cm',
active: computed(() => engine.value!.gridSnappingScale.value === 4), active: computed(() => controller.gridSnapping.value.scale === 4),
action: () => engine.value!.gridSnappingScale.value = 4, action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 4 }),
}, { }, {
type: 'radioOption', type: 'radioOption',
text: '8cm', text: '8cm',
active: computed(() => engine.value!.gridSnappingScale.value === 8), active: computed(() => controller.gridSnapping.value.scale === 8),
action: () => engine.value!.gridSnappingScale.value = 8, action: () => controller.setGridSnapping({ ...controller.gridSnapping.value, scale: 8 }),
}], ev.currentTarget ?? ev.target); }], ev.currentTarget ?? ev.target);
} }
function rotate() { function rotate() {
engine.value.changeGrabbingRotationY(Math.PI / 8); controller.changeGrabbingRotationY(Math.PI / 8);
canvas.value!.focus(); canvas.value!.focus();
} }
async function addObject(ev: PointerEvent) { 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), { const { dispose } = await os.popupAsyncWithDialog(import('./room.add-object-dialog.vue').then(x => x.default), {
}, { }, {
ok: async (res) => { ok: async (res) => {
engine.value?.addObject(res); controller.addObject(res);
canvas.value!.focus(); canvas.value!.focus();
}, },
closed: () => dispose(), closed: () => dispose(),
@@ -372,23 +316,16 @@ async function addObject(ev: PointerEvent) {
} }
function removeSelectedObject() { function removeSelectedObject() {
engine.value?.removeSelectedObject(); controller.removeSelectedObject();
canvas.value!.focus();
}
function showBoundingBox() {
engine.value?.showBoundingBox();
canvas.value!.focus(); canvas.value!.focus();
} }
function enterEditMode() { function enterEditMode() {
engine.value?.enterEditMode(); controller.enterEditMode();
isEditMode.value = true;
} }
function exitEditMode() { function exitEditMode() {
engine.value?.exitEditMode(); controller.exitEditMode();
isEditMode.value = false;
} }
function getHex(c: [number, number, number]) { function getHex(c: [number, number, number]) {
@@ -410,13 +347,11 @@ function getRgb(hex: string | number): [number, number, number] | null {
} }
function save() { function save() {
if (engine.value == null) return; localStorage.setItem('roomData', JSON.stringify(controller.roomState));
localStorage.setItem('roomData', JSON.stringify(engine.value.roomState));
} }
function expor() { function expor() {
if (engine.value == null) return; const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(controller.roomState));
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(engine.value.roomState));
const dlAnchorElem = window.document.createElement('a'); const dlAnchorElem = window.document.createElement('a');
dlAnchorElem.setAttribute('href', dataStr); dlAnchorElem.setAttribute('href', dataStr);
dlAnchorElem.setAttribute('download', 'room.json'); dlAnchorElem.setAttribute('download', 'room.json');
@@ -424,7 +359,6 @@ function expor() {
} }
function impor() { function impor() {
if (engine.value == null) return;
const inputElem = window.document.createElement('input'); const inputElem = window.document.createElement('input');
inputElem.setAttribute('type', 'file'); inputElem.setAttribute('type', 'file');
inputElem.setAttribute('accept', 'application/json'); 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: 近くのオブジェクトの端にスナップオプション
// 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 * as BABYLON from '@babylonjs/core';
import { AxesViewer } from '@babylonjs/core/Debug/axesViewer'; import { AxesViewer } from '@babylonjs/core/Debug/axesViewer';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic'; import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic';
import { BoundingBoxRenderer } from '@babylonjs/core/Rendering/boundingBoxRenderer'; import { BoundingBoxRenderer } from '@babylonjs/core/Rendering/boundingBoxRenderer';
import { GridMaterial } from '@babylonjs/materials'; import { GridMaterial } from '@babylonjs/materials';
import { ShowInspector } from '@babylonjs/inspector'; import { EventEmitter } from 'eventemitter3';
import { reactive, ref, shallowRef, triggerRef, watch } from 'vue';
import { genId } from '../id.js'; import { genId } from '../id.js';
import { deepClone } from '../clone.js'; import { deepClone } from '../clone.js';
import { getObjectDef } from './object-defs.js'; import { getObjectDef } from './object-defs.js';
import { HorizontalCameraKeyboardMoveInput, applyMorphTargetsToMesh, camelToKebab, findMaterial, scaleMorph } from './utility.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のドメイン知識は持たない // babylonのドメイン知識は持たない
type RoomStateObject<Options = any> = { export type RoomStateObject<Options = any> = {
id: string; id: string;
type: string; type: string;
position: [number, number, number]; position: [number, number, number];
@@ -96,7 +122,7 @@ type Heya = {
type: 'japanese'; type: 'japanese';
}; };
type RoomState = { export type RoomState = {
heya: Heya; heya: Heya;
installedObjects: RoomStateObject<any>[]; installedObjects: RoomStateObject<any>[];
}; };
@@ -302,7 +328,7 @@ class ModelManager {
} }
} }
type ObjectDef<OpSc extends OptionsSchema = OptionsSchema> = { export type ObjectDef<OpSc extends OptionsSchema = OptionsSchema> = {
id: string; id: string;
name: string; name: string;
path?: 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))); 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[]) { function enableObjectCollision(meshes: BABYLON.Mesh[]) {
let hasCollisionMesh = false; let hasCollisionMesh = false;
for (const mesh of meshes) { for (const mesh of meshes) {
@@ -420,15 +417,28 @@ function enableObjectCollision(meshes: BABYLON.Mesh[]) {
} }
} }
export async function createRoomEngine(roomState: RoomState, canvas: HTMLCanvasElement) { export type RoomEngineEvents = {
const babylonEngine = new BABYLON.WebGPUEngine(canvas); 'changeSelectedState': (ctx: {
babylonEngine.compatibilityMode = false; selected: {
await babylonEngine.initAsync(); objectId: string;
//const babylonEngine = new BABYLON.Engine(canvas, false, { alpha: false, antialias: false }); objectState: RoomStateObject<any>;
return new RoomEngine(roomState, { canvas, engine: babylonEngine }); 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 canvas: HTMLCanvasElement;
private engine: BABYLON.WebGPUEngine; private engine: BABYLON.WebGPUEngine;
public scene: BABYLON.Scene; public scene: BABYLON.Scene;
@@ -444,9 +454,12 @@ export class RoomEngine {
instance: RoomObjectInstance<any>; instance: RoomObjectInstance<any>;
model: ModelManager; model: ModelManager;
}> = new Map(); }> = new Map();
private grabbingCtx: {
// TODO: たぶんオブジェクト内の値のmutateはsetで検知できないので、そのような操作を実際に行うようになった & それを検知する必要性が出てきたら専用の設定関数などを新設してそれを使わせる
private _grabbingCtx: {
objectId: string; objectId: string;
objectType: string; objectType: string;
forInstall: boolean;
mesh: BABYLON.TransformNode; mesh: BABYLON.TransformNode;
originalDiffOfPosition: BABYLON.Vector3; originalDiffOfPosition: BABYLON.Vector3;
originalDiffOfRotationY: number; originalDiffOfRotationY: number;
@@ -458,17 +471,42 @@ export class RoomEngine {
onCancel?: () => void; onCancel?: () => void;
onDone?: () => void; onDone?: () => void;
} | null = null; } | 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; objectId: string;
objectEntity: RoomEngine['objectEntities'] extends Map<string, infer V> ? V : never; objectEntity: RoomEngine['objectEntities'] extends Map<string, infer V> ? V : never;
objectState: RoomStateObject<any>; objectState: RoomStateObject<any>;
objectDef: ObjectDef; 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 time: 0 | 1 | 2 = 0; // 0: 昼, 1: 夕, 2: 夜
private roomCollisionMeshes: BABYLON.AbstractMesh[] = []; private roomCollisionMeshes: BABYLON.AbstractMesh[] = [];
public roomState: RoomState; 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 putParticleSystem: BABYLON.ParticleSystem;
private envMapIndoor: BABYLON.CubeTexture; private envMapIndoor: BABYLON.CubeTexture;
private envMapOutdoor: BABYLON.CubeTexture; private envMapOutdoor: BABYLON.CubeTexture;
@@ -478,20 +516,27 @@ export class RoomEngine {
private yGridPreviewPlane: BABYLON.Mesh; private yGridPreviewPlane: BABYLON.Mesh;
private zGridPreviewPlane: BABYLON.Mesh; private zGridPreviewPlane: BABYLON.Mesh;
private selectionOutlineLayer: BABYLON.SelectionOutlineLayer; private selectionOutlineLayer: BABYLON.SelectionOutlineLayer;
public isEditMode = false;
public isSitting = ref(false); private _isEditMode = false;
public ui = reactive({ get isEditMode() {
isGrabbing: false, return this._isEditMode;
isGrabbingForInstall: false, }
}); set isEditMode(v) {
private fps = 30; this._isEditMode = v;
this.emit('changeEditMode', { isEditMode: v });
}
public isSitting = false;
private fps = 60;
constructor(roomState: RoomState, options: { constructor(roomState: RoomState, options: {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
engine: BABYLON.WebGPUEngine; engine: BABYLON.WebGPUEngine;
}) { }) {
super();
this.roomState = { this.roomState = {
...roomState, ...JSON.parse(JSON.stringify(roomState)),
installedObjects: roomState.installedObjects.map(o => ({ installedObjects: roomState.installedObjects.map(o => ({
...o, ...o,
options: { ...getObjectDef(o.type).options.default, ...o.options }, options: { ...getObjectDef(o.type).options.default, ...o.options },
@@ -672,9 +717,10 @@ export class RoomEngine {
gridMaterial.mainColor = new BABYLON.Color3(0, 0, 0); gridMaterial.mainColor = new BABYLON.Color3(0, 0, 0);
gridMaterial.minorUnitVisibility = 1; gridMaterial.minorUnitVisibility = 1;
gridMaterial.opacity = 0.5; gridMaterial.opacity = 0.5;
watch(this.gridSnappingScale, (v) => { // todo
gridMaterial.gridRatio = v; //watch(this.gridSnappingScale, (v) => {
}, { immediate: true }); // gridMaterial.gridRatio = v;
//}, { immediate: true });
this.xGridPreviewPlane = BABYLON.MeshBuilder.CreatePlane('xGridPreviewPlane', { width: 1000/*cm*/, height: 1000/*cm*/ }, this.scene); 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); 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); axes.zAxis.position = new BABYLON.Vector3(0, 30, 0);
} }
(window as any).showBabylonInspector = () => { if (!IN_WEB_WORKER) {
ShowInspector(this.scene); (window as any).showBabylonInspector = () => {
}; import('@babylonjs/inspector').then(({ ShowInspector }) => {
ShowInspector(this.scene);
});
};
}
} }
} }
@@ -818,9 +868,9 @@ export class RoomEngine {
} }
public selectObject(objectId: string | null) { public selectObject(objectId: string | null) {
const currentSelected = this.selected.value; const currentSelected = this.selected;
if (currentSelected != null) { if (currentSelected != null) {
this.selected.value = null; this.selected = null;
this.clearHighlight(); this.clearHighlight();
currentSelected.objectEntity.model.bakeMesh(); currentSelected.objectEntity.model.bakeMesh();
} }
@@ -831,7 +881,7 @@ export class RoomEngine {
entity.model.unbakeMesh(); entity.model.unbakeMesh();
this.highlightMeshes(entity.rootMesh.getChildMeshes()); this.highlightMeshes(entity.rootMesh.getChildMeshes());
const state = this.roomState.installedObjects.find(o => o.id === objectId)!; const state = this.roomState.installedObjects.find(o => o.id === objectId)!;
this.selected.value = { this.selected = {
objectId, objectId,
objectEntity: entity, objectEntity: entity,
objectState: state, objectState: state,
@@ -853,10 +903,10 @@ export class RoomEngine {
grabbing.ghost.position = newPos.clone(); grabbing.ghost.position = newPos.clone();
grabbing.ghost.rotation = newRotation.clone(); grabbing.ghost.rotation = newRotation.clone();
if (this.enableGridSnapping.value) { if (this.gridSnapping.enabled) {
newPos.x = Math.round(newPos.x / this.gridSnappingScale.value) * this.gridSnappingScale.value; newPos.x = Math.round(newPos.x / this.gridSnapping.scale) * this.gridSnapping.scale;
newPos.y = Math.round(newPos.y / this.gridSnappingScale.value) * this.gridSnappingScale.value; newPos.y = Math.round(newPos.y / this.gridSnapping.scale) * this.gridSnapping.scale;
newPos.z = Math.round(newPos.z / this.gridSnappingScale.value) * this.gridSnappingScale.value; newPos.z = Math.round(newPos.z / this.gridSnapping.scale) * this.gridSnapping.scale;
newRotation.y = Math.round(newRotation.y / (Math.PI / 8)) * (Math.PI / 8); newRotation.y = Math.round(newRotation.y / (Math.PI / 8)) * (Math.PI / 8);
} }
@@ -905,14 +955,14 @@ export class RoomEngine {
} else if (placement === 'ceiling') { } else if (placement === 'ceiling') {
newPos.y = 250/*cm*/; 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.position = new BABYLON.Vector3(grabbing.mesh.position.x, 250/*cm*/ - 0.1/*cm*/, grabbing.mesh.position.z);
this.yGridPreviewPlane.isVisible = true; this.yGridPreviewPlane.isVisible = true;
} }
} else if (placement === 'floor') { } else if (placement === 'floor') {
newPos.y = 0; 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.position = new BABYLON.Vector3(grabbing.mesh.position.x, 0.1/*cm*/, grabbing.mesh.position.z);
this.yGridPreviewPlane.isVisible = true; this.yGridPreviewPlane.isVisible = true;
} }
@@ -926,7 +976,7 @@ export class RoomEngine {
} }
if (newPos.y < 0) newPos.y = 0; 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.position = new BABYLON.Vector3(grabbing.mesh.position.x, grabbing.mesh.position.y + 0.1/*cm*/, grabbing.mesh.position.z);
this.yGridPreviewPlane.isVisible = true; this.yGridPreviewPlane.isVisible = true;
} }
@@ -1257,7 +1307,7 @@ export class RoomEngine {
root.metadata = metadata; root.metadata = metadata;
const model = new ModelManager(BAKE_TRANSFORM ? root : subRoot, loaderResult.meshes.filter(m => m.name !== subRoot), (meshes) => { 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); this.highlightMeshes(meshes);
} }
@@ -1367,9 +1417,9 @@ export class RoomEngine {
} }
public beginSelectedInstalledObjectGrabbing() { 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(); this.clearHighlight();
// 子から先に適用していく // 子から先に適用していく
@@ -1414,11 +1464,10 @@ export class RoomEngine {
let sticky: string | null; let sticky: string | null;
this.ui.isGrabbing = true;
this.grabbingCtx = { this.grabbingCtx = {
objectId: selectedObject.metadata.objectId, objectId: selectedObject.metadata.objectId,
objectType: selectedObject.metadata.objectType, objectType: selectedObject.metadata.objectType,
forInstall: false,
mesh: selectedObject, mesh: selectedObject,
originalDiffOfPosition: selectedObject.position.subtract(this.camera.position.add(dir.scale(distance))), originalDiffOfPosition: selectedObject.position.subtract(this.camera.position.add(dir.scale(distance))),
originalDiffOfRotationY: selectedObject.rotation.subtract(this.camera.rotation).y, originalDiffOfRotationY: selectedObject.rotation.subtract(this.camera.rotation).y,
@@ -1430,16 +1479,13 @@ export class RoomEngine {
sticky = info.sticky; sticky = info.sticky;
}, },
onCancel: () => { onCancel: () => {
this.ui.isGrabbing = false;
// todo: initialPositionなどを復元 // todo: initialPositionなどを復元
}, },
onDone: () => { // todo: sticky状態などを引数でもらうようにしたい onDone: () => { // todo: sticky状態などを引数でもらうようにしたい
this.ui.isGrabbing = false;
this.putParticleSystem.emitter = selectedObject.position.clone(); this.putParticleSystem.emitter = selectedObject.position.clone();
this.putParticleSystem.start(); this.putParticleSystem.start();
sound.playUrl('/client-assets/room/sfx/put.mp3', { this.playSfxUrl('/client-assets/room/sfx/put.mp3', {
volume: 1, volume: 1,
playbackRate: 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)!.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)!.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.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(); this.handleGrabbing();
}, 10); }, 10);
this.intervalIds.push(intervalId); this.intervalIds.push(intervalId);
sound.playUrl('/client-assets/room/sfx/grab.mp3', { this.playSfxUrl('/client-assets/room/sfx/grab.mp3', {
volume: 1, volume: 1,
playbackRate: 1, playbackRate: 1,
}); });
@@ -1532,7 +1580,7 @@ export class RoomEngine {
} }
public sitChair(objectId: string) { public sitChair(objectId: string) {
this.isSitting.value = true; this.isSitting = true;
this.fixedCamera.parent = this.objectMeshs.get(objectId); this.fixedCamera.parent = this.objectMeshs.get(objectId);
this.fixedCamera.position = new BABYLON.Vector3(0, 120/*cm*/, 0); this.fixedCamera.position = new BABYLON.Vector3(0, 120/*cm*/, 0);
this.fixedCamera.rotation = new BABYLON.Vector3(0, 0, 0); this.fixedCamera.rotation = new BABYLON.Vector3(0, 0, 0);
@@ -1541,7 +1589,7 @@ export class RoomEngine {
} }
public standUp() { public standUp() {
this.isSitting.value = false; this.isSitting = false;
this.scene.activeCamera = this.camera; this.scene.activeCamera = this.camera;
this.fixedCamera.parent = null; this.fixedCamera.parent = null;
} }
@@ -1640,11 +1688,10 @@ export class RoomEngine {
let sticky: string | null; let sticky: string | null;
this.ui.isGrabbingForInstall = true;
this.grabbingCtx = { this.grabbingCtx = {
objectId: id, objectId: id,
objectType: type, objectType: type,
forInstall: true,
mesh: root, mesh: root,
originalDiffOfPosition: new BABYLON.Vector3(0, 0, 0), originalDiffOfPosition: new BABYLON.Vector3(0, 0, 0),
originalDiffOfRotationY: Math.PI, originalDiffOfRotationY: Math.PI,
@@ -1656,8 +1703,6 @@ export class RoomEngine {
sticky = info.sticky; sticky = info.sticky;
}, },
onCancel: () => { onCancel: () => {
this.ui.isGrabbingForInstall = false;
// todo // todo
}, },
onDone: () => { // todo: sticky状態などを引数でもらうようにしたい onDone: () => { // todo: sticky状態などを引数でもらうようにしたい
@@ -1665,15 +1710,13 @@ export class RoomEngine {
enableObjectCollision(root.getChildMeshes()); enableObjectCollision(root.getChildMeshes());
} }
this.ui.isGrabbingForInstall = false;
const pos = root.position.clone(); const pos = root.position.clone();
const rotation = root.rotation.clone(); const rotation = root.rotation.clone();
this.putParticleSystem.emitter = pos; this.putParticleSystem.emitter = pos;
this.putParticleSystem.start(); this.putParticleSystem.start();
sound.playUrl('/client-assets/room/sfx/put.mp3', { this.playSfxUrl('/client-assets/room/sfx/put.mp3', {
volume: 1, volume: 1,
playbackRate: 1, playbackRate: 1,
}); });
@@ -1705,16 +1748,18 @@ export class RoomEngine {
sticky, sticky,
options, options,
}); });
this.emit('changeRoomState', { roomState: this.roomState });
}, },
}; };
const intervalId = window.setInterval(() => { const intervalId = setInterval(() => {
this.handleGrabbing(); this.handleGrabbing();
}, 10); }, 10);
this.intervalIds.push(intervalId); this.intervalIds.push(intervalId);
sound.playUrl('/client-assets/room/sfx/grab.mp3', { this.playSfxUrl('/client-assets/room/sfx/grab.mp3', {
volume: 1, volume: 1,
playbackRate: 1, playbackRate: 1,
}); });
@@ -1778,9 +1823,9 @@ export class RoomEngine {
} }
public removeSelectedObject() { 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.get(objectId)?.rootMesh.dispose();
this.objectEntities.delete(objectId); this.objectEntities.delete(objectId);
@@ -1788,9 +1833,10 @@ export class RoomEngine {
for (const o of this.roomState.installedObjects.filter(o => o.sticky === objectId)) { for (const o of this.roomState.installedObjects.filter(o => o.sticky === objectId)) {
o.sticky = null; 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, volume: 1,
playbackRate: 1, playbackRate: 1,
}); });
@@ -1812,12 +1858,15 @@ export class RoomEngine {
if (options == null) return; if (options == null) return;
options[key] = value; options[key] = value;
this.emit('changeRoomState', { roomState: this.roomState });
const entity = this.objectEntities.get(objectId); const entity = this.objectEntities.get(objectId);
if (entity == null) return; if (entity == null) return;
entity.instance.onOptionsUpdated?.([key, value]); entity.instance.onOptionsUpdated?.([key, value]);
if (this.selected.value?.objectId === objectId) { if (this.selected?.objectId === objectId) {
triggerRef(this.selected); // 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() { public resize() {
this.engine.resize(); this.engine.resize();
} }

View File

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

View File

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

View File

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