fix(studio): сундук→счётчик монет через broadcast, удаление точки спавна + фолбэк 0,0

- Киты «Сундук» и «Счётчик монет» связаны через game.broadcast('coins',{add})
  + game.onMessage('coins') — раньше каждый кит в своём worker, счётчик не
  обновлялся (был globalThis, не работает между воркерами).
- Точку спавна теперь МОЖНО удалить: Delete (SelectionManager.deleteSelected
  обрабатывает type==='spawn' → scene.deleteSpawn) + ПКМ в дереве → контекст-
  меню «Навести камеру / Удалить точку спавна».
- Если точка спавна удалена (_spawnEnabled=false) — игрок появляется в
  (0, поверхность+2, 0). Постановка новой точки (setSpawnAtCamera) возвращает.
- spawnEnabled сериализуется в project_data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
min 2026-06-05 02:21:38 +03:00
parent 7242e80602
commit 471af1cdeb
5 changed files with 69 additions and 12 deletions

View File

@ -242,7 +242,7 @@ const HierarchyPanel = ({
onSelectSound, onSelectPlayer, onSelectPlayerProps, onSelectFloor,
guiElements = [], onSelectGui, onCreateGui, onDeleteGui, onRenameGui, onMoveGuiZ, onSetGuiParent,
guiOverlayHidden = false, onToggleGuiOverlay,
floorEnabled = true, onCreateFloor, onDeleteFloor,
floorEnabled = true, onCreateFloor, onDeleteFloor, onDeleteSpawn,
scripts = [], onSelectScript, onCreateScript, onDeleteScript,
onRenameModel, onRenamePrimitive, onRenameScript,
/**
@ -786,8 +786,8 @@ const HierarchyPanel = ({
className={`${cl.item} ${selection?.type === 'spawn' ? cl.itemSelected : ''}`}
onClick={() => onSelectSpawn?.()}
onDoubleClick={() => { onSelectSpawn?.(); onFocusSelection?.(); }}
onContextMenu={(e) => { e.preventDefault(); onSelectSpawn?.(); }}
title="Точка спавна игрока (ПКМ — выбрать и открыть свойства)"
onContextMenu={(e) => { onSelectSpawn?.(); handleContextMenu(e, { type: 'spawn' }); }}
title="Точка спавна игрока (ПКМ — меню, Delete — удалить)"
>
<span className={cl.itemIcon} style={{ display: 'inline-flex' }}><Icon name="flag" size={14} /></span>
<span className={cl.itemLabel}>Точка спавна</span>
@ -1314,6 +1314,21 @@ const HierarchyPanel = ({
<Icon name="delete" size={13} /> Удалить пол
</div>
</>
) : contextMenu.item.type === 'spawn' ? (
<>
<div
className={cl.contextItem}
onClick={() => { onSelectSpawn?.(); onFocusSelection?.(); closeContext(); }}
>
<Icon name="target" size={13} /> Навести камеру
</div>
<div
className={`${cl.contextItem} ${cl.contextDanger}`}
onClick={() => { onDeleteSpawn?.(); closeContext(); }}
>
<Icon name="delete" size={13} /> Удалить точку спавна
</div>
</>
) : contextMenu.item.type === 'script' ? (
<>
<div

View File

@ -3309,6 +3309,11 @@ const KubikonEditor = () => {
// Активируем гизмо «Двигать» чтобы можно было сразу таскать
setGizmoMode('move');
}}
onDeleteSpawn={() => {
sceneRef.current?.deleteSpawn?.();
sceneRef.current?.clearSelection?.();
markDirty();
}}
onSelectLighting={() => {
sceneRef.current?.selection?.selectLighting();
setActiveTool('select');

View File

@ -201,6 +201,9 @@ export class BabylonScene {
// Точка спавна игрока в режиме Play (обновляется setSpawnPoint)
this._spawnPoint = { x: 0, y: 5, z: 0 };
// Есть ли заданная точка спавна. Если игрок её удалил (Delete) — спавн
// в (0, высота, 0). Можно вернуть постановкой новой точки.
this._spawnEnabled = true;
// Модель персонажа для режима Play.
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
@ -2854,6 +2857,21 @@ export class BabylonScene {
}
}
/** Есть ли заданная точка спавна (false → игрок появится в 0,высота,0). */
hasSpawn() { return this._spawnEnabled !== false; }
/**
* «Удалить» точку спавна: прячем маркер и помечаем, что спавна нет.
* В Play игрок появится в (0, безопасная высота, 0). Вернуть точку
* через setSpawnAtCamera() (кнопка «Поставить точку спавна»).
*/
deleteSpawn() {
this._spawnEnabled = false;
this._setSpawnMarkerVisible(false);
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
/**
* Raycast от курсора в сцену.
* Возвращает { mesh, point, normal } либо null если ни во что не попали.
@ -5465,6 +5483,8 @@ export class BabylonScene {
y: Math.max(0, Math.floor(p.y) - 1),
z: Math.round(p.z),
};
this._spawnEnabled = true; // вернуть точку, если была удалена
this._setSpawnMarkerVisible(true);
this._updateSpawnMarker();
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
@ -5851,7 +5871,17 @@ export class BabylonScene {
});
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
this.player.start(this._spawnPoint);
// Если точка спавна удалена — игрок появляется в (0, безопасная высота, 0).
let startPoint = this._spawnPoint;
if (this._spawnEnabled === false) {
let sy = 3;
try {
const surf = this.physics?._sampleRobloxSurface?.(0, 0);
if (surf !== null && surf !== undefined) sy = surf + 2;
} catch (e) { /* ignore */ }
startPoint = { x: 0, y: sy, z: 0 };
}
this.player.start(startPoint);
// Запускаем пользовательские скрипты (этап 2.1).
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
@ -7377,6 +7407,7 @@ export class BabylonScene {
gui: this.guiManager ? this.guiManager.serialize() : [],
inventory: this.inventory ? this.inventory.serialize() : null,
spawnPoint: { ...this._spawnPoint },
spawnEnabled: this._spawnEnabled !== false,
playerModelType: this._playerModelType,
skins: this._skinsConfig ? {
default: this._skinsConfig.default || null,
@ -7799,6 +7830,9 @@ export class BabylonScene {
this._spawnPoint = { ...state.scene.spawnPoint };
this._updateSpawnMarker();
}
// Удалена ли точка спавна (спавн в 0,0 при отсутствии).
this._spawnEnabled = state.scene.spawnEnabled !== false;
this._setSpawnMarkerVisible(this._spawnEnabled);
// === Авто-fix спавна для smooth terrain ===
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".

View File

@ -62,16 +62,15 @@ game.every(8, () => {
{
id: 'currency-counter',
name: 'Счётчик монет',
desc: 'Показывает счётчик монет в углу HUD. Метод game.addCoins(n) прибавляет монеты.',
desc: 'Счётчик монет в углу HUD. Другие механики шлют game.broadcast("coins", {add: N}) — счётчик обновляется.',
icon: 'circle', category: 'ui',
scripts: [{ attachTo: 'global', code:
`// Счётчик монет в HUD
`// Счётчик монет в HUD. Прибавить монеты из любого скрипта:
// game.broadcast('coins', { add: 100 });
let coins = 0;
function showCoins() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); }
showCoins();
// Глобальный помощник: вызывай game.scene.setData('_coins','add',N) или меняй coins из других скриптов.
game.every(0.3, () => showCoins());
globalThis.__addCoins = (n) => { coins += (n||1); showCoins(); };` }],
function show() { game.ui.set('coins', '🪙 ' + coins, { x: 92, y: 6, anchor: 'top', color: '#ffd23a', size: 22 }); }
show();
game.onMessage('coins', (m) => { coins += (m && m.add) ? m.add : 1; show(); });` }],
},
{
id: 'start-pad',
@ -175,11 +174,12 @@ game.after(5, () => game.ui.set('welcome', ''));` }],
{ type: 'cube', x: 0, y: 1.35, z: 0, sx: 1.7, sy: 0.4, sz: 1.3, color: '#d4a843', material: 'metal', name: 'Крышка сундука', canCollide: false },
],
scripts: [{ attachTo: 'on-target', code:
`// Сундук с лутом
`// Сундук с лутом — даёт 100 монет (через счётчик монет, если он добавлен).
let opened = false;
game.self.onInteract(() => {
if (opened) { game.ui.set('loot', 'Сундук уже открыт.', { x: 50, y: 85, anchor: 'bottom', color: '#bbb', size: 16 }); return; }
opened = true;
game.broadcast('coins', { add: 100 }); // обновит «Счётчик монет», если он есть
game.ui.set('loot', '🎁 Ты получил награду: +100 монет!', { x: 50, y: 85, anchor: 'bottom', color: '#ffd23a', size: 18 });
game.after(3, () => game.ui.set('loot', ''));
}, { text: 'Открыть сундук', key: 'f', distance: 4 });` }],

View File

@ -685,6 +685,9 @@ export class SelectionManager {
this.userModelManager.removeInstance(this._selection.instanceId);
} else if (this._selection.type === 'primitive') {
this.primitiveManager.removeInstance(this._selection.id);
} else if (this._selection.type === 'spawn') {
// Удаление точки спавна → игрок будет появляться в (0, высота, 0).
this._scene3d?.deleteSpawn?.();
}
this.clear();
}