feat(wiki): Lua-версии для всех 50 игр-уроков + расширения shim

ИНФРАСТРУКТУРА:
- docsGamesBuildersLua.js — реестр LUA_OVERRIDES[gameId][scriptId]
  с готовыми Lua-эквивалентами для всех 50 игр.
- buildGameProject(id, {lang:'lua'}) при открытии копии берёт код из
  реестра, или ставит code_lua слот, или TODO-заглушку.
- LessonPage в KubikonDocs обёрнут в DocsLangProvider + DocsLangPicker.
- Новый компонент LuaLessonBanner — при lang='lua' показывает
  сворачиваемые блоки с готовыми Lua-скриптами игры.

LUA-СКРИПТЫ:
- Игры 1-30: полные рабочие Lua-эквиваленты (collect-coins, platform-jump,
  dont-fall, button-door, maze, color-tiles, catch-falling, run-to-finish,
  traffic-light, spring-jump, echo-room, code-door, trader, collect-by-tag,
  shooting-range, lava-floor, key-chest, swing, elevator, enemy-names,
  chaser, danger-zone, switches, falling-bridge, flyby-camera, coin-magnet,
  double-jump, ghost-walls, shop, quest-tasks).
- Игры 31-50: главный скрипт с сообщением + TODO для полной реализации.
  Для clicker — полная версия. Остальные постепенно дорабатываются.

РАСШИРЕНИЯ LUA-RUNTIME (RobloxShim.js):
- CollectionService: полный набор методов AddTag/RemoveTag/HasTag/
  GetTagged/GetTags/GetInstanceAddedSignal/GetInstanceRemovedSignal.
- Debris сервис: AddItem(inst, lifetime) → setTimeout Destroy.
- localPlayer:LoadCharacter() реальный — сбрасывает HP + шлёт respawn.
- HumanoidRootPart реактивные Position/CFrame/Velocity — Lua-скрипт
  может телепортировать и подбрасывать игрока (spring-jump pattern).

РАСШИРЕНИЯ GameRuntime:
- playerSet 'position' — телепорт через hrp.Position = ...
- playerSet 'respawn' — респаун с сбросом HP и позиции на spawn.

Игры теперь работают на Lua. Игры 31-50 — урезанные main-скрипты
(нет полной механики на Lua), будут доработаны итеративно.
This commit is contained in:
min 2026-06-09 03:47:08 +03:00
parent 86b3d2f238
commit 3757eace9f
6 changed files with 1259 additions and 14 deletions

View File

@ -14,6 +14,7 @@ import { LESSONS, hasLesson } from './docsLessons';
import { buildGameProject } from './docsGamesBuilders'; import { buildGameProject } from './docsGamesBuilders';
import DocIcon from './docsIcons'; import DocIcon from './docsIcons';
import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang'; import { DocsLangProvider, DocsLangPicker, DOCS_LANG_STYLES, useDocsLang } from './docsLang';
import { LUA_OVERRIDES } from './docsGamesBuildersLua';
/** /**
* KubikonDocs вика редактора Рублокс. * KubikonDocs вика редактора Рублокс.
@ -500,14 +501,59 @@ const LessonPage = ({ game, navigate }) => {
</div> </div>
)} )}
{/* Тело урока */} {/* Тело урока с переключателем JS/Lua */}
<article className="docsChapter lessonBody"> <article className="docsChapter lessonBody">
<div className="docsSectionBody">{lesson.body}</div> <DocsLangProvider>
<DocsLangPicker />
<LuaLessonBanner gameId={game.id} />
<div className="docsSectionBody">{lesson.body}</div>
</DocsLangProvider>
</article> </article>
</section> </section>
); );
}; };
// При выбранном 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 (
<div className="luaLessonBanner luaLessonBanner--missing">
<b>Lua-версия в работе.</b>
<p>
Для этого урока пока готова только JS-версия (показана ниже).
Если откроешь копию с языком Lua получишь скрипт-заглушку
с подсказкой переключить язык в редакторе.
</p>
</div>
);
}
const entries = Object.entries(overrides);
return (
<div className="luaLessonBanner">
<div className="luaLessonBanner__head">
<b>Готовые Lua-скрипты для этой игры</b>
<span className="luaLessonBanner__hint">
Эти скрипты автоматически попадут в твою копию, если откроешь её на Lua.
</span>
</div>
{entries.map(([id, codeOrFn]) => {
const code = typeof codeOrFn === 'function' ? codeOrFn({ id }) : codeOrFn;
return (
<details key={id} className="luaLessonBanner__script">
<summary>{id}</summary>
<pre className="docCode" data-lang="lua"><code>{code}</code></pre>
</details>
);
})}
</div>
);
};
// //
// Модалка выбора языка скриптов при «Открыть копию» // Модалка выбора языка скриптов при «Открыть копию»
// //

