feat(g20): полный паритет «Имена над врагами» + shim NPC API

JS:
- 3 NPC (Гоблин, Скелет, Орк) через spawnNpc('character-b')
- setLabel над каждым с HP, обновляется при уроне
- ЛКМ → бьём ближайшего врага в радиусе 4 (damage 30)
- onDeath → clearLabel + hit sound + при 0 врагов — победа+confetti

Расширения shim/runtime:
- __rbxl_spawn_npc уже было, добавил api._localToRealNpc Map
- __rbxl_npc_damage(ref, amount) → cmd 'npc.damage'
- __rbxl_set_label(ref, text, color, height) → cmd 'scene.setLabel'
- __rbxl_clear_label(ref) → cmd 'scene.clearLabel'
- __rbxl_npc_on_death(ref, fn) — регистрирует cb. shim слушает global
  event 'npcDeath' (resolveTweenTarget теперь поддерживает kind='npc')
  и зовёт зарегистрированные cb с подходящим ref (local или real).
- GameRuntime.npc.spawn.then синхронизирует _localToRealNpc в Lua-sb.

Lua-скрипт игры 20 (паритет):
- showText 'Победи всех врагов! Кликай по ним'
- 3 спавна с метками HP над головой
- UserInputService.InputBegan MouseButton1 → ближайший враг в r=4 → -30hp
- На смерть: clearLabel + при 0 — Победа + win Sound + confetti
This commit is contained in:
min 2026-06-09 20:34:20 +03:00
parent 3cdbbc5049
commit 8a3405e34a
3 changed files with 142 additions and 17 deletions

View File

