Compare commits

..

No commits in common. "f6828aad2cf2a50dc118776019a6f7931442b1f2" and "17b01364572efa17ed3db1dccbc92f27cb3d0e48" have entirely different histories.

13 changed files with 15 additions and 544 deletions

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"version": 1, "scene": {"blocks": [], "models": [], "primitives": [{"id": 1, "type": "cube", "x": 0, "y": 1, "z": 0, "sx": 10, "sy": 1, "sz": 1, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e8e8e8", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Длинный брус", "folderId": null}, {"id": 2, "type": "cube", "x": 0, "y": 1, "z": 4, "sx": 2, "sy": 2, "sz": 2, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#3a7aff", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Куб 2x2", "folderId": null}, {"id": 3, "type": "cube", "x": 6, "y": 1, "z": 2, "sx": 1, "sy": 1, "sz": 6, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e07a30", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Брус по Z", "folderId": null}], "userModels": [], "terrain": null, "robloxTerrain": null, "decorations": [], "folders": [], "gui": [], "inventory": [], "spawnPoint": {"x": 0, "y": 1.7, "z": -6}, "playerModelType": "character-a", "worldSize": 100, "floorEnabled": true, "scripts": []}, "editorCamera": null, "settings": {}, "__devName": "Studs-тест"}

View File

@ -323,9 +323,4 @@ export const GAMES = [
desc: '3D-стрелка-указатель «иди сюда»: дорожка из бегущих шевронов + парящий маркер над целью. Дошёл — стрелка прыгает на следующую.',
mechanics: ['game.fx.pointer', 'preset стрелки', 'setTarget/update', 'onTouch цели'],
previewShot: 'guide-strelka-scene.png', openProjectId: 333, ready: true },
{ id: 'guide-lego', num: 56, group: 'g5', stars: 1, icon: 'cube',
title: 'Лего-полигон — studs материал',
desc: 'Лего-кружки (studs) на блоках и примитивах любого цвета: зелёный пол, оранжевая стена, разноцветные кубы + готовый лего-сет (дерево, дом, машина).',
mechanics: ['material: studs', 'studs-block (цвет на блок)', 'тайлинг по размеру', 'лего-сет моделей'],
previewShot: 'guide-lego-scene.png', openProjectId: 0, ready: true },
];

View File

@ -7900,88 +7900,6 @@ game.after(0.4, () => {
),
},
'guide-lego': {
body: (
<>
<h3 className="lessonH">Что получится</h3>
<p>
Лего-полигон в стиле Roblox: <b>зелёный пол</b> и <b>оранжевая
стена</b> из окрашиваемых блоков, <b>жёлтая ступенька</b>, синий
куб и фиолетовые кубики все с узнаваемой текстурой
<b> «studs»</b> (лего-кружки). В углу готовый <b>лего-сет</b>:
деревья, дом и машина. Один и тот же материал, любой цвет как
настоящий конструктор.
</p>
<Shot src="guide-lego-play.png" wide
caption="Лего-полигон в игре: пол и стена из studs-блоков, разноцветные кубы, готовые лего-дерево/дом/машина в углу." />
<h3 className="lessonH">Чему научишься</h3>
<ul>
<li><b>material: 'studs'</b> лего-текстура на любом примитиве
(куб, сфера, цилиндр), цвет берётся из обычного color;</li>
<li><b>studs-block</b> окрашиваемый блок: один тип, цвет
задаётся на каждый блок (per-instance), тысячи блоков один
draw call;</li>
<li><b>тайлинг по размеру</b> кружки не растягиваются: куб 4×4
покажет 4×4 studs, куб 1×1 один;</li>
<li><b>лего-сет</b> готовые модели (кирпичи, плиты, скаты,
дерево, дом, машина, человечек) из studs-примитивов.</li>
</ul>
<h3 className="lessonH">Шаг 1. Пол и стена из studs-блоков</h3>
<Shot src="guide-lego-scene.png" wide
caption="Сцена в редакторе: зелёный пол и оранжевая стена из окрашиваемых блоков, разноцветные studs-кубы, лего-модели в углу." />
<Step n="1">
В палитре блоков открой категорию <b>«Окрашиваемые»</b> там
блок <kbd className="kbd">Лего-кирпич</kbd> (studs-block). Под
палитрой появится <b>выбор цвета</b>.
</Step>
<Step n="2">
Поставь зелёным цветом большой <b>пол</b> (30×30), затем оранжевым
<b> стену</b> вдоль дальнего края. Цвет каждого блока сохраняется
отдельно стена и пол из одного типа блока.
</Step>
<h3 className="lessonH">Шаг 2. Studs на примитивах</h3>
<Step n="3">
Поставь <b>куб</b> (Примитив cube). В инспекторе справа выбери
материал <b>«Studs»</b> (пятый рядом с Матовый/Металл/Стекло/Неон)
и задай цвет. Готово лего-кружки на всех гранях.
</Step>
<Note>
Размер кружков считается автоматически от размера меша: растяни
куб studs останутся того же масштаба, просто их станет больше.
</Note>
<h3 className="lessonH">Шаг 3. Через код</h3>
<ScriptKind kind="global" />
<Code>{`// Окрашиваемый блок — пол:
for (let x = -15; x < 15; x++)
for (let z = -15; z < 15; z++)
game.scene.spawn('block:studs-block', { x, y: 0, z, color: '#5cba35' });
// Примитив с лего-текстурой:
game.scene.spawn('primitive:cube', {
x: 0, y: 1, z: 0, sx: 2, sy: 2, sz: 2,
color: '#3a7aff', material: 'studs',
});
// Готовая модель из лего-сета:
game.scene.spawn('model:lego-house-small', { x: 10, y: 0, z: -10 });
// Сменить цвет блока на лету:
game.scene.setColor('block:0,0,0', '#ff0000');`}</Code>
<Try>
Собери из <b>лего-кирпичей</b> разных размеров (1×2, 2×4, 2×8) свою
постройку, покрась каждый ряд в свой цвет и поставь сверху
готовую <b>лего-машину</b> из набора.
</Try>
</>
),
},
};
/** Есть ли готовый текст урока для игры с таким id. */

View File

@ -267,7 +267,6 @@ const PRIMITIVE_MATERIALS = [
{ id: 'metal', name: 'Металл' },
{ id: 'glass', name: 'Стекло' },
{ id: 'neon', name: 'Неон' },
{ id: 'studs', name: 'Studs' }, // задача 09 лего-кружки (любой цвет)
];
/** Форматирование массы — без хвоста из десятичных. Целые без запятой. */
@ -317,7 +316,6 @@ const InspectorPanel = ({
const [localSz, setLocalSz] = useState('');
const [localColor, setLocalColor] = useState('#888888');
const [localMaterial, setLocalMaterial] = useState('matte');
const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs
const [localCanCollide, setLocalCanCollide] = useState(true);
const [localVisible, setLocalVisible] = useState(true);
const [localAnchored, setLocalAnchored] = useState(true);
@ -363,7 +361,6 @@ const InspectorPanel = ({
setLocalSz((selection.sz || 1).toFixed(2));
setLocalColor(selection.color || '#888888');
setLocalMaterial(selection.material || 'matte');
setLocalStudDensity(selection.studDensity || 1);
setLocalCanCollide(selection.canCollide !== false);
setLocalVisible(selection.visible !== false);
setLocalAnchored(selection.anchored !== false);
@ -1757,38 +1754,6 @@ const InspectorPanel = ({
</div>
</div>
{/* Размер studs — плотность лего-кружков (только для material studs) */}
{localMaterial === 'studs' && (
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="grid" size={12} /> Размер studs</div>
<div className={cl.row3}>
{[
{ label: 'Крупные', d: 0.5 },
{ label: 'Средние', d: 1 },
{ label: 'Мелкие', d: 2 },
{ label: 'Меньше', d: 4 },
].map(opt => (
<button
key={opt.d}
type="button"
className={cl.smallBtn}
onClick={() => {
setLocalStudDensity(opt.d);
onSetPrimitiveProps?.({ studDensity: opt.d });
}}
style={{
fontWeight: localStudDensity === opt.d ? 700 : 400,
background: localStudDensity === opt.d ? 'var(--accent)' : undefined,
color: localStudDensity === opt.d ? '#fff' : undefined,
}}
>
{opt.label}
</button>
))}
</div>
</div>
)}
{/* Текстура — своя картинка на гранях примитива */}
<div className={cl.section}>
<div className={cl.sectionTitle}>

View File

@ -772,8 +772,6 @@ const KubikonEditor = () => {
const [gizmoMode, setGizmoMode] = useState('select'); // 'select'|'move'|'rotate'|'scale'
const [snapStep, setSnapStep] = useState(1.0);
const [blockCategory, setBlockCategory] = useState(BLOCK_CATEGORIES[0]);
// Цвет для окрашиваемых блоков (studs-block, задача 09).
const [activeBlockColor, setActiveBlockColor] = useState('#3a7aff');
const [modelCategory, setModelCategory] = useState(MODEL_CATEGORIES[0]);
const [search, setSearch] = useState('');
@ -1286,27 +1284,6 @@ const KubikonEditor = () => {
setActiveTool(prev => prev === 'model' ? 'model' : 'select');
});
// DEV-хук (только localhost): грузим готовую тест-сцену через ?dev=<имя>
// fetch /dev-<имя>.json из public/. Работает на ЛЮБОМ id (включая
// существующий проект dev-сцена заменяет его, БД-загрузка пропускается),
// чтобы не упираться в модал «Новая игра». Позволяет тестировать локально
// без БД (S2/прод). На проде неактивен.
const _isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const _devParam = _isLocalhost ? new URLSearchParams(window.location.search).get('dev') : null;
if (_isLocalhost && _devParam) {
fetch('/dev-' + _devParam + '.json')
.then(r => (r.ok ? r.json() : null))
.then(async (parsed) => {
if (!parsed) return;
try {
await sceneRef.current.loadFromState(parsed);
setProjectName('DEV: ' + (parsed.__devName || 'тест-сцена'));
dirtyRef.current = false;
} catch (e) { console.warn('[DEV scene load] failed', e); }
})
.catch(() => {});
}
// Для нового проекта создаём шаблон папок workspace
if (id === 'new') {
try {
@ -1365,8 +1342,7 @@ const KubikonEditor = () => {
}
// Если редактируем существующий проект грузим из БД.
// ПРОПУСКАЕМ при активном DEV-хуке (?dev=) там сцена грузится из файла.
if (id !== 'new' && /^\d+$/.test(id) && !_devParam) {
if (id !== 'new' && /^\d+$/.test(id)) {
(async () => {
try {
// Передаём userId иначе сервер не определит owner для
@ -1602,11 +1578,6 @@ const KubikonEditor = () => {
if (sceneRef.current) sceneRef.current.setActiveBlockType(activeBlockType);
}, [activeBlockType]);
// Цвет окрашиваемого блока в сцену (для studs-block при постановке).
useEffect(() => {
if (sceneRef.current) sceneRef.current.setActiveBlockColor?.(activeBlockColor);
}, [activeBlockColor]);
useEffect(() => {
if (sceneRef.current) sceneRef.current.setActiveModelType(activeModelType);
}, [activeModelType]);
@ -2279,34 +2250,6 @@ const KubikonEditor = () => {
</div>
)}
{/* Color-пикер для окрашиваемых блоков (studs-block, задача 09).
Показываем когда активный блок имеет colorable:true. */}
{paletteTab === 'blocks'
&& BLOCK_TYPES.find(b => b.id === activeBlockType)?.colorable && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 4px', flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, opacity: 0.8 }}>Цвет блока:</span>
{['#e02a2a', '#e07a30', '#f0c020', '#5cba35', '#3a7aff', '#9b5cf0', '#ffffff', '#222428'].map(c => (
<button
key={c}
onClick={() => setActiveBlockColor(c)}
title={c}
style={{
width: 22, height: 22, borderRadius: 5, background: c, cursor: 'pointer',
border: activeBlockColor === c ? '2px solid #fff' : '1px solid rgba(255,255,255,0.25)',
boxShadow: activeBlockColor === c ? '0 0 0 1px var(--accent)' : 'none',
}}
/>
))}
<input
type="color"
value={activeBlockColor}
onChange={e => setActiveBlockColor(e.target.value)}
title="Свой цвет"
style={{ width: 28, height: 24, padding: 0, border: 'none', background: 'none', cursor: 'pointer' }}
/>
</div>
)}
{/* Сетка блоков или моделей в зависимости от вкладки */}
{paletteTab === 'blocks' ? (
<div className={cl.blocksGrid}>

View File

@ -3374,7 +3374,7 @@ export class BabylonScene {
// _computePlacementCell вернёт нецелый y (реальная высота
// поверхности) — округляем, чтобы блок встал ровно в клетку.
const by = Math.round(target.y);
const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType, this._activeBlockColor);
const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType);
this._lastPlacedKey = `${target.x},${by},${target.z}`;
// Авто-выделение поставленного блока. Тени уже работают через proto-меш
// (зарегистрирован в refreshAllShadows и обновляется автоматически).
@ -3506,7 +3506,7 @@ export class BabylonScene {
const key = `${target.x},${target.y},${target.z}`;
if (key === this._lastPlacedKey) return;
if (this.blockManager.hasBlock(target.x, target.y, target.z)) return;
this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType, this._activeBlockColor);
this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType);
this._lastPlacedKey = key;
} else if (tool === 'erase') {
if (pick.mesh?.metadata?.isBlock) {
@ -4876,11 +4876,6 @@ export class BabylonScene {
this._activeBlockType = blockTypeId;
}
/** Цвет для постановки окрашиваемых блоков (studs-block). null = дефолт типа. */
setActiveBlockColor(hex) {
this._activeBlockColor = hex || null;
}
/** Публичный сеттер: выбрать тип модели для постановки. */
setActiveModelType(modelTypeId) {
this._activeModelType = modelTypeId;

View File

@ -94,10 +94,6 @@ export class BlockManager {
this._lavaSurfaceBaseY = null;
this._lavaDirty = false;
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; // макс блоков одного окрашиваемого типа
}
/** Вызывать каждый кадр для анимации воды/лавы. */
@ -363,24 +359,6 @@ export class BlockManager {
const mat = new StandardMaterial(name, this.scene);
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) {}
}
// Сочность (Roblox-look): почти-белая текстура × яркий vertex color,
// specular убран (он белит/тускнит цвет). vertex color (RGBA из
// thin-instance color buffer) умножается на diffuse → цвет насыщенный.
mat.specularColor = new Color3(0, 0, 0);
mat.useVertexColors = true;
return mat;
}
if (texturePath) {
const tex = new Texture(texturePath, this.scene);
tex.updateSamplingMode(Texture.NEAREST_SAMPLINGMODE);
@ -461,7 +439,7 @@ export class BlockManager {
* один meshes-prototype на тип блока, тысячи позиций в одном draw call.
* Жидкости (water/lava) идут по старому пути у них свой single-surface.
*/
addBlock(x, y, z, blockTypeId, color) {
addBlock(x, y, z, blockTypeId) {
const ix = Math.round(x);
const iy = Math.round(y);
const iz = Math.round(z);
@ -471,9 +449,6 @@ export class BlockManager {
const typeDef = getBlockType(blockTypeId);
const isWater = !!typeDef?.isWater;
const isLava = !!typeDef?.isLava;
// Окрашиваемый блок: цвет инстанса (из аргумента или дефолт типа).
const colorable = !!typeDef?.colorable;
const instColor = colorable ? (color || typeDef.defaultColor || '#cccccc') : null;
// Для жидкостей оставляем старую логику: невидимый куб + единый surface
if (isWater || isLava) {
@ -521,9 +496,6 @@ export class BlockManager {
keysArr[idx] = key;
this._cellToInst.set(key, { typeId: blockTypeId, idx });
// Окрашиваемый блок — пишем цвет инстанса в color buffer.
if (colorable) this._setBlockColorAt(blockTypeId, idx, instColor);
// Логический «meshProxy» — объект, имитирующий API mesh для совместимости.
// НЕ создаёт реального меша. Используется selection / removeBlockByMesh.
const meshProxy = {
@ -539,7 +511,6 @@ export class BlockManager {
mass: 1,
folderId: null,
_thinIdx: idx,
color: instColor, // per-instance цвет окрашиваемого блока
},
// Минимальные методы, которые ожидает остальной код
position: new Vector3(ix, iy + 0.5, iz),
@ -567,18 +538,6 @@ export class BlockManager {
proto.material = material;
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 не работают штатно;
// PlayerController в Play-режиме читает blocks Map напрямую (см. PhysicsAABB).
proto.isPickable = true;
@ -610,44 +569,6 @@ export class BlockManager {
/** Подписка для уведомлений о создании prototype-меша (для shadow setup). */
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 у блока. */
setBlockAnchored(x, y, z, anchored) {
const mesh = this.blocks.get(this._key(x, y, z));
@ -844,8 +765,6 @@ export class BlockManager {
canCollide: m.canCollide !== false,
visible: m.visible !== false,
mass: m.mass ?? 1,
// per-instance цвет окрашиваемого блока (studs-block); иначе пропускаем
...(m.color ? { color: m.color } : {}),
});
}
return out;
@ -857,7 +776,7 @@ export class BlockManager {
this._batchMode = true;
try {
for (const b of arr) {
const mesh = this.addBlock(b.x, b.y, b.z, b.type, b.color);
const mesh = this.addBlock(b.x, b.y, b.z, b.type);
if (!mesh) continue;
if (b.anchored === false) {
mesh.metadata.anchored = false;
@ -881,14 +800,6 @@ export class BlockManager {
proto.thinInstanceRefreshBoundingInfo(true);
} 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() {

View File

@ -105,14 +105,6 @@ export const BLOCK_TYPES = [
// top = stone, side = oven (4 стороны с дверцей — лучше чем раньше),
// bottom = stone. В будущем для одной двери понадобится 6-face формат.
multiCube('oven', 'Печь', 'Особые', `${TEX}/stone.png`, `${TEX}/oven.png`, `${TEX}/stone.png`),
// === ОКРАШИВАЕМЫЕ (задача 09) ===
// studs-block: лего-кирпич, цвет задаётся per-instance (vertex color на
// ThinInstance умножается на серую studs-текстуру). colorable:true говорит
// палитре показать color-пикер, BlockManager — включить color buffer.
cube('studs-block', 'Лего-кирпич', 'Окрашиваемые',
'/kubikon-assets/materials/studs_v4_diffuse.png',
{ colorable: true, normal: '/kubikon-assets/materials/studs_v4_normal.png', defaultColor: '#3a7aff' }),
];
/** Все доступные категории в порядке появления. */
@ -129,7 +121,6 @@ export const CATEGORY_COLORS = {
'Особые': '#9966ff',
'Природа': '#5a8c3e',
'Тест': '#3357FF',
'Окрашиваемые': '#3a7aff',
};
/**

View File

@ -2550,16 +2550,6 @@ export class GameRuntime {
try {
const color = payload?.color;
if (typeof color !== 'string') return;
// Окрашиваемый блок (studs-block): ref вида 'block:x,y,z' →
// меняем per-instance цвет через BlockManager.setBlockColor.
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;
if (!pm) return;
const rid = this._resolvePrimitiveId(payload?.id);
@ -2573,10 +2563,6 @@ export class GameRuntime {
if (data.material === 'neon') {
data.mesh.material.emissiveColor = c;
}
// studs — emissive = доля цвета (сочность сохраняется при смене).
if (data.material === 'studs') {
data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45);
}
}
}
} catch (e) {
@ -3397,8 +3383,7 @@ export class GameRuntime {
if (!this._localToReal) this._localToReal = new Map();
try {
if (kind === 'block') {
// color — для окрашиваемых блоков (studs-block); иначе игнорируется.
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color);
this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType);
// Для блоков ref детерминированный, но запоминаем — чтобы при
// Stop удалить заспавненные скриптом блоки (см. stop()).
if (ref) this._localToReal.set(ref, ref);

View File

@ -879,97 +879,6 @@ export const MODEL_TYPES = [
m('pg-fence-planks', 'Забор', 'Площадка', 'nature-kit', 'fence_planks',
{ targetHeight: 1.5 }),
// ============================================================
// === ЛЕГО-СЕТ (задача 09) — готовые модели из studs-примитивов ===
// Все части material:'studs' (лего-кружки). Цвета базовые из набора LEGO.
// ============================================================
// --- Кирпичи (стандартные плотности, высота 1) ---
mc('lego-brick-1x1', 'Лего 1×1', 'Лего-сет', [
{ kind: 'primitive', type: 'cube', sx: 1, sy: 1, sz: 1, color: '#e02a2a', material: 'studs', dy: 0.5 },
], { category: 'Лего-сет' }),
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 },
]),
// --- Плиты (плоские, высота 0.35) ---
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 },
]),
// --- Скаты (наклонные кирпичи через wedge) ---
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 }),
// --- Лего-машина-гонщик (каркас + кабина + 4 колеса) ---
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 }),
// --- Лего-ступеньки (4 ступени разной высоты) ---
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 }),
// --- Лего-человечек (минифигурка для NPC) ---
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
];

View File

@ -19,85 +19,11 @@
* При касании игроком обновляет spawnPoint сцены.
*/
import {
MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight,
MeshBuilder, StandardMaterial, Color3, Vector3, PointLight,
Mesh, VertexData, Texture, DynamicTexture,
} from '@babylonjs/core';
import { getPrimitiveType } from './PrimitiveTypes';
// === Материал «studs» (лего-кружки, задача 09) ===
// Серая diffuse-текстура с сеткой выпуклых кружков (multiply на цвет меша) +
// normal map для иллюзии выпуклости. 1 stud = STUD_UNIT юнитов → тайлинг
// считается из реального размера меша (куб 4×4 покажет 4×4 кружка).
const STUDS_DIFFUSE_URL = '/kubikon-assets/materials/studs_v4_diffuse.png';
const STUDS_NORMAL_URL = '/kubikon-assets/materials/studs_v4_normal.png';
const STUD_UNIT = 1; // 1 круглый stud на 1 юнит размера
const STUDS_GRID = 4; // текстура содержит сетку 4×4 круглых studs
// Кэш текстур на сцену: чтобы не грузить один и тот же PNG для каждого меша.
// Map<scene, { diffuse: Texture, normal: Texture }>. Каждый меш получает свою
// материал-копию (свой цвет/тайлинг), но текстуры шарятся.
const _studsTexCache = new WeakMap();
function _getStudsTextures(scene) {
let c = _studsTexCache.get(scene);
if (!c) {
const diffuse = new Texture(STUDS_DIFFUSE_URL, scene);
const normal = new Texture(STUDS_NORMAL_URL, scene);
c = { diffuse, normal };
_studsTexCache.set(scene, c);
}
return c;
}
/**
* Посчитать тайлинг (uScale/vScale) для studs по размеру меша. Чтобы кружки не
* растягивались: число кружков на грань = размер_грани / STUD_UNIT, делённое на
* число кружков в самой текстуре (STUDS_GRID).
* Для куба/плоскости тайлинг прямой; для сферы/цилиндра приближённый.
*/
function _studsTiling(type, sx, sy, sz, density) {
// density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще).
const d = density && density > 0 ? density : 1;
const f = (STUD_UNIT * STUDS_GRID) / d;
// По умолчанию (cube/plane/wedge) — горизонтальный размер по U, вертикаль по V.
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) };
}
/**
* faceUV для куба со studs КАЖДАЯ грань тайлится по СВОИМ реальным размерам,
* чтобы кружки были одного размера на всех гранях (не растягивались на длинных).
* Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-).
* front/back ширина=sx, высота=sy
* left/right ширина=sz, высота=sy
* top/bottom ширина=sx, высота=sz
* UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте).
*/
function _studsCubeFaceUV(sx, sy, sz, density) {
const d = density && density > 0 ? density : 1;
const f = (STUD_UNIT * STUDS_GRID) / d;
const nx = Math.max(0.25, sx / f); // studs вдоль X
const ny = Math.max(0.25, sy / f); // studs вдоль Y
const nz = Math.max(0.25, sz / f); // studs вдоль Z
// Vector4(u0, v0, u1, v1)
return [
new Vector4(0, 0, nx, ny), // front (z-): X×Y
new Vector4(0, 0, nx, ny), // back (z+): X×Y
new Vector4(0, 0, nz, ny), // right (x+): Z×Y
new Vector4(0, 0, nz, ny), // left (x-): Z×Y
new Vector4(0, 0, nx, nz), // top (y+): X×Z
new Vector4(0, 0, nx, nz), // bottom (y-): X×Z
];
}
export class PrimitiveManager {
constructor(scene) {
this.scene = scene;
@ -138,8 +64,6 @@ export class PrimitiveManager {
const isGlowingGd = isGdKind;
const isGdSpike = typeDef.kind === 'gd_spike';
const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte');
// studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще).
const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1;
// canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции)
const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike;
const visible = opts.visible !== false;
@ -157,7 +81,7 @@ export class PrimitiveManager {
const rotationY = opts.rotationY ?? 0;
const rotationZ = opts.rotationZ ?? 0;
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity);
const mesh = this._createMeshForType(typeDef, id, sx, sy, sz);
mesh.position = new Vector3(x, y, z);
mesh.rotation = new Vector3(rotationX, rotationY, rotationZ);
mesh.isPickable = true;
@ -179,15 +103,13 @@ export class PrimitiveManager {
id, mesh, type, x, y, z, sx, sy, sz,
rotationX, rotationY, rotationZ,
color, material, canCollide, visible, anchored, mass,
textureAsset, studDensity,
textureAsset,
// locked — объект защищён от выделения/перемещения в редакторе
// (Фаза 5.11). На геймплей не влияет.
locked: opts.locked === true,
name: opts.name || null,
folderId: opts.folderId ?? null,
};
// Размеры для тайлинга studs-материала (читается в _applyMaterial).
mesh._studsDims = { type, sx, sy, sz, density: studDensity };
this._applyMaterial(mesh, typeDef, color, material);
this._applyVisible(mesh, visible, typeDef);
// Пользовательская текстура — поверх базового материала.
@ -265,17 +187,13 @@ export class PrimitiveManager {
}
/** Создать базовый mesh нужной формы (без материала). */
_createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) {
_createMeshForType(typeDef, id, sx, sy, sz) {
const name = `prim_${typeDef.id}_${id}`;
switch (typeDef.id) {
case 'cube':
case 'trigger': {
const boxOpts = { width: sx, height: sy, depth: sz };
// studs — per-face UV, чтобы кружки были одного размера на всех
// гранях (не растягивались на длинной стороне).
if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity);
return MeshBuilder.CreateBox(name, boxOpts, this.scene);
}
case 'trigger':
return MeshBuilder.CreateBox(name,
{ width: sx, height: sy, depth: sz }, this.scene);
case 'sphere':
return MeshBuilder.CreateSphere(name,
{ diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene);
@ -515,34 +433,6 @@ export class PrimitiveManager {
mat.emissiveColor = Color3.FromHexString(color || '#888888');
mat.specularColor = new Color3(0, 0, 0);
break;
case 'studs': {
// Лего-материал: почти белая diffuse-текстура с лёгкими кружками
// умножается на цвет меша → цвет остаётся СОЧНЫМ (как в Roblox).
// emissive = доля цвета → цвет «светится», не тускнеет в тени.
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 };
// Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки
// одного размера на всех гранях. Остальные формы — через uScale.
if (dims.type === 'cube' || dims.type === 'trigger') {
dt.uScale = nt.uScale = 1;
dt.vScale = nt.vScale = 1;
} else {
const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density);
dt.uScale = nt.uScale = tile.u;
dt.vScale = nt.vScale = tile.v;
}
mat.diffuseTexture = dt;
mat.bumpTexture = nt;
const sc = Color3.FromHexString(color || '#cccccc');
mat.diffuseColor = sc;
// Сочность: подмешиваем цвет в emissive (45%) — Roblox-look,
// насыщенный даже без прямого света. specular убираем (он белит).
mat.emissiveColor = new Color3(sc.r * 0.45, sc.g * 0.45, sc.b * 0.45);
mat.specularColor = new Color3(0, 0, 0);
break;
}
case 'matte':
default:
mat.specularColor = new Color3(0, 0, 0);
@ -686,12 +576,6 @@ export class PrimitiveManager {
if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; }
if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; }
if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; }
// Плотность studs (мелкие/крупные кружки) — требует пересоздания меша
// (faceUV для куба зашит в геометрию).
if (patch.studDensity !== undefined) {
data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1;
scaleChanged = true;
}
if (scaleChanged) {
// Поскольку MeshBuilder уже создал mesh с базовыми размерами,
// изменения через scaling кажутся правильными. Простой способ —
@ -717,7 +601,6 @@ export class PrimitiveManager {
if (data.mesh.material) {
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);
}
// Текстуру переприменяем если: сменили саму текстуру, или
@ -731,7 +614,6 @@ export class PrimitiveManager {
if (data.mesh.material) {
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);
}
@ -840,7 +722,7 @@ export class PrimitiveManager {
const oldMat = oldMesh.material;
const typeDef = getPrimitiveType(data.type);
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity);
const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz);
newMesh.position = oldPos;
if (oldRot) newMesh.rotation = oldRot;
newMesh.material = oldMat;
@ -853,24 +735,6 @@ export class PrimitiveManager {
catch (e) { /* ignore */ }
data.mesh = newMesh;
newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity };
// studs-материал: пересчитать тайлинг под новый размер меша.
// Куб уже пересоздан с новым faceUV (тайлинг в геометрии) — uScale=1.
// Для остальных форм пересчитываем uScale/vScale по размеру.
if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) {
if (data.type === 'cube' || data.type === 'trigger') {
oldMat.diffuseTexture.uScale = oldMat.diffuseTexture.vScale = 1;
if (oldMat.bumpTexture) oldMat.bumpTexture.uScale = oldMat.bumpTexture.vScale = 1;
} else {
const tile = _studsTiling(data.type, data.sx, data.sy, data.sz, data.studDensity);
oldMat.diffuseTexture.uScale = tile.u;
oldMat.diffuseTexture.vScale = tile.v;
if (oldMat.bumpTexture) {
oldMat.bumpTexture.uScale = tile.u;
oldMat.bumpTexture.vScale = tile.v;
}
}
}
}
/** Удалить инстанс. */
@ -923,8 +787,6 @@ export class PrimitiveManager {
...(d.locked ? { locked: true } : {}),
// id пользовательской текстуры (картинка из AssetManager).
...(d.textureAsset ? { textureAsset: d.textureAsset } : {}),
// Плотность studs (если не 1) — мелкие/крупные кружки.
...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}),
// Параметры лампы (только для type='light', иначе undefined)
...(d.light ? { brightness: d.brightness, range: d.range } : {}),
// Параметр эмиттера (только для type='emitter')

View File

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