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
emit('update', k, v)">
+
+ emit('update', k, v)">
+
emit('update', k, v)">
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 {