@ -1720,26 +1720,73 @@ end)`,
// ИГРА 20 — «Имена врагов» // ИГРА 20 — «Имена врагов»
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
'enemy-names': { 'enemy-names': {
g20_main: `-- === ИГРА «ИМЕНА ВРАГОВ» (Lua) === g20_main: `-- === ИГРА «ИМЕНА НАД ВРАГАМИ» — главный скрипт (Lua) ===
local function addLabel(part, text, color) local UserInputService = game:GetService("UserInputService")
local bb = Instance.new("BillboardGui", part)
bb.Size = UDim2.new(4, 0, 1, 0) __rbxl_show_text("Победи всех врагов! Кликай по ним", 3)
bb.StudsOffset = Vector3.new(0, 2.5, 0)
bb.AlwaysOnTop = true local hitSound = Instance.new("Sound", workspace)
local label = Instance.new("TextLabel", bb) hitSound.SoundId = "hit"; hitSound.Volume = 0.6
label.Size = UDim2.new(1, 0, 1, 0) local winSound = Instance.new("Sound", workspace)
label.BackgroundTransparency = 1 winSound.SoundId = "win"; winSound.Volume = 1
label.Text = text
label.TextColor3 = color or Color3.new(1, 1, 1) -- Данные врагов: имя, позиция, HP
label.TextScaled = true local enemies = {
{ name = "Гоблин", x = -5, z = 3, hp = 60, maxHp = 60, ref = nil },
{ name = "Скелет", x = 4, z = 5, hp = 80, maxHp = 80, ref = nil },
{ name = "Орк", x = 0, z = 8, hp = 100, maxHp = 100, ref = nil },
}
local alive = #enemies
local won = false
-- Спавним всех врагов и метки над ними
for i, e in ipairs(enemies) do
e.ref = __rbxl_spawn_npc("character-b", e.x, 1, e.z, e.name, e.hp, 0)
__rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 3)
-- Callback на смерть NPC
__rbxl_npc_on_death(e.ref, function()
if e._dead then return end
e._dead = true
__rbxl_clear_label(e.ref)
alive = alive - 1
hitSound:Play()
if alive <= 0 and not won then
won = true
winSound:Play()
__rbxl_show_text("Победа! Все враги повержены!", 5)
local px = __rbxl_player_x()
local py = __rbxl_player_y()
local pz = __rbxl_player_z()
__rbxl_spawn_particles("confetti", px, py + 3, pz, 3, 3)
end
end)
end end
for _, child in ipairs(workspace:GetChildren()) do -- Клик по сцене бьём ближайшего врага в радиусе 4
if child:IsA("BasePart") and child.Name:match("^Враг") then UserInputService.InputBegan:Connect(function(input, gp)
addLabel(child, child.Name, Color3.fromRGB(255, 80, 80)) if gp then return end
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end
if won then return end
local px = __rbxl_player_x()
local pz = __rbxl_player_z()
for _, e in ipairs(enemies) do
if not e._dead and e.hp > 0 then
local dx = e.x - px
local dz = e.z - pz
local dist = math.sqrt(dx*dx + dz*dz)
if dist < 4 then
e.hp = e.hp - 30
if e.hp < 0 then e.hp = 0 end
__rbxl_npc_damage(e.ref, 30)
__rbxl_set_label(e.ref, e.name .. " HP: " .. e.hp, "#ff5555", 3)
__rbxl_spawn_particles("sparks", e.x, 2, e.z, 0.4, 1)
hitSound:Play()
break -- бьём только одного за клик
end
end
end end
end end)`,
print("Над врагами появились имена")`,
}, },
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════

View File

@ -1558,6 +1558,30 @@ export class GameRuntime {
const d = tryGet(this.scene3d?.modelManager); const d = tryGet(this.scene3d?.modelManager);
if (d) return { kind: 'model', data: d }; if (d) return { kind: 'model', data: d };
} }
// NPC — для setLabel/clearLabel над NPC.
if (kind === 'npc' || kind == null) {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs) {
let npc = nm.npcs.get(rawId);
if (!npc) {
const n = Number(rawId);
if (Number.isFinite(n)) npc = nm.npcs.get(n);
}
if (npc) {
// Возвращаем npc в формате 'tween-target' с mesh-ссылкой.
return {
kind: 'npc',
data: {
mesh: npc.rootMesh || npc.mesh || npc.rootNode || npc,
rootMesh: npc.rootMesh || npc.rootNode,
x: npc.x ?? npc.position?.x ?? 0,
y: npc.y ?? npc.position?.y ?? 0,
z: npc.z ?? npc.position?.z ?? 0,
},
};
}
}
}
const um = tryGet(this.scene3d?.userModelManager); const um = tryGet(this.scene3d?.userModelManager);
if (um) return { kind: 'userModel', data: um }; if (um) return { kind: 'userModel', data: um };
return null; return null;
@ -2125,6 +2149,13 @@ export class GameRuntime {
// после spawnNpc (follow/moveTo/say) — они ждали // после spawnNpc (follow/moveTo/say) — они ждали
// резолва ref в очереди. // резолва ref в очереди.
this._flushPendingNpcCmds(payload.ref, npcId); this._flushPendingNpcCmds(payload.ref, npcId);
// Также сообщаем Lua-sandbox-ам маппинг, чтобы
// npc.onDeath по локальному ref находил npcId.
for (const sb of this.sandboxes) {
if (sb.api?._localToRealNpc) {
try { sb.api._localToRealNpc.set(payload.ref, 'npc:' + npcId); } catch (_) {}
}
}
} }
// Сообщаем воркеру маппинг localRef → npcId, чтобы // Сообщаем воркеру маппинг localRef → npcId, чтобы
// npc.onDeath по локальному ref находил правильного NPC. // npc.onDeath по локальному ref находил правильного NPC.

View File

@ -1849,6 +1849,7 @@ export function registerRobloxShim(lua, opts) {
// Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать // Возвращает локальный ref (строку 'npc_lua_N'), который можно передавать
// в __rbxl_npc_say(ref, text, duration). // в __rbxl_npc_say(ref, text, duration).
let _nextNpcRef = 0; let _nextNpcRef = 0;
api._localToRealNpc = new Map();
global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => { global.set('__rbxl_spawn_npc', (modelType, x, y, z, name, hp, speed) => {
const ref = 'npc_lua_' + (_nextNpcRef++); const ref = 'npc_lua_' + (_nextNpcRef++);
send('npc.spawn', { send('npc.spawn', {
@ -1868,6 +1869,33 @@ export function registerRobloxShim(lua, opts) {
duration: +duration || 3, duration: +duration || 3,
}); });
}); });
global.set('__rbxl_npc_damage', (ref, amount) => {
send('npc.damage', {
ref: String(ref || ''),
amount: +amount || 0,
});
});
// Метка с именем/HP над NPC или примитивом — паритет с JS scene.setLabel.
global.set('__rbxl_set_label', (ref, text, color, height) => {
send('scene.setLabel', {
ref: String(ref || ''),
text: String(text || ''),
opts: {
color: color || '#ff5555',
height: Number(height) || 3,
},
});
});
global.set('__rbxl_clear_label', (ref) => {
send('scene.clearLabel', { ref: String(ref || '') });
});
// Регистрация коллбэка onDeath для NPC. GameRuntime шлёт globalEvent
// 'npcDeath' с {ref} при смерти. Shim фильтрует по ref и зовёт.
const _npcDeathCbs = new Map(); // ref → fn
global.set('__rbxl_npc_on_death', (ref, fn) => {
if (typeof fn === 'function') _npcDeathCbs.set(String(ref || ''), fn);
});
api._npcDeathCbs = _npcDeathCbs;
// Инвентарь invUI — паритет с JS game.inventory.add(itemId, count). // Инвентарь invUI — паритет с JS game.inventory.add(itemId, count).
// Сначала определяем итем (один раз), потом добавляем. // Сначала определяем итем (один раз), потом добавляем.
const _localInventory = new Map(); const _localInventory = new Map();
@ -2214,6 +2242,25 @@ export function registerRobloxShim(lua, opts) {
if (part && humanoid.Touched) humanoid.Touched.Fire(part); if (part && humanoid.Touched) humanoid.Touched.Fire(part);
} }
} }
// NPC погиб — фейерим registered cb для конкретного локального ref.
if (p.type === 'npcDeath' && p.npcId != null) {
const realRef = 'npc:' + p.npcId;
// Ищем локальный ref по реальному
let localRef = null;
if (api._localToRealNpc) {
for (const [k, v] of api._localToRealNpc.entries()) {
if (v === realRef) { localRef = k; break; }
}
}
// Вызываем все cb с подходящим ref
if (_npcDeathCbs.size > 0) {
for (const [ref, fn] of _npcDeathCbs.entries()) {
if (ref === realRef || ref === localRef) {
_pendingHandlerQueue.push({ fn, args: [] });
}
}
}
}
// GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id} // GUI-клик: GameRuntime шлёт {type:'guiClick', localId, id}
if (p.type === 'guiClick') { if (p.type === 'guiClick') {
const ref = p.localId || p.id; const ref = p.localId || p.id;