diff --git a/src/community/KubikonDocs.jsx b/src/community/KubikonDocs.jsx index 4e0de2a..53fa92a 100644 --- a/src/community/KubikonDocs.jsx +++ b/src/community/KubikonDocs.jsx @@ -14,6 +14,7 @@ import { LESSONS, hasLesson } from './docsLessons'; import { buildGameProject } from './docsGamesBuilders'; import DocIcon from './docsIcons'; import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang'; +import { LUA_OVERRIDES } from './docsGamesBuildersLua'; /** * KubikonDocs — вика редактора Рублокс. @@ -500,14 +501,59 @@ const LessonPage = ({ game, navigate }) => { )} - {/* Тело урока */} + {/* Тело урока с переключателем JS/Lua */}
-
{lesson.body}
+ + + +
{lesson.body}
+
); }; +// При выбранном Lua показывает плашку с готовыми Lua-скриптами для урока +// (если они есть в LUA_OVERRIDES). Скрипты ниже в основном теле остаются +// на JS как референс — Lua-версия здесь сверху для копирования. +const LuaLessonBanner = ({ gameId }) => { + const { lang } = useDocsLang(); + if (lang !== 'lua') return null; + const overrides = LUA_OVERRIDES[gameId]; + if (!overrides) { + return ( +
+ Lua-версия в работе. +

+ Для этого урока пока готова только JS-версия (показана ниже). + Если откроешь копию с языком Lua — получишь скрипт-заглушку + с подсказкой переключить язык в редакторе. +

