feat(g21): полный паритет «Преследователь» + npc.follow/stop/pos в shim

JS:
- spawnNpc 'Охотник' speed=4 follow('player')
- onTick: dist(player,enemy) < 1.6 → respawn + 'Пойман!' + lose
- onMessage 'win' → enemy.stop() + 'Победа!' + win + confetti
- g21_finish: onTouch → broadcast 'win'

Lua (паритет):
- __rbxl_show_text + Sounds
- __rbxl_spawn_npc('character-b', ..., 'Охотник', 100, 4)
- __rbxl_npc_follow(ref, 'player') — велим NPC следовать за игроком
- Heartbeat: __rbxl_npc_x/z для расстояния, при <1.6 → LoadCharacter
  + 'Пойман!' + lose Sound (с throttle 2с)
- BindableEvent WinReached + g21_finish.Touched → ev:Fire
- При победе: npc_stop + showText + win + confetti

Shim хелперы:
- __rbxl_npc_follow(ref, target='player')
- __rbxl_npc_stop(ref)
- __rbxl_npc_x/y/z(ref) — позиция NPC
- api.updateNpcPos(localRef, x, y, z) — GameRuntime синкает каждый кадр

GameRuntime.tick собирает позиции всех NPC из npcManager.npcs через
_localToReal и шлёт sb.api.updateNpcPos.
This commit is contained in:
min 2026-06-09 21:23:53 +03:00
parent d7478fe311
commit ea1308d539
3 changed files with 109 additions and 16 deletions

View File

