fix(studio): враг=NPC+урон, волна бьёт, удалённые скрипты не исполняются, ключ+дверь красивые, тени-acne

1) Враг с HP → R15-NPC (зомби-скин), преследует и бьёт игрока при касании.
2) Волна врагов: враги наносят урон при касании (onTick дистанция → damage).
3) Удалённые скрипты больше не исполняются: _cleanupOrphanScripts при удалении
   объекта (primitive/model/userModel) + перед enterPlayMode чистим скрипты-сироты.
4) Ключ и замок: ключ из примитивов (стержень+кольцо-torus+бородка), дверь
   как дверь-по-E (плавный поворот вокруг петли, только с ключом).
6) Тени-полосы: normalBias 0.005→0.02 (убирает acne-полосы от соседних объектов).

Машина (vehicle:car) — остаётся рантайм-спавном (особенность транспорта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-05 20:12:50 +03:00
parent 26e6306f6e
commit c4d184257b
4 changed files with 75 additions and 28 deletions

View File

@ -3367,13 +3367,17 @@ const KubikonEditor = () => {
}} }}
onDeleteModel={(id) => { onDeleteModel={(id) => {
sceneRef.current?.modelManager?.removeInstance(id); sceneRef.current?.modelManager?.removeInstance(id);
sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection(); sceneRef.current?.clearSelection();
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty(); markDirty();
hierarchyDirtyRef.current = true; hierarchyDirtyRef.current = true;
}} }}
onDeletePrimitive={(id) => { onDeletePrimitive={(id) => {
sceneRef.current?.primitiveManager?.removeInstance(id); sceneRef.current?.primitiveManager?.removeInstance(id);
sceneRef.current?._cleanupOrphanScripts?.();
sceneRef.current?.clearSelection(); sceneRef.current?.clearSelection();
setScriptsList(sceneRef.current?.getScripts?.() || []);
markDirty(); markDirty();
hierarchyDirtyRef.current = true; hierarchyDirtyRef.current = true;
}} }}

View File

