feat(09): материал studs + studs-block + лего-сет (паритет со студией)

Портирование задачи 09 в плеер:
- PrimitiveManager: material 'studs' (diffuse×color + normal, тайлинг по размеру).
- BlockTypes: studs-block ('Окрашиваемые', colorable).
- BlockManager: per-instance color через ThinInstance color buffer
  (useVertexColors + thinInstanceSetBuffer('color')), addBlock с color,
  _setBlockColorAt/setBlockColor, serialize/load с color.
- GameRuntime: scene.setColor блока + spawn block с color.
- ScriptSandboxWorker: spawn блока прокидывает color.
- ModelTypes: лего-сет 19 compound-моделей (паритет).

Текстуры: public/kubikon-assets/materials/studs_{diffuse,normal}.png.
Проверено: рендер studs-блоков/примитивов/лего-моделей.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
МИН 2026-05-31 11:11:09 +03:00
parent cd31078e6d
commit 80e546eb08
6 changed files with 1180 additions and 943 deletions

View File

@ -94,6 +94,10 @@ export class BlockManager {
this._lavaSurfaceBaseY = null; this._lavaSurfaceBaseY = null;
this._lavaDirty = false; this._lavaDirty = false;
this._animTime = 0; this._animTime = 0;
// Окрашиваемые блоки (studs-block, задача 09): per-instance color через
// ThinInstance color buffer. blockTypeId → Float32Array(maxBlocks*4 RGBA).
this._colorsByProto = new Map();
this._STUDS_MAX = 20000; // макс блоков одного окрашиваемого типа
} }
/** Вызывать каждый кадр для анимации воды/лавы. */ /** Вызывать каждый кадр для анимации воды/лавы. */
@ -359,6 +363,22 @@ export class BlockManager {
const mat = new StandardMaterial(name, this.scene); const mat = new StandardMaterial(name, this.scene);
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
// Окрашиваемый блок (studs-block): цвет берётся per-instance из vertex
// color буфера ThinInstance и умножается на серую текстуру. Включаем
// useVertexColors, normal map (выпуклость кружков), мягкий спекуляр.
if (blockType.colorable) {
const tex = new Texture(texturePath, this.scene);
mat.diffuseTexture = tex;
mat.diffuseColor = new Color3(1, 1, 1); // нейтраль — цвет идёт из vertex color
if (blockType.normal) {
try { mat.bumpTexture = new Texture(blockType.normal, this.scene); } catch (e) {}
}
mat.specularColor = new Color3(0.2, 0.2, 0.2);
mat.specularPower = 24;
mat.useVertexColors = true;
return mat;
}
if (texturePath) { if (texturePath) {
const tex = new Texture(texturePath, this.scene); const tex = new Texture(texturePath, this.scene);
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE); tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
@ -439,7 +459,7 @@ export class BlockManager {
* один meshes-prototype на тип блока, тысячи позиций в одном draw call. * один meshes-prototype на тип блока, тысячи позиций в одном draw call.
* Жидкости (water/lava) идут по старому пути у них свой single-surface. * Жидкости (water/lava) идут по старому пути у них свой single-surface.
*/ */
addBlock(x, y, z, blockTypeId) { addBlock(x, y, z, blockTypeId, color) {
const ix = Math.round(x); const ix = Math.round(x);
const iy = Math.round(y); const iy = Math.round(y);
const iz = Math.round(z); const iz = Math.round(z);
@ -449,6 +469,9 @@ export class BlockManager {
const typeDef = getBlockType(blockTypeId); const typeDef = getBlockType(blockTypeId);
const isWater = !!typeDef?.isWater; const isWater = !!typeDef?.isWater;
const isLava = !!typeDef?.isLava; const isLava = !!typeDef?.isLava;
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
const colorable = !!typeDef?.colorable;
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
// Для жидкостей оставляем старую логику: невидимый куб + единый surface // Для жидкостей оставляем старую логику: невидимый куб + единый surface
if (isWater || isLava) { if (isWater || isLava) {
@ -496,6 +519,9 @@ export class BlockManager {
keysArr[idx] = key; keysArr[idx] = key;
this._cellToInst.set(key, { typeId: blockTypeId, idx }); this._cellToInst.set(key, { typeId: blockTypeId, idx });
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости. // Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh. // НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
const meshProxy = { const meshProxy = {
@ -511,6 +537,7 @@ export class BlockManager {
mass: 1, mass: 1,
folderId: null, folderId: null,
_thinIdx: idx, _thinIdx: idx,
color: instColor, // per-instance цвет окрашиваемого блока
}, },
// Минимальные методы, которые ожидает остальной код // Минимальные методы, которые ожидает остальной код
position: new Vector3(ix, iy + 0.5, iz), position: new Vector3(ix, iy + 0.5, iz),
@ -538,6 +565,18 @@ export class BlockManager {
proto.material = material; proto.material = material;
if (isMulti) this._setupSubmeshes(proto); if (isMulti) this._setupSubmeshes(proto);
// Окрашиваемый блок — включаем per-instance color buffer (vertex colors).
const _bt = getBlockType(blockTypeId);
if (_bt && _bt.colorable) {
proto.useVertexColors = true;
proto.hasVertexAlpha = false;
const buf = new Float32Array(this._STUDS_MAX * 4);
// Дефолт-цвет (белый) для всех слотов — иначе невыставленные = чёрные.
buf.fill(1);
proto.thinInstanceSetBuffer('color', buf, 4, false);
this._colorsByProto.set(blockTypeId, buf);
}
proto.checkCollisions = false; // коллизии через thin-instances не работают штатно; proto.checkCollisions = false; // коллизии через thin-instances не работают штатно;
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB). // PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
proto.isPickable = true; proto.isPickable = true;
@ -571,6 +610,44 @@ export class BlockManager {
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */ /** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
setOnProtoCreated(cb) { this._onProtoCreated = cb; } setOnProtoCreated(cb) { this._onProtoCreated = cb; }
/**
* Записать цвет инстанса окрашиваемого блока в color buffer (RGBA float).
* idx индекс thin-instance. hex '#rrggbb'. В batch-режиме обновление
* GPU откладывается (флаг dirty), иначе сразу thinInstanceBufferUpdated.
*/
_setBlockColorAt(blockTypeId, idx, hex) {
const buf = this._colorsByProto.get(blockTypeId);
if (!buf) return;
const c = Color3.FromHexString(hex || '#cccccc');
const o = idx * 4;
buf[o] = c.r; buf[o + 1] = c.g; buf[o + 2] = c.b; buf[o + 3] = 1;
const proto = this._protoMeshes.get(blockTypeId);
if (!proto) return;
if (this._batchMode) {
if (!this._colorDirtyProtos) this._colorDirtyProtos = new Set();
this._colorDirtyProtos.add(blockTypeId);
} else {
try { proto.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
}
}
/**
* Сменить цвет окрашиваемого блока в (x,y,z) на лету (для scene.setColor /
* color-пикера). Возвращает true если блок окрашиваемый и цвет применён.
*/
setBlockColor(x, y, z, hex) {
const key = this._key(Math.round(x), Math.round(y), Math.round(z));
const inst = this._cellToInst.get(key);
if (!inst) return false;
const bt = getBlockType(inst.typeId);
if (!bt || !bt.colorable) return false;
this._setBlockColorAt(inst.typeId, inst.idx, hex);
const mp = this.blocks.get(key);
if (mp && mp.metadata) mp.metadata.color = hex;
this._notifyChange();
return true;
}
/** Установить флаг anchored у блока. */ /** Установить флаг anchored у блока. */
setBlockAnchored(x, y, z, anchored) { setBlockAnchored(x, y, z, anchored) {
const mesh = this.blocks.get(this._key(x, y, z)); const mesh = this.blocks.get(this._key(x, y, z));
@ -767,6 +844,8 @@ export class BlockManager {
canCollide: m.canCollide !== false, canCollide: m.canCollide !== false,
visible: m.visible !== false, visible: m.visible !== false,
mass: m.mass ?? 1, mass: m.mass ?? 1,
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
...(m.color ? { color: m.color } : {}),
}); });
} }
return out; return out;
@ -778,7 +857,7 @@ export class BlockManager {
this._batchMode = true; this._batchMode = true;
try { try {
for (const b of arr) { for (const b of arr) {
const mesh = this.addBlock(b.x, b.y, b.z, b.type); const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color);
if (!mesh) continue; if (!mesh) continue;
if (b.anchored === false) { if (b.anchored === false) {
mesh.metadata.anchored = false; mesh.metadata.anchored = false;
@ -802,6 +881,14 @@ export class BlockManager {
proto.thinInstanceRefreshBoundingInfo(true); proto.thinInstanceRefreshBoundingInfo(true);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
// Финальный refresh color-буферов окрашиваемых блоков (batch).
if (this._colorDirtyProtos) {
for (const typeId of this._colorDirtyProtos) {
const proto = this._protoMeshes.get(typeId);
try { proto?.thinInstanceBufferUpdated('color'); } catch (e) { /* ignore */ }
}
this._colorDirtyProtos.clear();
}
} }
clear() { clear() {

View File

@ -105,6 +105,11 @@ export const BLOCK_TYPES = [
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше), // top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
// bottom = stone. В будущем для одной двери понадобится 6-face формат. // bottom = stone. В будущем для одной двери понадобится 6-face формат.
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`), multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
// === ОКРАШИВАЕМЫЕ (задача 09) — паритет со студией ===
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
'/kubikon-assets/materials/studs_diffuse.png',
{ colorable: true, normal: '/kubikon-assets/materials/studs_normal.png', defaultColor: '#3a7aff' }),
]; ];
/** Все доступные категории в порядке появления. */ /** Все доступные категории в порядке появления. */
@ -120,6 +125,7 @@ export const CATEGORY_COLORS = {
'Кирпич': '#9d4a3a', 'Кирпич': '#9d4a3a',
'Особые': '#9966ff', 'Особые': '#9966ff',
'Природа': '#5a8c3e', 'Природа': '#5a8c3e',
'Окрашиваемые': '#3a7aff',
}; };
/** Найти описание блока по id. */ /** Найти описание блока по id. */

View File

@ -2385,6 +2385,15 @@ export class GameRuntime {
try { try {
const color = payload?.color; const color = payload?.color;
if (typeof color !== 'string') return; if (typeof color !== 'string') return;
// Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager.
const ref = payload?.id;
if (typeof ref === 'string' && ref.startsWith('block:')) {
const parts = ref.slice(6).split(',').map(Number);
if (parts.length === 3 && parts.every(Number.isFinite)) {
this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color);
}
return;
}
const pm = this.scene3d?.primitiveManager; const pm = this.scene3d?.primitiveManager;
if (!pm) return; if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id); const rid = this._resolvePrimitiveId(payload?.id);
@ -3199,7 +3208,8 @@ export class GameRuntime {
if (!this._localToReal) this._localToReal = new Map(); if (!this._localToReal) this._localToReal = new Map();
try { try {
if (kind === 'block') { if (kind === 'block') {
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType); // color — для окрашиваемых блоков (studs-block); иначе игнорируется.
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color);
// Для блоков ref детерминированный, но запоминаем — чтобы при // Для блоков ref детерминированный, но запоминаем — чтобы при
// Stop удалить заспавненные скриптом блоки (см. stop()). // Stop удалить заспавненные скриптом блоки (см. stop()).
if (ref) this._localToReal.set(ref, ref); if (ref) this._localToReal.set(ref, ref);

View File

@ -879,6 +879,85 @@ export const MODEL_TYPES = [
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks', m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
{ targetHeight: 1.5 }), { targetHeight: 1.5 }),
// === ЛЕГО-СЕТ (задача 09) — паритет со студией ===
mc('lego-brick-1x1', 'Лего 1×1', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#e02a2a', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-1x2', 'Лего 1×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 1, color: '#2a6fe0', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-1x4', 'Лего 1×4', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 1, color: '#f0c020', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-2x2', 'Лего 2×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#35ba5c', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-2x4', 'Лего 2×4', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 4, sy: 1, sz: 2, color: '#e07a30', material: 'studs', dy: 0.5 },
]),
mc('lego-brick-2x8', 'Лего 2×8', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 8, sy: 1, sz: 2, color: '#9b5cf0', material: 'studs', dy: 0.5 },
]),
mc('lego-plate-1x1', 'Плита 1×1', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
]),
mc('lego-plate-1x2', 'Плита 1×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 1, color: '#cfd2d6', material: 'studs', dy: 0.175 },
]),
mc('lego-plate-2x2', 'Плита 2×2', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 0.35, sz: 2, color: '#cfd2d6', material: 'studs', dy: 0.175 },
]),
mc('lego-plate-4x4', 'Плита 4×4', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 4, sy: 0.35, sz: 4, color: '#9aa0a6', material: 'studs', dy: 0.175 },
]),
mc('lego-slope-30', 'Скат 30°', 'Лего-сет', [
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 1, sz: 2, color: '#e02a2a', material: 'studs', dy: 0.5 },
]),
mc('lego-slope-45', 'Скат 45°', 'Лего-сет', [
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 2, sz: 2, color: '#2a6fe0', material: 'studs', dy: 1 },
]),
mc('lego-slope-60', 'Скат 60°', 'Лего-сет', [
{ kind: 'primitive', type: 'wedge', sx: 2, sy: 3, sz: 2, color: '#f0c020', material: 'studs', dy: 1.5 },
]),
mc('lego-tree', 'Лего-дерево', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1, sy: 3, sz: 1, color: '#8a5a2b', material: 'studs', dy: 1.5 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 3, color: '#35ba5c', material: 'studs', dy: 4 },
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1.5, sz: 2, color: '#2e9e4c', material: 'studs', dy: 5.5 },
], { targetHeight: 6 }),
mc('lego-bush', 'Лего-куст', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 2, sy: 1, sz: 2, color: '#2e9e4c', material: 'studs', dy: 0.5 },
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: 0.4 },
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#35ba5c', material: 'studs', dy: 1.3, dx: -0.5, dz: 0.3 },
], { targetHeight: 1.8 }),
mc('lego-house-small', 'Лего-дом', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 6, sy: 4, sz: 6, color: '#e02a2a', material: 'studs', dy: 2 },
{ kind: 'primitive', type: 'wedge', sx: 7, sy: 2.5, sz: 7, color: '#2a6fe0', material: 'studs', dy: 5.25 },
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 2.4, sz: 0.3, color: '#f0c020', material: 'studs', dy: 1.2, dz: -3.05 },
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.2, sz: 0.3, color: '#9ad0ff', material: 'studs', dy: 2.6, dz: -3.05, dx: 1.8 },
], { targetHeight: 6.5 }),
mc('lego-car-racer', 'Лего-машина', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 6, sy: 1, sz: 3, color: '#e02a2a', material: 'studs', dy: 0.9 },
{ kind: 'primitive', type: 'cube', sx: 2.5, sy: 1.2, sz: 2.6, color: '#2a6fe0', material: 'studs', dy: 1.9, dx: 0.6 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: 1.6, rz: Math.PI / 2 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: 1.8, dz: -1.6, rz: Math.PI / 2 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: 1.6, rz: Math.PI / 2 },
{ kind: 'primitive', type: 'cylinder', sx: 1, sy: 0.6, sz: 1, color: '#222428', material: 'studs', dy: 0.5, dx: -1.8, dz: -1.6, rz: Math.PI / 2 },
], { targetHeight: 2.5 }),
mc('lego-stairs', 'Лего-ступеньки', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 3, sy: 1, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 0.5, dz: 1.8 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 2, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.0, dz: 0.6 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 3, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 1.5, dz: -0.6 },
{ kind: 'primitive', type: 'cube', sx: 3, sy: 4, sz: 1.2, color: '#cfd2d6', material: 'studs', dy: 2.0, dz: -1.8 },
], { targetHeight: 4 }),
mc('lego-minifig', 'Лего-человечек', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1.4, sy: 0.6, sz: 0.9, color: '#f0c020', material: 'studs', dy: 0.3 },
{ kind: 'primitive', type: 'cube', sx: 1.2, sy: 1.6, sz: 0.8, color: '#2a6fe0', material: 'studs', dy: 1.4 },
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: 0.85 },
{ kind: 'primitive', type: 'cube', sx: 0.4, sy: 1.4, sz: 0.4, color: '#f0c020', material: 'studs', dy: 1.4, dx: -0.85 },
{ kind: 'primitive', type: 'cube', sx: 1.1, sy: 1.0, sz: 0.85, color: '#f5c84a', material: 'studs', dy: 2.7 },
{ kind: 'primitive', type: 'cylinder', sx: 0.9, sy: 0.5, sz: 0.9, color: '#e02a2a', material: 'studs', dy: 3.4 },
], { targetHeight: 3.8 }),
// TOTAL: 644 // TOTAL: 644
]; ];

View File

@ -33,6 +33,30 @@ import { DynamicTexture } from '@babylonjs/core/Materials/Textures/dynamicTextur
import { Texture } from '@babylonjs/core/Materials/Textures/texture'; import { Texture } from '@babylonjs/core/Materials/Textures/texture';
import { getPrimitiveType } from './PrimitiveTypes'; import { getPrimitiveType } from './PrimitiveTypes';
// === Материал «studs» (лего-кружки, задача 09) — паритет со студией ===
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_normal.png';
const STUD_UNIT = 1;
const STUDS_GRID = 4;
const _studsTexCache = new WeakMap();
function _getStudsTextures(scene) {
let c = _studsTexCache.get(scene);
if (!c) {
c = { diffuse: new Texture(STUDS_DIFFUSE_URL, scene), normal: new Texture(STUDS_NORMAL_URL, scene) };
_studsTexCache.set(scene, c);
}
return c;
}
function _studsTiling(type, sx, sy, sz) {
const f = STUD_UNIT * STUDS_GRID;
let u = Math.max(sx, sz) / f;
let v = sy / f;
if (type === 'cylinder') { u = (Math.PI * sx) / f; v = sy / f; }
else if (type === 'sphere') { u = (Math.PI * sx) / f; v = (Math.PI * sy) / f; }
else if (type === 'plane') { u = sx / f; v = sz / f; }
return { u: Math.max(0.25, u), v: Math.max(0.25, v) };
}
export class PrimitiveManager { export class PrimitiveManager {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
@ -121,6 +145,7 @@ export class PrimitiveManager {
name: opts.name || null, name: opts.name || null,
folderId: opts.folderId ?? null, folderId: opts.folderId ?? null,
}; };
mesh._studsDims = { type, sx, sy, sz };
this._applyMaterial(mesh, typeDef, color, material); this._applyMaterial(mesh, typeDef, color, material);
this._applyVisible(mesh, visible, typeDef); this._applyVisible(mesh, visible, typeDef);
// Пользовательская текстура — поверх базового материала. // Пользовательская текстура — поверх базового материала.
@ -431,6 +456,23 @@ export class PrimitiveManager {
mat.emissiveColor = Color3.FromHexString(color || '#888888'); mat.emissiveColor = Color3.FromHexString(color || '#888888');
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
break; break;
case 'studs': {
// Лего-материал (паритет со студией): серая diffuse с кружками
// × цвет меша + normal map. Тайлинг по размеру меша.
const tex = _getStudsTextures(this.scene);
const dt = tex.diffuse.clone();
const nt = tex.normal.clone();
const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 };
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz);
dt.uScale = nt.uScale = tile.u;
dt.vScale = nt.vScale = tile.v;
mat.diffuseTexture = dt;
mat.bumpTexture = nt;
mat.diffuseColor = Color3.FromHexString(color || '#cccccc');
mat.specularColor = new Color3(0.25, 0.25, 0.25);
mat.specularPower = 24;
break;
}
case 'matte': case 'matte':
default: default:
mat.specularColor = new Color3(0, 0, 0); mat.specularColor = new Color3(0, 0, 0);
@ -601,6 +643,7 @@ export class PrimitiveManager {
if (data.mesh.material) { if (data.mesh.material) {
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
} }
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
this._applyMaterial(data.mesh, typeDef, data.color, data.material); this._applyMaterial(data.mesh, typeDef, data.color, data.material);
} }
// Текстуру переприменяем если: сменили саму текстуру, или // Текстуру переприменяем если: сменили саму текстуру, или
@ -614,6 +657,7 @@ export class PrimitiveManager {
if (data.mesh.material) { if (data.mesh.material) {
try { data.mesh.material.dispose(); } catch (e) { /* ignore */ } try { data.mesh.material.dispose(); } catch (e) { /* ignore */ }
} }
data.mesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
this._applyMaterial(data.mesh, typeDef, data.color, data.material); this._applyMaterial(data.mesh, typeDef, data.color, data.material);
} }
@ -717,6 +761,16 @@ export class PrimitiveManager {
catch (e) { /* ignore */ } catch (e) { /* ignore */ }
data.mesh = newMesh; data.mesh = newMesh;
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz };
if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz);
oldMat.diffuseTexture.uScale = tile.u;
oldMat.diffuseTexture.vScale = tile.v;
if (oldMat.bumpTexture) {
oldMat.bumpTexture.uScale = tile.u;
oldMat.bumpTexture.vScale = tile.v;
}
}
} }
/** Удалить инстанс. */ /** Удалить инстанс. */

View File

@ -1410,7 +1410,8 @@ const game = {
if (kind === 'block') { if (kind === 'block') {
const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z); const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z);
const ref = 'block:' + ix + ',' + iy + ',' + iz; const ref = 'block:' + ix + ',' + iy + ',' + iz;
_send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref }); // color — для окрашиваемых блоков (studs-block, задача 09).
_send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color });
if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime);
return ref; return ref;
} }