diff --git a/packages/frontend/assets/room/objects/electronic-display-board/electronic-display-board.blend b/packages/frontend/assets/room/objects/electronic-display-board/electronic-display-board.blend new file mode 100644 index 0000000000..ea20d0d081 Binary files /dev/null and b/packages/frontend/assets/room/objects/electronic-display-board/electronic-display-board.blend differ diff --git a/packages/frontend/assets/room/objects/electronic-display-board/electronic-display-board.glb b/packages/frontend/assets/room/objects/electronic-display-board/electronic-display-board.glb new file mode 100644 index 0000000000..a72fbe6f77 Binary files /dev/null and b/packages/frontend/assets/room/objects/electronic-display-board/electronic-display-board.glb differ diff --git a/packages/frontend/assets/room/textures/dot-matrix-chars.af b/packages/frontend/assets/room/textures/dot-matrix-chars.af new file mode 100644 index 0000000000..eedcb55822 Binary files /dev/null and b/packages/frontend/assets/room/textures/dot-matrix-chars.af differ diff --git a/packages/frontend/assets/room/textures/dot-matrix-chars.png b/packages/frontend/assets/room/textures/dot-matrix-chars.png new file mode 100644 index 0000000000..fcfe0bb223 Binary files /dev/null and b/packages/frontend/assets/room/textures/dot-matrix-chars.png differ diff --git a/packages/frontend/src/pages/room.object-customize-form.vue b/packages/frontend/src/pages/room.object-customize-form.vue index 86f935fd11..9e1e3aa3fe 100644 --- a/packages/frontend/src/pages/room.object-customize-form.vue +++ b/packages/frontend/src/pages/room.object-customize-form.vue @@ -17,6 +17,9 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ +
diff --git a/packages/frontend/src/world/room/object-defs.ts b/packages/frontend/src/world/room/object-defs.ts index 95004b945a..c70a897126 100644 --- a/packages/frontend/src/world/room/object-defs.ts +++ b/packages/frontend/src/world/room/object-defs.ts @@ -31,6 +31,7 @@ import { djMixer } from './objects/djMixer.js'; import { djPlayer } from './objects/djPlayer.js'; import { ductRailSpotLights } from './objects/ductRailSpotLights.js'; import { ductTape } from './objects/ductTape.js'; +import { electronicDisplayBoard } from './objects/electronicDisplayBoard.js'; import { emptyBento } from './objects/emptyBento.js'; import { energyDrink } from './objects/energyDrink.js'; import { envelope } from './objects/envelope.js'; @@ -128,6 +129,7 @@ export const OBJECT_DEFS = [ djPlayer, ductRailSpotLights, ductTape, + electronicDisplayBoard, emptyBento, energyDrink, envelope, diff --git a/packages/frontend/src/world/room/object.ts b/packages/frontend/src/world/room/object.ts index 8b7b14489d..eabcaab7e0 100644 --- a/packages/frontend/src/world/room/object.ts +++ b/packages/frontend/src/world/room/object.ts @@ -47,6 +47,11 @@ type BooleanOptionSchema = { label: string; }; +type StringOptionSchema = { + type: 'string'; + label: string; +}; + type ColorOptionSchema = { type: 'color'; label: string; @@ -76,12 +81,13 @@ type SeedOptionSchema = { label: string; }; -type OptionsSchema = Record; +type OptionsSchema = Record; type GetOptionsSchemaValues = { [K in keyof T]: T[K] extends NumberOptionSchema ? number : T[K] extends BooleanOptionSchema ? boolean : + T[K] extends StringOptionSchema ? string : T[K] extends ColorOptionSchema ? [number, number, number] : T[K] extends EnumOptionSchema ? T[K]['enum'][number] : T[K] extends RangeOptionSchema ? number : diff --git a/packages/frontend/src/world/room/objects/electronicDisplayBoard.ts b/packages/frontend/src/world/room/objects/electronicDisplayBoard.ts new file mode 100644 index 0000000000..df0c38d658 --- /dev/null +++ b/packages/frontend/src/world/room/objects/electronicDisplayBoard.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as BABYLON from '@babylonjs/core'; +import { defineObject } from '../object.js'; +import { createPlaneUvMapper, RecyvlingTextGrid } from '../../utility.js'; + +export const electronicDisplayBoard = defineObject({ + id: 'electronicDisplayBoard', + name: 'electronicDisplayBoard', + options: { + schema: { + text: { + type: 'string', + label: 'Text', + }, + frameColor: { + type: 'color', + label: 'Frame color', + }, + ledColor: { + type: 'color', + label: 'LED color', + }, + }, + default: { + text: 'Hello, Misskey!', + frameColor: [0.2, 0.2, 0.2], + ledColor: [1, 1, 1], + }, + }, + placement: 'side', + hasCollisions: false, + hasTexture: true, + createInstance: async ({ scene, options, model, timer }) => { + const frameMaterial = model.findMaterial('__X_BODY__'); + + const textMaterial = new BABYLON.PBRMaterial('textMaterial', scene); + textMaterial.albedoColor = new BABYLON.Color3(0, 0, 0); + textMaterial.roughness = 1; + + const texLoading = Promise.withResolvers(); + + const tex = new BABYLON.Texture('/client-assets/room/textures/dot-matrix-chars.png', scene, false, false, undefined, () => { + textMaterial.emissiveTexture = tex; + textMaterial.albedoTexture = tex; + textMaterial.disableLighting = true; + textMaterial.emissiveTexture.hasAlpha = true; + textMaterial.transparencyMode = BABYLON.Material.MATERIAL_ALPHABLEND; + textMaterial.useAlphaFromAlbedoTexture = true; + textMaterial.freeze(); + texLoading.resolve(); + }, (message, exception) => { + console.warn('Failed to load texture:', message, exception); + textMaterial.emissiveColor = new BABYLON.Color3(0, 1, 0); + textMaterial.emissiveTexture = null; + texLoading.resolve(); + }); + + await texLoading.promise; + + const maxChars = 6; + + const displayMesh = model.findMesh('__X_DISPLAY__'); + displayMesh.material = textMaterial; + const textManager = new RecyvlingTextGrid(displayMesh, maxChars, { + meshFlipped: true, + material: textMaterial, + }); + + model.bakeExcludeMeshes = [displayMesh]; + + const applyFrameColor = () => { + const [r, g, b] = options.frameColor; + frameMaterial.albedoColor = new BABYLON.Color3(r, g, b); + }; + + applyFrameColor(); + + const applyLedColor = () => { + const [r, g, b] = options.ledColor; + textMaterial.emissiveColor = new BABYLON.Color3(r, g, b); + }; + + applyLedColor(); + + let text = ''; + + const applyText = () => { + text = options.text + ' '; + }; + + applyText(); + + let textIndex = 0; + timer.setInterval(() => { + let displayText = ''; + for (let i = 0; i < maxChars; i++) { + displayText += text[(textIndex + i) % text.length]; + } + textManager.write(displayText); + + textIndex = (textIndex + 1) % text.length; + }, 500); + + return { + onInited: () => { + + }, + onOptionsUpdated: ([k, v]) => { + switch (k) { + case 'text': applyText(); break; + case 'frameColor': applyFrameColor(); break; + case 'ledColor': applyLedColor(); break; + } + }, + interactions: {}, + }; + }, +}); diff --git a/packages/frontend/src/world/utility.ts b/packages/frontend/src/world/utility.ts index cd4583eb73..f2ee8d3f8f 100644 --- a/packages/frontend/src/world/utility.ts +++ b/packages/frontend/src/world/utility.ts @@ -384,6 +384,7 @@ export class RecyvlingText { } } +// 各面はUV展開時に全ての面が0~1の正方形に"reset"されていること。(全ての面がUV的に重なっている状態) export class RecyvlingTextGrid { public facesCount: number; public mesh: BABYLON.Mesh; @@ -487,7 +488,7 @@ export class RecyvlingTextGrid { } for (let i = 0; i < this.facesCount; i++) { - if (i + text.length >= (maxRepeat * text.length)) { + if (maxRepeat > 1 && (i + text.length >= (maxRepeat * text.length))) { if (i >= this.facesCount - this.repeatSeparator.length) { charIndexes.push(TEXT_TEXTURE_CHAR_MAP[this.repeatSeparator[(i - (this.facesCount - this.repeatSeparator.length)) % this.repeatSeparator.length]]); } else {