@ -1784,28 +1784,76 @@ end`,
// ИГРА 21 — «Догонялки»
// ═══════════════════════════════════════════════════════════════
'chaser': {
g21_main: `-- === ИГРА «ДОГОНЯЛКИ» (Lua) ===
g21_main: `-- === ИГРА «ПРЕСЛЕДОВАТЕЛЬ» — главный скрипт (Lua) ===
${SNIPPET_BROADCAST}
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local player = Players.LocalPlayer
local won = false
local enemy = workspace:WaitForChild("Догонщик")
__rbxl_show_text("Убегай от врага! Добеги до укрытия!", 3)
RunService.Heartbeat:Connect(function(dt)
local player = Players:GetPlayers()[1]
if not player or not player.Character then return end
local target = player.Character:FindFirstChild("HumanoidRootPart")
if not target then return end
-- Двигаемся в сторону игрока со скоростью 5
local dir = (target.Position - enemy.Position)
if dir.Magnitude > 1 then
enemy.Position = enemy.Position + dir.Unit * 5 * dt
else
-- Поймал
local h = player.Character:FindFirstChild("Humanoid")
if h then h:TakeDamage(10) end
local loseSound = Instance.new("Sound", workspace)
loseSound.SoundId = "lose"; loseSound.Volume = 0.7
local winSound = Instance.new("Sound", workspace)
winSound.SoundId = "win"; winSound.Volume = 1
-- Спавним NPC-преследователя (speed=4, follow за игроком)
local enemyRef = __rbxl_spawn_npc("character-b", 0, 1, -3, "Охотник", 100, 4)
-- Велим NPC следовать за игроком
__rbxl_npc_follow(enemyRef, "player")
-- Каждый кадр проверяем не догнал ли враг
local lastCaughtTime = 0
RunService.Heartbeat:Connect(function()
if won then return end
local px = __rbxl_player_x()
local pz = __rbxl_player_z()
local ex = __rbxl_npc_x(enemyRef)
local ez = __rbxl_npc_z(enemyRef)
-- Если позиции NPC ещё не пришли (ex=0,ez=0 = до спавна) пропускаем
if ex == 0 and ez == 0 then return end
local dx = px - ex
local dz = pz - ez
local dist = math.sqrt(dx*dx + dz*dz)
if dist < 1.6 then
local now = tick()
if now - lastCaughtTime > 2 then
lastCaughtTime = now
player:LoadCharacter()
__rbxl_show_text("Пойман! Беги снова!", 2)
loseSound:Play()
end
end
end)
print("Убегай от догонщика!")`,
-- Финиш сообщает о победе
local winEvent = getEvent("WinReached")
winEvent.Event:Connect(function()
if won then return end
won = true
__rbxl_npc_stop(enemyRef)
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)`,
g21_finish: `-- === Скрипт финиша (Lua) ===
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local part = script.Parent
local fired = false
part.Touched:Connect(function(hit)
if fired then return end
local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid")
if not h then return end
fired = true
local ev = ReplicatedStorage:FindFirstChild("WinReached")
if ev then ev:Fire() end
end)`,
},
// ═══════════════════════════════════════════════════════════════

View File

@ -986,6 +986,20 @@ export class GameRuntime {
}
}
} catch (_) {}
// Собираем позиции NPC для Lua-shim
const npcPositions = [];
try {
const nm = this.scene3d?.npcManager;
if (nm && nm.npcs && this._localToReal) {
// localRef ('npc_lua_N') → реальный 'npc:<id>' → npc
for (const [localRef, realRef] of this._localToReal.entries()) {
if (typeof realRef !== 'string' || !realRef.startsWith('npc:')) continue;
const npcId = Number(realRef.slice(4));
const npc = nm.npcs.get(npcId);
if (npc) npcPositions.push([localRef, npc.x, npc.y, npc.z]);
}
}
} catch (_) {}
for (const sb of this.sandboxes) {
// Обновляем реальную позицию игрока для Lua-shim
if (realPos && sb.api?.updatePlayerPos) {
@ -997,6 +1011,12 @@ export class GameRuntime {
try { sb.api.updateSpawnedPos(id, x, y, z); } catch (_) {}
}
}
// Синк позиций NPC
if (npcPositions.length > 0 && sb.api?.updateNpcPos) {
for (const [ref, x, y, z] of npcPositions) {
try { sb.api.updateNpcPos(ref, x, y, z); } catch (_) {}
}
}
// Для скриптов с target — добавляем актуальную позицию self
const stateForSb = sb.target
? { ...state, selfPosition: this._collectSelfPosition(sb.target) }

View File

@ -1869,6 +1869,27 @@ export function registerRobloxShim(lua, opts) {
duration: +duration || 3,
});
});
global.set('__rbxl_npc_follow', (ref, targetRef) => {
send('npc.follow', {
ref: String(ref || ''),
target: String(targetRef || 'player'),
});
});
global.set('__rbxl_npc_stop', (ref) => {
send('npc.stop', { ref: String(ref || '') });
});
// Позиция NPC — резолвится через GameRuntime по локальному ref.
// GameRuntime в tick шлёт api.updateNpcPos(localRef, x, y, z).
const _npcPositions = new Map(); // localRef → {x,y,z}
global.set('__rbxl_npc_pos', (ref) => {
const p = _npcPositions.get(String(ref || ''));
if (!p) return { x: 0, y: 0, z: 0, ok: false };
return { x: p.x, y: p.y, z: p.z, ok: true };
});
// Отдельные x/y/z — обходим wasmoon userdata-proxy.
global.set('__rbxl_npc_x', (ref) => (_npcPositions.get(String(ref || ''))?.x ?? 0));
global.set('__rbxl_npc_y', (ref) => (_npcPositions.get(String(ref || ''))?.y ?? 0));
global.set('__rbxl_npc_z', (ref) => (_npcPositions.get(String(ref || ''))?.z ?? 0));
global.set('__rbxl_npc_damage', (ref, amount) => {
send('npc.damage', {
ref: String(ref || ''),
@ -2034,6 +2055,10 @@ export function registerRobloxShim(lua, opts) {
setNpcLocalRef(localRef, realRef) {
_localToRealNpc.set(String(localRef), String(realRef));
},
// GameRuntime каждый кадр обновляет позиции NPC для Lua-скриптов.
updateNpcPos(localRef, x, y, z) {
_npcPositions.set(String(localRef), { x: +x, y: +y, z: +z });
},
onSceneSnapshot(snap) {
try {
const prims = snap?.primitives || [];