fix(studio): self.setLabel, дверь по коду (красивая+радиус), счётчик механик, ссылка на скрипт в консоли

1) Дверь по коду: красивая составная дверь (полотно+рамка+кодовая панель),
   поле ввода появляется ТОЛЬКО когда игрок в радиусе 6м (onTick по дистанции).
2) game.self.setLabel/clearLabel добавлены (кит «Метка с именем» падал
   'setLabel is not a function').
3) Плитка «Готовые механики» в тулбоксе считает киты динамически
   (GAMEPLAY_KITS.length), а не хардкод «12».
4) Консоль: ошибки/логи скриптов привязаны к источнику — справа строки
   кликабельная ссылка «📄 имя скрипта», открывает скрипт в редакторе
   (_log прокидывает scriptId/scriptName).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-05 19:27:09 +03:00
parent 045f892aaa
commit 3bf1e77230
6 changed files with 101 additions and 23 deletions

View File

@ -3124,6 +3124,11 @@ const KubikonEditor = () => {
logs={scriptLogs} logs={scriptLogs}
onClear={() => setScriptLogs([])} onClear={() => setScriptLogs([])}
onClose={() => setConsoleOpen(false)} onClose={() => setConsoleOpen(false)}
onOpenScript={(scriptId) => {
// Открыть скрипт-источник ошибки в редакторе.
try { sceneRef.current?.selection?.selectScript?.(scriptId); } catch (e) {}
openScriptTab(scriptId);
}}
/> />
{/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */} {/* Monaco-редактор скрипта (этап 2.2). Активен когда выбран таб со скриптом. */}

View File

@ -25,7 +25,7 @@ const LEVEL_BG = {
warn: 'rgba(245, 158, 11, 0.12)', warn: 'rgba(245, 158, 11, 0.12)',
}; };
const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => { const ScriptConsole = ({ logs = [], onClear, onClose, visible, onOpenScript }) => {
const listRef = useRef(null); const listRef = useRef(null);
const [copyState, setCopyState] = useState('idle'); const [copyState, setCopyState] = useState('idle');
@ -260,16 +260,38 @@ const ScriptConsole = ({ logs = [], onClear, onClose, visible }) => {
? `3px solid ${LEVEL_COLORS[l.level]}` ? `3px solid ${LEVEL_COLORS[l.level]}`
: '3px solid transparent', : '3px solid transparent',
paddingLeft: 8, paddingLeft: 8,
display: 'flex', alignItems: 'flex-start', gap: 8,
}}> }}>
<span style={{ <span style={{ flex: 1, minWidth: 0 }}>
color: '#94a3b8', marginRight: 10, fontWeight: 700, <span style={{
}}> color: '#94a3b8', marginRight: 10, fontWeight: 700,
{new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8)} }}>
{new Date(l.ts || Date.now()).toLocaleTimeString().slice(0, 8)}
</span>
{l.level === 'error' && <span><Icon name="error" size={14} /></span>}
{l.level === 'warn' && <span><Icon name="warning" size={14} /></span>}
{l.level === 'info' && <span style={{ opacity: 0.7 }}> </span>}
{l.text}
</span> </span>
{l.level === 'error' && <span><Icon name="error" size={14} /></span>} {/* Ссылка на скрипт-источник (клик открывает его). */}
{l.level === 'warn' && <span><Icon name="warning" size={14} /></span>} {l.scriptId && (
{l.level === 'info' && <span style={{ opacity: 0.7 }}> </span>} <button
{l.text} type="button"
onClick={() => onOpenScript?.(l.scriptId)}
title={'Открыть скрипт: ' + (l.scriptName || l.scriptId)}
style={{
flex: '0 0 auto', maxWidth: 160,
background: 'rgba(79,116,255,0.16)',
border: '1px solid rgba(79,116,255,0.3)',
color: '#8aa0ff', borderRadius: 6,
padding: '1px 8px', fontSize: 11, fontWeight: 700,
cursor: 'pointer', whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}
>
📄 {l.scriptName || l.scriptId}
</button>
)}
</div> </div>
)) ))
)} )}