@ -1701,8 +1701,10 @@ export class BabylonScene {
// "уехавшая" тень на скрине пользователя // "уехавшая" тень на скрине пользователя
// 2026-05-27. 0.005 — золотая середина для // 2026-05-27. 0.005 — золотая середина для
// кубов 1м с прямыми гранями. // кубов 1м с прямыми гранями.
const PCF_BIAS = 0.0005; const PCF_BIAS = 0.0008;
const PCF_NORMAL_BIAS = 0.005; // normalBias повышен 0.005→0.02: убирает «полосы»-acne на полу, которые
// появлялись от теней соседних объектов (на пустой сцене их не было).
const PCF_NORMAL_BIAS = 0.02;
if (!this._shadowGenerator) { if (!this._shadowGenerator) {
if (wantCsm) { if (wantCsm) {
@ -6183,6 +6185,9 @@ export class BabylonScene {
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
// Перед стартом чистим скрипты-сироты (их объект-носитель удалён) —
// иначе on-target скрипт удалённого объекта продолжал исполняться. Баг 2026-06-05.
this._cleanupOrphanScripts?.();
// Старт через requestAnimationFrame — даём Babylon собрать сцену // Старт через requestAnimationFrame — даём Babylon собрать сцену
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);

View File

@ -760,18 +760,22 @@ game.self.onClick(() => {
{ {
id: 'enemy-hp', id: 'enemy-hp',
name: 'Враг с HP', name: 'Враг с HP',
desc: 'Враг с полоской здоровья над головой. Кликай — урон, при нуле HP погибает. (Вики: «Имена над врагами», «босс»)', desc: 'Враг-персонаж: преследует игрока, бьёт при касании. Над головой — полоска здоровья. (Вики: «босс», «имена над врагами»)',
icon: 'boss', category: 'npc', icon: 'boss', category: 'npc',
prims: [{ type: 'cube', x: 0, y: 1.5, z: 0, sx: 1.5, sy: 3, sz: 1.5, color: '#7a2030', material: 'matte', name: 'Враг' }], // Невидимый триггер-якорь; рядом спавнится NPC-враг.
prims: [{ type: 'cube', x: 0, y: 1, z: 0, sx: 1, sy: 2, sz: 1, color: '#7a2030', material: 'matte', visible: false, canCollide: false, name: 'Якорь врага' }],
scripts: [{ attachTo: 'on-target', code: scripts: [{ attachTo: 'on-target', code:
`// Враг с HP: клик → -20 HP, метка обновляется, при 0 — исчезает. `// Враг-персонаж: спавним NPC, он преследует игрока и бьёт при касании.
let hp = 100; const p = game.self.position;
function lbl(){ game.self.setLabel('Враг ❤ ' + hp, { color:'#fff', bg:'#7a2030' }); } const enemy = game.scene.spawnNpc('skin_retro-zombie', { x: p.x, z: p.z, name: 'Враг', hp: 100, speed: 3 });
lbl(); if (enemy && enemy.follow) enemy.follow('player');
game.self.onClick(() => { let cd = 0;
hp -= 20; if (hp <= 0) { game.self.setVisible(false); game.broadcast('score', { add: 50 }); game.onTick((dt) => {
game.ui.set('kill', '💀 Враг повержен! +50', { x:50, y:75, anchor:'bottom', color:'#ffd23a', size:18 }); return; } if (!enemy || !enemy.position) return;
lbl(); cd -= dt;
const pl = game.player.position, e = enemy.position;
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
if (d < 2.5 && cd <= 0) { game.player.damage(10); cd = 1; } // удар раз в секунду
});` }], });` }],
}, },
{ {
@ -781,13 +785,25 @@ game.self.onClick(() => {
icon: 'zombie', category: 'npc', icon: 'zombie', category: 'npc',
prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }], prims: [{ type: 'cylinder', x: 0, y: 0.15, z: 0, sx: 2, sy: 0.3, sz: 2, color: '#7a2030', material: 'neon', name: 'Портал врагов' }],
scripts: [{ attachTo: 'on-target', code: scripts: [{ attachTo: 'on-target', code:
`// Каждые 5с спавнит 2 врагов из точки портала, они идут к игроку. `// Каждые 5с спавнит 2 врагов из портала, они идут к игроку И бьют при касании.
const p = game.self.position; const p = game.self.position;
const enemies = [];
function wave(){ function wave(){
for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_roblox-noob', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', speed:3 }); for (let i=0;i<2;i++){ const e = game.scene.spawnNpc('skin_retro-zombie', { x:p.x+(Math.random()-0.5)*2, z:p.z+(Math.random()-0.5)*2, name:'Враг', hp:60, speed:3 });
if (e && e.follow) e.follow('player'); } if (e){ if (e.follow) e.follow('player'); enemies.push({ npc:e, cd:0 }); } }
} }
game.after(2, wave); game.every(5, wave);` }], game.after(2, wave); game.every(5, wave);
// Урон игроку при касании любого врага (кулдаун у каждого свой).
game.onTick((dt) => {
const pl = game.player.position;
for (const en of enemies){
if (!en.npc || !en.npc.position) continue;
en.cd -= dt;
const e = en.npc.position;
const d = Math.sqrt((pl.x-e.x)**2 + (pl.z-e.z)**2);
if (d < 2.5 && en.cd <= 0){ game.player.damage(8); en.cd = 1; }
}
});` }],
}, },
// --- Экономика --- // --- Экономика ---
@ -827,29 +843,48 @@ game.gui.onClick('clickbtn', () => { n++; show(); });` }],
{ {
id: 'key-lock', id: 'key-lock',
name: 'Ключ и замок', name: 'Ключ и замок',
desc: 'Подбери ключ, затем открой запертую дверь. Без ключа дверь не открывается. (Вики: «Ключ и сундук»)', desc: 'Найди золотой ключ, подбери — и дверь рядом плавно откроется по E. Без ключа заперта. (Вики: «Ключ и сундук»)',
icon: 'key', category: 'economy', icon: 'key', category: 'economy',
prims: [ prims: [
{ type: 'cube', x: 0, y: 1, z: 0, sx: 0.4, sy: 0.8, sz: 0.4, color: '#ffd23a', material: 'metal', name: 'Ключ' }, // Ключ из примитивов: стержень + бородка + кольцо (torus). ПЕРВЫЙ — скрипт на нём.
{ type: 'cube', x: 6, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#6b4423', material: 'matte', name: 'Запертая дверь' }, { type: 'cylinder', x: 0, y: 1, z: 0, sx: 0.12, sy: 1.0, sz: 0.12, color: '#ffd23a', material: 'metal', name: 'Ключ' },
{ type: 'torus', x: 0, y: 1.6, z: 0, sx: 0.5, sy: 0.5, sz: 0.5, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Кольцо ключа' },
{ type: 'cube', x: 0.18, y: 0.6, z: 0, sx: 0.3, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа' },
{ type: 'cube', x: 0.18, y: 0.4, z: 0, sx: 0.2, sy: 0.12, sz: 0.12, color: '#ffd23a', material: 'metal', canCollide: false, name: 'Бородка ключа 2' },
// Красивая дверь (полотно + рамка) на расстоянии.
{ type: 'cube', x: 6, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#6b4423', material: 'matte', name: 'Запертая дверь' },
{ type: 'cube', x: 6, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк левый замка' },
{ type: 'cube', x: 6, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#4a3018', material: 'matte', name: 'Косяк правый замка' },
{ type: 'cube', x: 6, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#4a3018', material: 'matte', name: 'Перемычка замка' },
], ],
scripts: [{ attachTo: 'on-target', code: scripts: [{ attachTo: 'on-target', code:
`// Ключ (этот объект) подбирается касанием → открывает «Запертую дверь». `// Ключ подбирается касанием. Дверь рядом открывается по E ТОЛЬКО с ключом —
// плавный поворот вокруг петли (как дверь по кнопке E).
let hasKey = false; let hasKey = false;
const keyParts = ['Ключ','Кольцо ключа','Бородка ключа','Бородка ключа 2'];
game.self.onTouch(() => { game.self.onTouch(() => {
if (hasKey) return; hasKey = true; if (hasKey) return; hasKey = true;
game.self.setVisible(false); for (const nm of keyParts){ const o = game.scene.findOne(nm); if (o) o.visible = false; }
game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18}); game.ui.set('key', '🔑 Ключ найден! Иди к двери (E).', {x:50,y:80,anchor:'bottom',color:'#ffd23a',size:18});
}); });
const door = game.scene.findOne('Запертая дверь'); const door = game.scene.findOne('Запертая дверь');
if (door && door.onInteract) { if (door && door.onInteract){
let opened = false; const dp0 = door.position;
const halfW = 1.3, hingeZ = dp0.z - halfW;
let open = false, cur = 0, target = 0;
function rotY(lx,lz,a){ const s=Math.sin(a),c=Math.cos(a); return {x:lx*c+lz*s, z:-lx*s+lz*c}; }
game.onTick((dt) => {
if (cur===target) return;
const st = Math.PI*dt; cur = Math.abs(target-cur)<=st ? target : cur+Math.sign(target-cur)*st;
const pc = rotY(0, halfW, cur);
door.move(dp0.x+pc.x, dp0.y, hingeZ+pc.z); if (door.rotate) door.rotate(cur);
});
door.onInteract(() => { door.onInteract(() => {
if (!hasKey) { game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; } if (!hasKey){ game.ui.set('key','🔒 Заперто. Нужен ключ.',{x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); return; }
if (opened) return; opened = true; open = !open; target = open ? Math.PI/2 : 0;
const dp = door.position; door.move(dp.x, dp.y-4.2, dp.z); game.ui.set('key', open ? '✓ Дверь открыта!' : '🔒 Дверь закрыта.', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18});
game.ui.set('key','✓ Дверь открыта!',{x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); game.after(2, () => game.ui.set('key','',{}));
}, { text:'Открыть дверь', key:'e', distance:4 }); }, { text:'Открыть / закрыть', key:'e', distance:4 });
}` }], }` }],
}, },

View File

@ -717,10 +717,13 @@ export class SelectionManager {
this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ); this.blockManager.removeBlock(this._selection.gridX, this._selection.gridY, this._selection.gridZ);
} else if (this._selection.type === 'model') { } else if (this._selection.type === 'model') {
this.modelManager.removeInstance(this._selection.instanceId); this.modelManager.removeInstance(this._selection.instanceId);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'userModel') { } else if (this._selection.type === 'userModel') {
this.userModelManager.removeInstance(this._selection.instanceId); this.userModelManager.removeInstance(this._selection.instanceId);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'primitive') { } else if (this._selection.type === 'primitive') {
this.primitiveManager.removeInstance(this._selection.id); this.primitiveManager.removeInstance(this._selection.id);
this.clear(); this._scene3d?._cleanupOrphanScripts?.(); return;
} else if (this._selection.type === 'spawn') { } else if (this._selection.type === 'spawn') {
// Удаление точки спавна → игрок будет появляться в (0, высота, 0). // Удаление точки спавна → игрок будет появляться в (0, высота, 0).
this._scene3d?.deleteSpawn?.(); this._scene3d?.deleteSpawn?.();