View File

@ -6158,6 +6158,15 @@ export function hasGameBuilder(id) {
return typeof GAME_BUILDERS[id] === 'function'; 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. /** Построить project_data для игры-урока. Возвращает объект или null.
* opts.lang: 'js' (default) | 'lua' на каком языке скрипты в копии. * opts.lang: 'js' (default) | 'lua' на каком языке скрипты в копии.
*/ */
@ -6166,26 +6175,30 @@ export function buildGameProject(id, opts = {}) {
if (!fn) return null; if (!fn) return null;
const project = fn(); const project = fn();
if (opts.lang === 'lua' && project) { if (opts.lang === 'lua' && project) {
// Если в скрипте есть code_lua слот — делаем его активным.
// Иначе ставим stub с заметкой что Lua-версия в работе.
const scene = project.scene || {}; const scene = project.scene || {};
if (Array.isArray(scene.scripts)) { if (Array.isArray(scene.scripts)) {
const overrides = LUA_OVERRIDES[id] || {};
scene.scripts = scene.scripts.map(s => { scene.scripts = scene.scripts.map(s => {
if (s.language === 'lua') return s; if (s.language === 'lua') return s;
if (s.code_lua && s.code_lua.trim()) { // Приоритет: явный code_lua → override из реестра → stub.
return { ...s, language: 'lua', code: s.code_lua, code_js: s.code_js || s.code }; 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 вверху), чтобы увидеть рабочий код. -- Переключи язык на JS в редакторе (кнопка JS вверху), чтобы увидеть рабочий код.
-- Lua-API: game:GetService("Players"), workspace, script.Parent print("Lua-скрипт " .. (script and script.Name or "?") .. " запущен (заглушка)")
print("Lua-скрипт запущен (заглушка)")
`; `;
}
return { return {
...s, ...s,
language: 'lua', language: 'lua',
code: luaStub, code: luaCode,
code_js: s.code_js || s.code, code_js: s.code_js || s.code,
code_lua: luaStub, code_lua: luaCode,
}; };
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@ -419,4 +419,39 @@ export const DOCS_LANG_STYLES = `
.docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */ .docCode .hl-number { color: #bd93f9; } /* 42, 3.14 */
.docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */ .docCode .hl-comment { color: #6272a4; font-style: italic; } /* // или -- */
.docCode .hl-fn { color: #50fa7b; } /* myFunc() */ .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; }
`; `;

View File

@ -4285,6 +4285,28 @@ export class GameRuntime {
player.maxHp = max; player.maxHp = max;
if (player.hp > max) player.hp = max; if (player.hp > max) player.hp = max;
} catch (_) {} } 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; return;
} }

View File

@ -769,7 +769,16 @@ export function registerRobloxShim(lua, opts) {
localPlayer.Team = undefined; localPlayer.Team = undefined;
localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) }; localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) };
localPlayer.Kick = function () {}; 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; }; localPlayer.HasAppearanceLoaded = function () { return true; };
// Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически
// клонируется в Backpack каждого спавнящегося игрока. // клонируется в Backpack каждого спавнящегося игрока.
@ -896,8 +905,45 @@ export function registerRobloxShim(lua, opts) {
const hrp = newInstance('Part', 'HumanoidRootPart'); const hrp = newInstance('Part', 'HumanoidRootPart');
hrp.Parent = character; hrp.Parent = character;
hrp.Position = new RbxVector3(0, 5, 0); hrp._position = new RbxVector3(0, 5, 0);
hrp.Size = new RbxVector3(2, 2, 1); 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.Children.push(hrp);
character.HumanoidRootPart = hrp; character.HumanoidRootPart = hrp;
character.PrimaryPart = hrp; character.PrimaryPart = hrp;
@ -1021,7 +1067,66 @@ export function registerRobloxShim(lua, opts) {
if (sound && typeof sound.Play === 'function') sound.Play(); if (sound && typeof sound.Play === 'function') sound.Play();
}; };
makeService('PathfindingService'); makeService('PathfindingService');
makeService('CollectionService');
// CollectionService — теги на инстансах
const cs = makeService('CollectionService');
const tagMap = new Map(); // tag → Set<instance>
const instTags = new WeakMap(); // instance → Set<tag>
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'); makeService('MarketplaceService');
const ds = makeService('DataStoreService'); const ds = makeService('DataStoreService');