View File

@ -470,7 +470,7 @@ const ToolboxModal = ({
{ id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' }, { id: '3d', label: '3D-объекты', icon: 'cube', desc: '700+ моделей: природа, дома, мебель, NPC' },
{ id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' }, { id: 'fx', label: 'Эффекты', icon: 'sparkles', desc: 'Частицы, лучи, маркеры' },
{ id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' }, { id: '2d', label: '2D-картинки', icon: 'image', desc: 'Иконки и текстуры для интерфейса' },
{ id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: '12 механик: вставил — работает' }, { id: 'gameplay', label: 'Готовые механики', icon: 'zap', desc: `${GAMEPLAY_KITS.length} механик: вставил — работает` },
{ id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' }, { id: 'plugins', label: 'Плагины', icon: 'puzzle', desc: 'Расширения студии' },
{ id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' }, { id: 'audio', label: 'Аудио', icon: 'sound', desc: 'Звуки и музыка' },
]; ];

View File

@ -68,6 +68,7 @@ export class GameRuntime {
start(scripts) { start(scripts) {
this.stop(); this.stop();
this._isRunning = true; this._isRunning = true;
this.scripts = scripts || []; // для привязки логов/ошибок к скрипту
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[GameRuntime] start called with scripts:', scripts); console.log('[GameRuntime] start called with scripts:', scripts);
// Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс), // Phase 6.5: lazy-init физ-мира. Wasm-инициализация асинхронная (~50мс),
@ -1548,7 +1549,13 @@ export class GameRuntime {
/** Команда от Worker'а пришла — применяем на сцене. */ /** Команда от Worker'а пришла — применяем на сцене. */
_handleCommand(scriptId, cmd, payload) { _handleCommand(scriptId, cmd, payload) {
if (cmd === 'log') { if (cmd === 'log') {
this._log(payload?.level || 'info', payload?.text || ''); // Привязываем запись к скрипту-источнику (для ссылки в консоли).
let scriptName = null;
try {
const meta = (this.scripts || []).find(s => s.id === scriptId);
scriptName = meta?.name || scriptId;
} catch (e) { scriptName = scriptId; }
this._log(payload?.level || 'info', payload?.text || '', scriptId, scriptName);
return; return;
} }
if (cmd === 'player.teleport') { if (cmd === 'player.teleport') {
@ -4213,9 +4220,9 @@ export class GameRuntime {
} }
} }
_log(level, text) { _log(level, text, scriptId = null, scriptName = null) {
if (this._onLog) { if (this._onLog) {
try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ } try { this._onLog({ level, text, ts: Date.now(), scriptId, scriptName }); } catch (e) { /* ignore */ }
} }
} }

View File

@ -578,21 +578,51 @@ game.hud.setHpVisible(false);` }],
{ {
id: 'code-door', id: 'code-door',
name: 'Дверь по коду', name: 'Дверь по коду',
desc: 'Поле ввода: введи правильный код (1234) — дверь открывается. (Вики: «Дверь по коду»)', desc: 'Красивая дверь с кодовой панелью: подойди — появится поле ввода, введи код (1234) — откроется. (Вики: «Дверь по коду»)',
icon: 'keypad', category: 'ui', icon: 'keypad', category: 'world',
prims: [{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.5, sy: 4, sz: 3, color: '#5a6478', material: 'metal', name: 'Дверь-код' }], // Красивая дверь (полотно + рамка) + кодовая панель на стене.
prims: [
{ type: 'cube', x: 0, y: 2, z: 0, sx: 0.25, sy: 3.8, sz: 2.6, color: '#5a6478', material: 'metal', name: 'Полотно двери-код' },
{ type: 'cube', x: 0.16, y: 2.6, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери' },
{ type: 'cube', x: 0.16, y: 1.3, z: 0, sx: 0.06, sy: 0.9, sz: 1.8, color: '#79879a', material: 'metal', canCollide: false, name: 'Панель двери низ' },
{ type: 'cube', x: 0.3, y: 2, z: 0.95, sx: 0.15, sy: 0.6, sz: 0.5, color: '#ffd23a', material: 'neon', canCollide: false, name: 'Кодовая панель' },
{ type: 'cube', x: 0, y: 2, z: -1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк левый' },
{ type: 'cube', x: 0, y: 2, z: 1.55, sx: 0.4, sy: 4.2, sz: 0.4, color: '#3a4250', material: 'metal', name: 'Косяк правый' },
{ type: 'cube', x: 0, y: 4.1, z: 0, sx: 0.4, sy: 0.4, sz: 3.5, color: '#3a4250', material: 'metal', name: 'Перемычка' },
],
scripts: [{ attachTo: 'on-target', code: scripts: [{ attachTo: 'on-target', code:
`// Дверь по коду 1234. Поле ввода снизу. `// Дверь по коду 1234. Поле ввода появляется ТОЛЬКО когда игрок рядом.
const CODE = '1234'; const CODE = '1234';
const inp = game.gui.create('textbox', { id:'codein', x:50, y:88, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 });
game.ui.set('codehint', 'Введи код двери (1234) и нажми Enter', {x:50,y:80,anchor:'bottom',color:'#fff',size:16});
let opened = false;
const p0 = game.self.position; const p0 = game.self.position;
const RADIUS = 6;
let opened = false, near = false, inp = null;
game.ui.set('codehint', '', {});
game.onTick(() => {
if (opened) return;
const pl = game.player.position;
const dx = pl.x - p0.x, dz = pl.z - p0.z;
const d = Math.sqrt(dx*dx + dz*dz);
if (d < RADIUS && !near) {
near = true;
inp = game.gui.create('textbox', { id:'codein', x:50, y:86, w:24, h:8, anchor:'center', placeholder:'Код...', textSize:20 });
game.ui.set('codehint', '🔢 Введи код двери и нажми Enter', {x:50,y:78,anchor:'bottom',color:'#fff',size:16});
} else if (d >= RADIUS && near) {
near = false;
if (inp) game.gui.remove('codein');
game.ui.set('codehint', '', {});
}
});
game.gui.onSubmit('codein', (text) => { game.gui.onSubmit('codein', (text) => {
if (opened) return; if (opened) return;
if (String(text).trim() === CODE) { opened = true; game.self.move(p0.x, p0.y-4.2, p0.z); if (String(text).trim() === CODE) {
game.ui.set('codehint', '✓ Открыто!', {x:50,y:80,anchor:'bottom',color:'#36d57a',size:18}); } opened = true;
else game.ui.set('codehint', '✗ Неверный код', {x:50,y:80,anchor:'bottom',color:'#ff5555',size:18}); game.self.move(p0.x, p0.y - 4.2, p0.z); // дверь уезжает вниз (открыта)
game.gui.remove('codein');
game.ui.set('codehint', '✓ Открыто!', {x:50,y:78,anchor:'bottom',color:'#36d57a',size:18});
game.after(2, () => game.ui.set('codehint', '', {}));
} else {
game.ui.set('codehint', '✗ Неверный код', {x:50,y:78,anchor:'bottom',color:'#ff5555',size:18});
}
});` }], });` }],
}, },
{ {

View File

@ -779,6 +779,20 @@ function _buildSelfApi() {
const id = _target.id ?? _target.ref; const id = _target.id ?? _target.ref;
_send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex }); _send('scene.setColor', { kind: k, id, ref: (k && id != null) ? (k + ':' + id) : undefined, color: hex });
}, },
/** Повесить текст-метку над объектом-носителем (имя/HP). */
setLabel(text, opts) {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.setLabel', { ref, text: String(text == null ? '' : text), opts: opts || {} });
},
/** Убрать метку с объекта-носителя. */
clearLabel() {
const k = _target.kind;
const id = _target.id ?? _target.ref;
const ref = (k && id != null) ? (k + ':' + id) : undefined;
_send('scene.clearLabel', { ref });
},
delete() { delete() {
_send('self.delete', { target: _target }); _send('self.delete', { target: _target });
}, },