+
+ ); + } + const entries = Object.entries(overrides); + return ( +
+
+ Готовые Lua-скрипты для этой игры + + Эти скрипты автоматически попадут в твою копию, если откроешь её на Lua. + +
+ {entries.map(([id, codeOrFn]) => { + const code = typeof codeOrFn === 'function' ? codeOrFn({ id }) : codeOrFn; + return ( +
+ {id} +
{code}
+
+ ); + })} +
+ ); +}; + // ══════════════════════════════════════════════════════════════════ // Модалка выбора языка скриптов при «Открыть копию» // ══════════════════════════════════════════════════════════════════ diff --git a/src/community/docsGamesBuilders.js b/src/community/docsGamesBuilders.js index 77b2da3..25e8ba2 100644 --- a/src/community/docsGamesBuilders.js +++ b/src/community/docsGamesBuilders.js @@ -6158,6 +6158,15 @@ export function hasGameBuilder(id) { return typeof GAME_BUILDERS[id] === 'function'; } +// ══════════════════════════════════════════════════════════════════ +// LUA_OVERRIDES — реестр Lua-версий скриптов для уроков. +// Структура: { gameId: { scriptId: 'lua code' | (script) => 'lua code' } } +// Если скрипт описан здесь — при buildGameProject(id, {lang:'lua'}) его +// code будет заменён на Lua-версию. +// См. docsGamesBuildersLua.js для содержимого. +// ══════════════════════════════════════════════════════════════════ +import { LUA_OVERRIDES } from './docsGamesBuildersLua'; + /** Построить project_data для игры-урока. Возвращает объект или null. * opts.lang: 'js' (default) | 'lua' — на каком языке скрипты в копии. */ @@ -6166,26 +6175,30 @@ export function buildGameProject(id, opts = {}) { if (!fn) return null; const project = fn(); if (opts.lang === 'lua' && project) { - // Если в скрипте есть code_lua слот — делаем его активным. - // Иначе ставим stub с заметкой что Lua-версия в работе. const scene = project.scene || {}; if (Array.isArray(scene.scripts)) { + const overrides = LUA_OVERRIDES[id] || {}; scene.scripts = scene.scripts.map(s => { if (s.language === 'lua') return s; - if (s.code_lua && s.code_lua.trim()) { - return { ...s, language: 'lua', code: s.code_lua, code_js: s.code_js || s.code }; + // Приоритет: явный code_lua → override из реестра → stub. + let luaCode = s.code_lua; + if (!luaCode) { + const ov = overrides[s.id]; + if (typeof ov === 'function') luaCode = ov(s); + else if (typeof ov === 'string') luaCode = ov; } - const luaStub = `-- TODO: Lua-версия этого скрипта пока не готова. + if (!luaCode || !luaCode.trim()) { + luaCode = `-- TODO: Lua-версия этого скрипта пока не готова. -- Переключи язык на JS в редакторе (кнопка JS вверху), чтобы увидеть рабочий код. --- Lua-API: game:GetService("Players"), workspace, script.Parent -print("Lua-скрипт запущен (заглушка)") +print("Lua-скрипт " .. (script and script.Name or "?") .. " запущен (заглушка)") `; + } return { ...s, language: 'lua', - code: luaStub, + code: luaCode, code_js: s.code_js || s.code, - code_lua: luaStub, + code_lua: luaCode, }; }); } diff --git a/src/community/docsGamesBuildersLua.js b/src/community/docsGamesBuildersLua.js new file mode 100644 index 0000000..2d7e897 --- /dev/null +++ b/src/community/docsGamesBuildersLua.js @@ -0,0 +1,1024 @@ +/** + * docsGamesBuildersLua.js — Lua-эквиваленты скриптов для уроков. + * + * Структура: LUA_OVERRIDES[gameId][scriptId] = 'lua-code' + * Может быть строкой или функцией (script) => 'lua-code' для случаев, + * когда код зависит от target/name (например, имя примитива). + * + * Когда юзер нажимает «Открыть копию → Lua» в LessonPage, + * buildGameProject(id, {lang:'lua'}) подменяет JS-скрипт на Lua-версию + * отсюда. Геометрия (примитивы, блоки) остаётся той же — отличается + * только язык скриптов. + * + * Lua-код пишется в стандартном Roblox-стиле: + * game:GetService("Players"), workspace, Instance.new, Vector3, CFrame, + * :Connect, RunService.Heartbeat, BindableEvent через ReplicatedStorage. + * + * Конвенции: + * - target=null (глобальный JS-скрипт) → в Lua это просто script в workspace, + * общение через ReplicatedStorage:BindableEvent. + * - target.kind='primitive' (на объекте) → script лежит ВНУТРИ части, + * обращение к ней через script.Parent. Имя части совпадает с тем что + * в JS-builder указано (Монетка_N, Платформа_N и т.д.). + * + * Помощники общего назначения: + * getPlayerFromHit — извлекает Player из hit события Touched. + * getOrCreateEvent — общая BindableEvent в ReplicatedStorage для broadcast. + */ + +// ══════════════════════════════════════════════════════════════════ +// Общие сниппеты — вставляются в начало многих скриптов. +// ══════════════════════════════════════════════════════════════════ +const SNIPPET_BROADCAST = `local ReplicatedStorage = game:GetService("ReplicatedStorage") +local function getEvent(name) + local ev = ReplicatedStorage:FindFirstChild(name) + if not ev then + ev = Instance.new("BindableEvent") + ev.Name = name + ev.Parent = ReplicatedStorage + end + return ev +end`; + +const SNIPPET_PLAYER_HIT = `local Players = game:GetService("Players") +local function getPlayerFromHit(hit) + if not hit or not hit.Parent then return nil end + return Players:GetPlayerFromCharacter(hit.Parent) +end`; + +export const LUA_OVERRIDES = { + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 1 — «Собери монетки» + // ═══════════════════════════════════════════════════════════════ + 'collect-coins': { + g1_main: `-- === ИГРА «СОБЕРИ МОНЕТКИ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local score = 0 +local TOTAL = 8 + +-- Создаём leaderstats для отображения счёта в правом верхнем углу +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player) + stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats) + coins.Name = "Монеты" + coins.Value = 0 +end) +-- Для уже зашедшего LocalPlayer (Рублокс дублирует PlayerAdded при init): +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p) + stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты" + end +end + +print("Собери все монетки!") + +local coinEvent = getEvent("CoinCollected") +coinEvent.Event:Connect(function() + score = score + 1 + for _, p in ipairs(Players:GetPlayers()) do + local stats = p:FindFirstChild("leaderstats") + if stats and stats:FindFirstChild("Монеты") then + stats.Монеты.Value = score + end + end + print("Собрано: " .. score) + if score >= TOTAL then + print("Победа! Все монетки твои!") + end +end)`, + // Скрипт каждой монетки — генератор по script-объекту + g1_coin_1: makeCoinScript(), + g1_coin_2: makeCoinScript(), + g1_coin_3: makeCoinScript(), + g1_coin_4: makeCoinScript(), + g1_coin_5: makeCoinScript(), + g1_coin_6: makeCoinScript(), + g1_coin_7: makeCoinScript(), + g1_coin_8: makeCoinScript(), + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 2 — «Прыгай по платформам» + // ═══════════════════════════════════════════════════════════════ + 'platform-jump': { + g2_main: `-- === ИГРА «ПРЫГАЙ ПО ПЛАТФОРМАМ» — главный скрипт (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +print("Допрыгай до зелёного финиша!") + +-- Каждый кадр проверяем не упал ли игрок +RunService.Heartbeat:Connect(function() + for _, player in ipairs(Players:GetPlayers()) do + local char = player.Character + if char then + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp and hrp.Position.Y < -10 then + -- Респаун + player:LoadCharacter() + print("Упал! Попробуй ещё раз") + end + end + end +end)`, + g2_finish: `-- === Скрипт финишной платформы (Lua) === +local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("ПОБЕДА! Ты допрыгал!") + -- Заморозить + h.WalkSpeed = 0 + h.JumpPower = 0 +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 3 — «Не упади» (платформа сужается) + // ═══════════════════════════════════════════════════════════════ + 'dont-fall': { + g3_main: `-- === ИГРА «НЕ УПАДИ» — главный скрипт (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +print("Удержись на платформе как можно дольше!") + +local startTime = tick() +local alive = true + +RunService.Heartbeat:Connect(function() + if not alive then return end + for _, player in ipairs(Players:GetPlayers()) do + local char = player.Character + if char then + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp and hrp.Position.Y < -5 then + alive = false + local elapsed = math.floor(tick() - startTime) + print("Ты упал! Продержался: " .. elapsed .. " сек") + task.delay(2, function() + player:LoadCharacter() + startTime = tick() + alive = true + end) + end + end + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 4 — «Кнопка и дверь» + // ═══════════════════════════════════════════════════════════════ + 'button-door': { + g4_main: `-- === ИГРА «КНОПКА И ДВЕРЬ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} +print("Найди кнопку и открой дверь!") +-- Глобальный канал для оповещения двери +getEvent("DoorOpen")`, + g4_button: `-- === Скрипт кнопки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("DoorOpen") + if ev then ev:Fire() end + print("Кнопка нажата!") + part.Color = Color3.fromRGB(100, 255, 100) -- зелёная +end)`, + g4_door: `-- === Скрипт двери (Lua) === +local TweenService = game:GetService("TweenService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local door = script.Parent +local startPos = door.Position + +local ev = ReplicatedStorage:WaitForChild("DoorOpen") +ev.Event:Connect(function() + -- Поднимаем дверь вверх + local goal = { Position = startPos + Vector3.new(0, 5, 0) } + TweenService:Create(door, TweenInfo.new(1), goal):Play() + door.CanCollide = false + print("Дверь открыта!") +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 5 — «Лабиринт» + // ═══════════════════════════════════════════════════════════════ + 'maze': { + g5_main: `-- === ИГРА «ЛАБИРИНТ» — главный скрипт (Lua) === +print("Найди выход из лабиринта!")`, + g5_finish: `-- === Финиш лабиринта (Lua) === +local part = script.Parent +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("ПОБЕДА! Ты нашёл выход!") + h.WalkSpeed = 0 +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 6 — «Угадай цвет» + // ═══════════════════════════════════════════════════════════════ + 'color-tiles': { + g6_main: `-- === ИГРА «УГАДАЙ ЦВЕТ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local colors = { "red", "green", "blue", "yellow" } +local target = colors[math.random(1, #colors)] +print("Встань на плитку цвета: " .. target) + +local ev = getEvent("TileStepped") +ev.Event:Connect(function(color) + if color == target then + print("Верно! +1 очко") + target = colors[math.random(1, #colors)] + print("Теперь встань на: " .. target) + else + print("Неверно! Нужен " .. target) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 7 — «Ловишка предметов» + // ═══════════════════════════════════════════════════════════════ + 'catch-falling': { + g7_main: `-- === ИГРА «ЛОВИШКА ПРЕДМЕТОВ» — главный скрипт (Lua) === +local Debris = game:GetService("Debris") + +local score = 0 +local function showScore() + print("Поймано: " .. score) +end +showScore() + +-- Каждую секунду создаём падающий предмет +task.spawn(function() + while true do + task.wait(1) + local ball = Instance.new("Part") + ball.Shape = Enum.PartType.Ball + ball.Size = Vector3.new(1, 1, 1) + ball.Position = Vector3.new(math.random(-8, 8), 20, math.random(-8, 8)) + ball.Color = Color3.fromRGB(255, 215, 0) + ball.Material = Enum.Material.Neon + ball.Anchored = false + ball.Parent = workspace + Debris:AddItem(ball, 10) + + -- Скрипт на ловлю + ball.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h and ball.Parent then + score = score + 1 + showScore() + ball:Destroy() + end + end) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 8 — «Беги до финиша» + // ═══════════════════════════════════════════════════════════════ + 'run-to-finish': { + g8_main: `-- === ИГРА «БЕГИ ДО ФИНИША» — главный скрипт (Lua) === +print("Беги к зелёной плите!")`, + g8_finish: `-- === Финишная плита (Lua) === +local part = script.Parent +local won = false +part.Touched:Connect(function(hit) + if won then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + won = true + print("ПОБЕДА! Ты добежал!") + h.WalkSpeed = 0 +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 9 — «Светофор» + // ═══════════════════════════════════════════════════════════════ + 'traffic-light': { + g9_main: `-- === ИГРА «СВЕТОФОР» — главный скрипт (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +local isGreen = true +print("ЗЕЛЁНЫЙ — беги!") + +-- Каждые 3-5 сек переключаем свет +task.spawn(function() + while true do + task.wait(math.random(3, 5)) + isGreen = not isGreen + if isGreen then + print("ЗЕЛЁНЫЙ — беги!") + else + print("КРАСНЫЙ — стой!") + end + end +end) + +-- Следим за движением игрока во время красного +local lastPos = {} +RunService.Heartbeat:Connect(function() + if isGreen then return end + for _, player in ipairs(Players:GetPlayers()) do + local char = player.Character + local hrp = char and char:FindFirstChild("HumanoidRootPart") + if hrp then + local prev = lastPos[player] + if prev and (hrp.Position - prev).Magnitude > 0.5 then + print(player.Name .. " двигался на красный! Респаун") + player:LoadCharacter() + end + lastPos[player] = hrp.Position + end + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 10 — «Прыжки на пружинах» + // ═══════════════════════════════════════════════════════════════ + 'spring-jump': { + g10_main: `-- === ИГРА «ПРЫЖКИ НА ПРУЖИНАХ» — главный скрипт (Lua) === +print("Прыгай с пружины на пружину до финиша!")`, + g10_spring: `-- === Скрипт пружины (Lua) === +local part = script.Parent +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + local hrp = hit.Parent and hit.Parent:FindFirstChild("HumanoidRootPart") + if h and hrp then + -- Подбрасываем игрока вверх + hrp.Velocity = Vector3.new(hrp.Velocity.X, 80, hrp.Velocity.Z) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 11 — «Эхо» (нажми кнопку → звук) + // ═══════════════════════════════════════════════════════════════ + 'echo-room': { + g11_main: `-- === ИГРА «ЭХО» (Lua) === +print("Касайся блоков — они отвечают звуком!")`, + g11_block: `-- === Скрипт звукового блока (Lua) === +local part = script.Parent +local lastTouch = 0 +part.Touched:Connect(function(hit) + local now = tick() + if now - lastTouch < 0.5 then return end + lastTouch = now + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + print("Блок " .. part.Name .. " звенит!") + part.Color = Color3.fromRGB(math.random(0,255), math.random(0,255), math.random(0,255)) +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 12 — «Кодовая дверь» + // ═══════════════════════════════════════════════════════════════ + 'code-door': { + g12_main: `-- === ИГРА «КОДОВАЯ ДВЕРЬ» — главный скрипт (Lua) === +${SNIPPET_BROADCAST} + +local correctCode = "1234" +local currentInput = "" + +print("Введи код 1234 (касайся кнопок по порядку)") + +local ev = getEvent("CodeButton") +ev.Event:Connect(function(digit) + currentInput = currentInput .. tostring(digit) + print("Ввод: " .. currentInput) + if #currentInput == 4 then + if currentInput == correctCode then + print("Верно! Дверь открывается") + local doorEv = getEvent("DoorOpen") + doorEv:Fire() + else + print("Неверный код, попробуй ещё") + end + currentInput = "" + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 13 — «Торговец» + // ═══════════════════════════════════════════════════════════════ + 'trader': { + g13_main: `-- === ИГРА «ТОРГОВЕЦ» (Lua) === +local Players = game:GetService("Players") + +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 10 +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 10 + end +end +print("У тебя 10 монет. Купи зелье у торговца!")`, + g13_npc: `-- === Скрипт торговца (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +part.Touched:Connect(function(hit) + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if not stats then return end + if stats.Монеты.Value >= 5 then + stats.Монеты.Value = stats.Монеты.Value - 5 + local h = hit.Parent:FindFirstChild("Humanoid") + if h then h.Health = math.min(h.MaxHealth, h.Health + 50) end + print("Купил зелье! +50 HP. Осталось монет: " .. stats.Монеты.Value) + else + print("Не хватает монет! Нужно 5") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 14 — «Собери по тегу» + // ═══════════════════════════════════════════════════════════════ + 'collect-by-tag': { + g14_main: `-- === ИГРА «СОБЕРИ ПО ТЕГУ» (Lua) === +local CollectionService = game:GetService("CollectionService") +${SNIPPET_BROADCAST} + +local total = #CollectionService:GetTagged("звезда") +local collected = 0 +print("Собери все " .. total .. " звёзд!") + +local ev = getEvent("StarCollected") +ev.Event:Connect(function() + collected = collected + 1 + print("Собрано: " .. collected .. "/" .. total) + if collected >= total then + print("ПОБЕДА! Все звёзды собраны!") + end +end)`, + g14_star: `-- === Скрипт звезды (Lua) === +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent +CollectionService:AddTag(part, "звезда") + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("StarCollected") + if ev then ev:Fire() end + part:Destroy() +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 15 — «Тир» + // ═══════════════════════════════════════════════════════════════ + 'shooting-range': { + g15_main: `-- === ИГРА «ТИР» (Lua) === +local UserInputService = game:GetService("UserInputService") +local Players = game:GetService("Players") +${SNIPPET_BROADCAST} + +local score = 0 +print("Стреляй ЛКМ по красным шарам!") + +local ev = getEvent("TargetHit") +ev.Event:Connect(function() + score = score + 1 + print("Попал! Очки: " .. score) +end) + +-- ЛКМ — пускаем луч из камеры +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end + local player = Players.LocalPlayer + local mouse = player:GetMouse() + local target = mouse.Target + if target and target:GetAttribute("IsTarget") then + local hitEv = workspace:FindFirstChild("TargetHit") or ev + ev:Fire() + target:Destroy() + end +end)`, + g15_target: `-- === Скрипт мишени (Lua) === +local part = script.Parent +part:SetAttribute("IsTarget", true)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 16 — «Лавовый пол» + // ═══════════════════════════════════════════════════════════════ + 'lava-floor': { + g16_main: `-- === ИГРА «ЛАВОВЫЙ ПОЛ» (Lua) === +print("Прыгай по островкам, не упади в лаву!")`, + g16_lava: `-- === Скрипт лавы (Lua) === +local part = script.Parent +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h then h.Health = 0 end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 17 — «Ключ от сундука» + // ═══════════════════════════════════════════════════════════════ + 'key-chest': { + g17_main: `-- === ИГРА «КЛЮЧ ОТ СУНДУКА» (Lua) === +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local key = Instance.new("BoolValue", stats); key.Name = "Ключ" +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local key = Instance.new("BoolValue", stats); key.Name = "Ключ" + end +end +print("Найди ключ и открой сундук!")`, + g17_key: `-- === Скрипт ключа (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +part.Touched:Connect(function(hit) + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if stats and stats:FindFirstChild("Ключ") then + stats.Ключ.Value = true + print("Подобрал ключ!") + part:Destroy() + end +end)`, + g17_chest: `-- === Скрипт сундука (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +part.Touched:Connect(function(hit) + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if stats and stats.Ключ and stats.Ключ.Value then + print("Сундук открыт! ПОБЕДА!") + part.Color = Color3.fromRGB(255, 215, 0) + else + print("Нужен ключ!") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 18 — «Качели» + // ═══════════════════════════════════════════════════════════════ + 'swing': { + g18_main: `-- === ИГРА «КАЧЕЛИ» (Lua) === +local TweenService = game:GetService("TweenService") +local swing = workspace:WaitForChild("Качели") +local startPos = swing.Position + +-- Качаем туда-сюда бесконечно +task.spawn(function() + while true do + local up = TweenService:Create(swing, + TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut), + { Position = startPos + Vector3.new(0, 0, 5) }) + up:Play(); up.Completed:Wait() + local down = TweenService:Create(swing, + TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut), + { Position = startPos + Vector3.new(0, 0, -5) }) + down:Play(); down.Completed:Wait() + end +end) +print("Запрыгни на качающуюся платформу!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 19 — «Лифт» + // ═══════════════════════════════════════════════════════════════ + 'elevator': { + g19_main: `-- === ИГРА «ЛИФТ» (Lua) === +local TweenService = game:GetService("TweenService") +local elevator = workspace:WaitForChild("Лифт") +local startPos = elevator.Position +local topPos = startPos + Vector3.new(0, 10, 0) + +local goingUp = true +elevator.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local goal = { Position = goingUp and topPos or startPos } + TweenService:Create(elevator, TweenInfo.new(3), goal):Play() + goingUp = not goingUp +end) +print("Встань на лифт — он повезёт тебя наверх!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 20 — «Имена врагов» + // ═══════════════════════════════════════════════════════════════ + 'enemy-names': { + g20_main: `-- === ИГРА «ИМЕНА ВРАГОВ» (Lua) === +local function addLabel(part, text, color) + local bb = Instance.new("BillboardGui", part) + bb.Size = UDim2.new(4, 0, 1, 0) + bb.StudsOffset = Vector3.new(0, 2.5, 0) + bb.AlwaysOnTop = true + local label = Instance.new("TextLabel", bb) + label.Size = UDim2.new(1, 0, 1, 0) + label.BackgroundTransparency = 1 + label.Text = text + label.TextColor3 = color or Color3.new(1, 1, 1) + label.TextScaled = true +end + +for _, child in ipairs(workspace:GetChildren()) do + if child:IsA("BasePart") and child.Name:match("^Враг") then + addLabel(child, child.Name, Color3.fromRGB(255, 80, 80)) + end +end +print("Над врагами появились имена")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 21 — «Догонялки» + // ═══════════════════════════════════════════════════════════════ + 'chaser': { + g21_main: `-- === ИГРА «ДОГОНЯЛКИ» (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") + +local enemy = workspace:WaitForChild("Догонщик") + +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 + end +end) +print("Убегай от догонщика!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 22 — «Опасная зона» + // ═══════════════════════════════════════════════════════════════ + 'danger-zone': { + g22_main: `-- === ИГРА «ОПАСНАЯ ЗОНА» (Lua) === +print("Не стой в красной зоне!")`, + g22_zone: `-- === Скрипт опасной зоны (Lua) === +local part = script.Parent +local insiders = {} + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h then insiders[h] = true end +end) +part.TouchEnded:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if h then insiders[h] = nil end +end) + +-- Урон каждые 0.5 сек пока стоят +while true do + task.wait(0.5) + for h in pairs(insiders) do + if h.Parent then h:TakeDamage(5) end + end +end`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 23 — «3 переключателя» + // ═══════════════════════════════════════════════════════════════ + 'switches': { + g23_main: `-- === ИГРА «3 ПЕРЕКЛЮЧАТЕЛЯ» (Lua) === +${SNIPPET_BROADCAST} + +local activated = {false, false, false} +print("Активируй все 3 переключателя!") + +local ev = getEvent("SwitchToggled") +ev.Event:Connect(function(idx) + activated[idx] = true + print("Переключатель " .. idx .. " активирован") + if activated[1] and activated[2] and activated[3] then + print("ПОБЕДА! Все 3 активированы!") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 24 — «Падающий мост» + // ═══════════════════════════════════════════════════════════════ + 'falling-bridge': { + g24_main: `-- === ИГРА «ПАДАЮЩИЙ МОСТ» (Lua) === +print("Беги по мосту — плиты падают!")`, + g24_plate: `-- === Скрипт падающей плиты (Lua) === +local part = script.Parent +local fell = false +part.Touched:Connect(function(hit) + if fell then return end + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + fell = true + task.delay(0.5, function() + part.Anchored = false + part.CanCollide = false + game:GetService("Debris"):AddItem(part, 3) + end) +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 25 — «Облёт камеры» + // ═══════════════════════════════════════════════════════════════ + 'flyby-camera': { + g25_main: `-- === ИГРА «ОБЛЁТ КАМЕРЫ» (Lua) === +local TweenService = game:GetService("TweenService") +local camera = workspace.CurrentCamera +camera.CameraType = Enum.CameraType.Scriptable + +local points = { + CFrame.new(Vector3.new(0, 20, -30), Vector3.new(0, 5, 0)), + CFrame.new(Vector3.new(20, 15, 0), Vector3.new(0, 5, 0)), + CFrame.new(Vector3.new(0, 25, 30), Vector3.new(0, 5, 0)), +} + +for _, cf in ipairs(points) do + local tween = TweenService:Create(camera, TweenInfo.new(2), { CFrame = cf }) + tween:Play(); tween.Completed:Wait() +end + +camera.CameraType = Enum.CameraType.Custom +print("Облёт окончен — теперь играй!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 26 — «Магнит монет» + // ═══════════════════════════════════════════════════════════════ + 'coin-magnet': { + g26_main: `-- === ИГРА «МАГНИТ МОНЕТ» (Lua) === +local Players = game:GetService("Players") +local RunService = game:GetService("RunService") +local CollectionService = game:GetService("CollectionService") +${SNIPPET_BROADCAST} + +local score = 0 +local ev = getEvent("CoinCollected") +ev.Event:Connect(function() + score = score + 1 + print("Собрано: " .. score) +end) + +RunService.Heartbeat:Connect(function(dt) + local player = Players:GetPlayers()[1] + if not player or not player.Character then return end + local hrp = player.Character:FindFirstChild("HumanoidRootPart") + if not hrp then return end + for _, coin in ipairs(CollectionService:GetTagged("magnetcoin")) do + local dist = (coin.Position - hrp.Position).Magnitude + if dist < 8 then + coin.Position = coin.Position + (hrp.Position - coin.Position).Unit * 20 * dt + if dist < 1 then + ev:Fire() + coin:Destroy() + end + end + end +end) +print("Монетки сами летят к тебе!")`, + g26_coin: `-- === Скрипт магнит-монетки (Lua) === +local CollectionService = game:GetService("CollectionService") +CollectionService:AddTag(script.Parent, "magnetcoin")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 27 — «Двойной прыжок» + // ═══════════════════════════════════════════════════════════════ + 'double-jump': { + g27_main: `-- === ИГРА «ДВОЙНОЙ ПРЫЖОК» (Lua) === +local Players = game:GetService("Players") +local UserInputService = game:GetService("UserInputService") + +local function setupDoubleJump(player) + local jumpsLeft = 2 + local char = player.Character or player.CharacterAdded:Wait() + local h = char:WaitForChild("Humanoid") + + -- Восстанавливаем прыжки при касании земли + h.StateChanged:Connect(function(_, newState) + if newState == Enum.HumanoidStateType.Landed then + jumpsLeft = 2 + end + end) + + UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if input.KeyCode == Enum.KeyCode.Space and jumpsLeft > 0 then + jumpsLeft = jumpsLeft - 1 + if jumpsLeft == 1 then + local hrp = char:FindFirstChild("HumanoidRootPart") + if hrp then + hrp.Velocity = Vector3.new(hrp.Velocity.X, 50, hrp.Velocity.Z) + end + end + end + end) +end + +Players.PlayerAdded:Connect(setupDoubleJump) +for _, p in ipairs(Players:GetPlayers()) do setupDoubleJump(p) end +print("Жми Space дважды — двойной прыжок!")`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 28 — «Призрачные стены» + // ═══════════════════════════════════════════════════════════════ + 'ghost-walls': { + g28_main: `-- === ИГРА «ПРИЗРАЧНЫЕ СТЕНЫ» (Lua) === +print("Некоторые стены — призрачные. Найди проход!")`, + g28_ghost: `-- === Скрипт призрачной стены (Lua) === +local part = script.Parent +part.CanCollide = false +part.Transparency = 0.5`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 29 — «Магазин» + // ═══════════════════════════════════════════════════════════════ + 'shop': { + g29_main: `-- === ИГРА «МАГАЗИН» (Lua) === +local Players = game:GetService("Players") +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 20 +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local coins = Instance.new("IntValue", stats); coins.Name = "Монеты"; coins.Value = 20 + end +end +print("У тебя 20 монет — купи что-нибудь!")`, + g29_item: `-- === Скрипт товара (Lua) === +local Players = game:GetService("Players") +local part = script.Parent +local price = part:GetAttribute("Price") or 5 +local bought = false + +part.Touched:Connect(function(hit) + if bought then return end + local player = Players:GetPlayerFromCharacter(hit.Parent) + if not player then return end + local stats = player:FindFirstChild("leaderstats") + if not stats then return end + if stats.Монеты.Value >= price then + stats.Монеты.Value = stats.Монеты.Value - price + bought = true + print("Куплено! Цена: " .. price) + part.Transparency = 0.7 + else + print("Не хватает! Нужно " .. price .. " монет") + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 30 — «Квесты» + // ═══════════════════════════════════════════════════════════════ + 'quest-tasks': { + g30_main: `-- === ИГРА «КВЕСТЫ» (Lua) === +${SNIPPET_BROADCAST} + +local quests = { + { name = "Собери 5 ягод", goal = 5, current = 0 }, + { name = "Победи врага", goal = 1, current = 0 }, + { name = "Дойди до башни", goal = 1, current = 0 }, +} + +print("Квесты:") +for i, q in ipairs(quests) do print(" " .. i .. ". " .. q.name) end + +local ev = getEvent("QuestProgress") +ev.Event:Connect(function(idx, amount) + quests[idx].current = quests[idx].current + (amount or 1) + local q = quests[idx] + print(q.name .. ": " .. q.current .. "/" .. q.goal) + if q.current >= q.goal then + print("Квест выполнен: " .. q.name) + end +end)`, + }, + + // ═══════════════════════════════════════════════════════════════ + // ИГРА 31-50: упрощённые версии (главные скрипты) + // ═══════════════════════════════════════════════════════════════ + 'base-defense': { g31_main: simpleMain("Защити базу от волн врагов!") }, + 'lap-race': { g32_main: simpleMain("Проедь все круги первым!") }, + 'boss-platformer': { g33_main: simpleMain("Победи босса прыжками на голову!") }, + 'harvest': { g34_main: simpleMain("Собирай урожай, продавай в магазин!") }, + 'hide-from-npc': { g35_main: simpleMain("Прячься от NPC — не попадайся!") }, + 'box-puzzle': { g36_main: simpleMain("Двигай ящики на места!") }, + 'obstacle-course': { g37_main: simpleMain("Пройди полосу препятствий!") }, + 'music-game': { g38_main: simpleMain("Жми клавиши в ритм музыки!") }, + 'tower-build': { g39_main: simpleMain("Построй самую высокую башню!") }, + 'wave-survival': { g40_main: simpleMain("Выживай в волнах врагов!") }, + 'adventure-platformer': { g41_main: simpleMain("Приключение — собирай артефакты!") }, + 'rpg-village': { g42_main: simpleMain("Бегай по деревне, выполняй квесты!") }, + 'obstacle-race': { g43_main: simpleMain("Гонка с препятствиями — финишируй!") }, + 'tower-defense': { g44_main: simpleMain("Расставь башни — не пускай врагов!") }, + 'arena-shooter': { g45_main: simpleMain("Стреляй по противникам на арене!") }, + 'clicker': { g46_main: simpleClicker() }, + 'escape-quest': { g47_main: simpleMain("Найди подсказки и выберись!") }, + 'mp-tag': { g48_main: simpleMain("Поймай других игроков (мультиплеер)!") }, + 'mp-race': { g49_main: simpleMain("Гонка на нескольких игроков!") }, + 'make-your-own': { g50_main: simpleMain("Это твоя пустая площадка — твори!") }, +}; + +// ══════════════════════════════════════════════════════════════════ +// Хелперы для генерации часто повторяющихся скриптов +// ══════════════════════════════════════════════════════════════════ + +/** Возвращает Lua-код скрипта монетки. */ +function makeCoinScript() { + return `-- === Скрипт монетки (Lua) === +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local part = script.Parent + +part.Touched:Connect(function(hit) + local h = hit.Parent and hit.Parent:FindFirstChild("Humanoid") + if not h then return end + local ev = ReplicatedStorage:FindFirstChild("CoinCollected") + if ev then ev:Fire() end + part:Destroy() +end)`; +} + +/** Простой главный скрипт со стартовой подсказкой. */ +function simpleMain(message) { + return `-- === Главный скрипт (Lua) === +print("${message.replace(/"/g, '\\"')}") +-- TODO: эта игра-урок ещё не имеет полной Lua-реализации. +-- Переключи язык на JS в редакторе, чтобы увидеть рабочую механику.`; +} + +/** Кликер. */ +function simpleClicker() { + return `-- === КЛИКЕР (Lua) === +local Players = game:GetService("Players") +local UserInputService = game:GetService("UserInputService") + +Players.PlayerAdded:Connect(function(player) + local stats = Instance.new("Folder", player); stats.Name = "leaderstats" + local cnt = Instance.new("IntValue", stats); cnt.Name = "Клики"; cnt.Value = 0 +end) +for _, p in ipairs(Players:GetPlayers()) do + if not p:FindFirstChild("leaderstats") then + local stats = Instance.new("Folder", p); stats.Name = "leaderstats" + local cnt = Instance.new("IntValue", stats); cnt.Name = "Клики"; cnt.Value = 0 + end +end + +local function onClick(player) + local stats = player:FindFirstChild("leaderstats") + if stats and stats:FindFirstChild("Клики") then + stats.Клики.Value = stats.Клики.Value + 1 + end +end + +UserInputService.InputBegan:Connect(function(input, gp) + if gp then return end + if input.UserInputType == Enum.UserInputType.MouseButton1 then + onClick(Players.LocalPlayer) + end +end) +print("Кликай ЛКМ — копи клики!")`; +} diff --git a/src/community/docsLang.jsx b/src/community/docsLang.jsx index 394a4b3..a47fa98 100644 --- a/src/community/docsLang.jsx +++ b/src/community/docsLang.jsx @@ -419,4 +419,39 @@ export const DOCS_LANG_STYLES = ` .docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */ .docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */ .docCode .hl-fn { color: #50fa7b; } /* myFunc() */ + +/* ══════════════════════════════════════════════════════════════════ + Баннер «Lua-скрипты для урока» + ══════════════════════════════════════════════════════════════════ */ +.luaLessonBanner { + background: #eef4ff; + border: 1px solid #c7d8f5; + border-radius: 10px; + padding: 14px 18px; + margin: 14px 0 22px; +} +.luaLessonBanner--missing { + background: #fff7e0; + border-color: #f0d599; + color: #5a4500; +} +.luaLessonBanner--missing p { margin: 4px 0 0; font-size: 13px; } +.luaLessonBanner__head { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } +.luaLessonBanner__head b { font-size: 14px; color: #1e3a8a; } +.luaLessonBanner__hint { font-size: 12px; color: #475569; font-style: italic; } +.luaLessonBanner__script { margin: 6px 0; } +.luaLessonBanner__script summary { + cursor: pointer; + padding: 8px 12px; + background: #fff; + border-radius: 6px; + border: 1px solid #d0dcf0; + font-family: Consolas, monospace; + font-size: 13px; + color: #1e3a8a; + font-weight: 600; +} +.luaLessonBanner__script summary:hover { background: #f4f8ff; } +.luaLessonBanner__script[open] summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } +.luaLessonBanner__script pre { margin: 0; border-top-left-radius: 0; border-top-right-radius: 0; } `; diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index c189888..71d8985 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -4285,6 +4285,28 @@ export class GameRuntime { player.maxHp = max; if (player.hp > max) player.hp = max; } catch (_) {} + } else if (payload.prop === 'position') { + // Lua-вызов hrp.Position = ... — телепорт игрока + try { + const v = payload.value || {}; + if (player.body && player.body.position) { + player.body.position.set(v.x || 0, v.y || 0, v.z || 0); + } + } catch (_) {} + } else if (payload.prop === 'respawn') { + // Lua-вызов player:LoadCharacter() — телепорт к spawn и сброс HP + try { + if (typeof player.respawn === 'function') player.respawn(); + else { + const sp = this.scene3d?.projectData?.scene?.spawnPoint + || this.projectData?.scene?.spawnPoint + || { x: 0, y: 5, z: 0 }; + if (player.body && player.body.position) { + player.body.position.set(sp.x, sp.y, sp.z); + } + player.hp = player.maxHp || 100; + } + } catch (_) {} } return; } diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 9663929..22a8174 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -769,7 +769,16 @@ export function registerRobloxShim(lua, opts) { localPlayer.Team = undefined; localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) }; localPlayer.Kick = function () {}; - localPlayer.LoadCharacter = function () {}; + localPlayer.LoadCharacter = function () { + // Респаун: возвращаем HP и шлём команду в плеер на телепорт к spawn. + // Plus сбрасываем humanoid.Health на MaxHealth. + try { + if (humanoid && humanoid.MaxHealth) { + humanoid.Health = humanoid.MaxHealth; + } + send('playerSet', { prop: 'respawn', value: true }); + } catch (_) {} + }; localPlayer.HasAppearanceLoaded = function () { return true; }; // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически // клонируется в Backpack каждого спавнящегося игрока. @@ -896,8 +905,45 @@ export function registerRobloxShim(lua, opts) { const hrp = newInstance('Part', 'HumanoidRootPart'); hrp.Parent = character; - hrp.Position = new RbxVector3(0, 5, 0); + hrp._position = new RbxVector3(0, 5, 0); hrp.Size = new RbxVector3(2, 2, 1); + // Реактивные Position и Velocity — Lua скрипт может задавать. + Object.defineProperty(hrp, 'Position', { + get() { return hrp._position; }, + set(v) { + if (!v) return; + hrp._position = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + try { send('playerSet', { prop: 'position', + value: { x: hrp._position.X, y: hrp._position.Y, z: hrp._position.Z } }); } + catch (_) {} + }, + }); + let _hrpCFrame = null; + Object.defineProperty(hrp, 'CFrame', { + get() { return _hrpCFrame || { Position: hrp._position, p: hrp._position }; }, + set(v) { + if (!v) return; + _hrpCFrame = v; + const pos = v.Position || v.p || v; + if (pos && pos.X !== undefined) { + hrp._position = new RbxVector3(pos.X, pos.Y, pos.Z); + try { send('playerSet', { prop: 'position', + value: { x: pos.X, y: pos.Y, z: pos.Z } }); } + catch (_) {} + } + }, + }); + Object.defineProperty(hrp, 'Velocity', { + get() { return hrp._velocity || new RbxVector3(0, 0, 0); }, + set(v) { + if (!v) return; + hrp._velocity = new RbxVector3(v.X || 0, v.Y || 0, v.Z || 0); + if (v.Y > 10) { + try { send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); } + catch (_) {} + } + }, + }); character.Children.push(hrp); character.HumanoidRootPart = hrp; character.PrimaryPart = hrp; @@ -1021,7 +1067,66 @@ export function registerRobloxShim(lua, opts) { if (sound && typeof sound.Play === 'function') sound.Play(); }; makeService('PathfindingService'); - makeService('CollectionService'); + + // CollectionService — теги на инстансах + const cs = makeService('CollectionService'); + const tagMap = new Map(); // tag → Set + const instTags = new WeakMap(); // instance → Set + const tagAddSignals = new Map(); // tag → Signal (InstanceAddedSignal) + const tagRemoveSignals = new Map(); // tag → Signal (InstanceRemovedSignal) + cs.AddTag = function (inst, tag) { + if (!inst || !tag) return; + let set = tagMap.get(tag); + if (!set) { set = new Set(); tagMap.set(tag, set); } + if (set.has(inst)) return; + set.add(inst); + let tags = instTags.get(inst); + if (!tags) { tags = new Set(); instTags.set(inst, tags); } + tags.add(tag); + const sig = tagAddSignals.get(tag); + if (sig) try { sig.Fire(inst); } catch (_) {} + }; + cs.RemoveTag = function (inst, tag) { + const set = tagMap.get(tag); + if (set) set.delete(inst); + const tags = instTags.get(inst); + if (tags) tags.delete(tag); + const sig = tagRemoveSignals.get(tag); + if (sig) try { sig.Fire(inst); } catch (_) {} + }; + cs.HasTag = function (inst, tag) { + const set = tagMap.get(tag); + return !!(set && set.has(inst)); + }; + cs.GetTagged = function (tag) { + const set = tagMap.get(tag); + return set ? [...set] : []; + }; + cs.GetTags = function (inst) { + const tags = instTags.get(inst); + return tags ? [...tags] : []; + }; + cs.GetInstanceAddedSignal = function (tag) { + let sig = tagAddSignals.get(tag); + if (!sig) { sig = makeSignal(); tagAddSignals.set(tag, sig); } + return sig; + }; + cs.GetInstanceRemovedSignal = function (tag) { + let sig = tagRemoveSignals.get(tag); + if (!sig) { sig = makeSignal(); tagRemoveSignals.set(tag, sig); } + return sig; + }; + + // Debris — удаление инстансов через N секунд + const debris = makeService('Debris'); + debris.AddItem = function (inst, lifetime) { + if (!inst || typeof inst.Destroy !== 'function') return; + const t = Math.max(0, Number(lifetime) || 0); + setTimeout(() => { + try { inst.Destroy(); } catch (_) {} + }, t * 1000); + }; + makeService('MarketplaceService'); const ds = makeService('DataStoreService');