From 913283ffa61cd1ed040805041c40f73413900496 Mon Sep 17 00:00:00 2001 From: min Date: Mon, 8 Jun 2026 19:43:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(rbxl):=209=20=D0=BC=D0=B5=D1=85=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=20ROBLOX=20Battle=20(Teams/Leaderstats/HUD/T?= =?UTF-8?q?ools/etc)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализовано из 14 механик: 1. Teams (game.Teams, Player.Team, TeamColor): scene.teams[] из конвертера, эвристика TeamBeacon-Model → автоматически создаются 4 команды. В shim создаются Team-инстансы при snapshot, авто-эквип игрока в первую. 2. Leaderstats UI: IntValue.Value реактивно шлёт leaderstatSet → существующий LeaderstatsManager (define + set). HUD автоматически рисуется в правом верхнем по родительскому Name='leaderstats'. 3. BindableFunction + RemoteFunction + Message/Hint класс. Message с реактивным .Text и .Parent шлёт hudMessage в наш RbxlHudOverlay. 4. KillFeed UI + creator-tag tracking. RbxlHudOverlay.addKillFeed() рисует А → [weapon] → Б в правом верхнем. Humanoid.TakeDamage при Health=0 ищет creator-ObjectValue и шлёт killFeed. Авто-respawn через 2с. 5. SpawnLocation.TeamColor → scene.team_spawns[] для будущей логики команд-спавна. 6. Tool:Clone() / Model:Clone() / :clone(): поверхностный клон + lowercase alias. Также :MakeJoints/:BreakJoints/:Remove/:remove no-op методы. 7. Creator-tag handling в TakeDamage (см. пункт 4). 12. Bouncer/батут: BodyVelocity с +Y и Parent=Torso/HumanoidRootPart → эвристика "толкаем вверх" → playerSet jumpVelocity → реальный jump через player._vy. 14. Mouse.Icon → CSS cursor на canvas (crosshair для не-пустых). Также: - RbxlHudOverlay.js — новый модуль DOM-оверлей для HUD-элементов (KillFeed/Message/WinGui). Lazy-создаётся при первом hudMessage/killFeed. - BabylonScene.serialize включает scene.teams и scene.team_spawns. - Converter: scene = teams[] + team_spawns[]. TeamBeacon Model'и → команды. - Deploy converter.py на VM 130. Остались: 8 Regeneration, 9 BattleArmor, 10 WinGui/FireButton кастомное позиционирование, 11 AdminConsole (no-op уже ok), 13 NotLinkedBlocker. --- .../src/__pycache__/converter.cpython-314.pyc | Bin 57867 -> 60712 bytes rbxl-importer/src/converter.py | 55 +++++ src/editor/engine/GameRuntime.js | 67 +++++- src/editor/engine/RbxlHudOverlay.js | 177 +++++++++++++++ src/editor/engine/lua/RobloxShim.js | 202 +++++++++++++++++- 5 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 src/editor/engine/RbxlHudOverlay.js diff --git a/rbxl-importer/src/__pycache__/converter.cpython-314.pyc b/rbxl-importer/src/__pycache__/converter.cpython-314.pyc index 6a970143b7f310de1a057acf50dc9337cf620ebd..4e9d3dba9e16132df1d1cca3525f4172b04ec3d4 100644 GIT binary patch delta 11715 zcmb_C30PF;wfD|GtOGN^up=Ni$gsQMj(`eLz$+OgA&vu#Fv&18{bx|L%V1+u)0d=} zKUQL!1RJ&3nnpif-lJJ+7Sk-PDToTW@wL7zFKzl>GbXnB(k1Vl|IV-#YwYXraqhYQ zdj7Nh=Rf!VmoxGKQ64ovDl&|NU;giN?%wvosi--z{AaW%c7^^kbyjOx$>QQxK#JNH zmfKoCqkCerp!R5NKGrU5DJ@xK+YQaOFK#Kde>V7jY@#wcjkI(-&5llwoA~Uckp49x zoi7+nGwzku7sE896hRpPF~aAz+Pj=~V(s3w+oUG*u*QTna*x|f7U1hb1dCW1ar?Y# zz;bdj@JGz_N>T#9jB1mg zx&j7hO-eotup_0ucrFx}cu@s}b=bS1#|kK5J#ttNF|yU|bdyr6)PPjdKc-|&o4jS( zyj15DY0QbrO z+t!`osjo(a9X3vEsT+F_Y=X2>@Ielg}ic%T{rxYWt)>fy@>uqzi`m9zq zUS>~F^Jr%7F_`_0+!YIK(3Z3yXhqP5z>Yu;KvZCFvYnw$2k3D-R&~&2^D5)-29)t> zl{D&BtlCNs%xhE?O6bS)68X!6T2m#TW8q49AuEA%YJ_ZnI$yj{#>(chvK%aP@|5H? zXRzX8R$RiGrV6FLGz;fT7i3wSX#u^IcTzGoWv9E%?qo&_yhA@i<^up6IY1bJ$j8Zb zzdXMR)~(JT2;Yq@(AVA@tJ&0z!!Rv;~Oi z;R-o{FW~6jg5&%aT2Po$BIFlvg1LYz)K>ByUMLU>g(9I?C=p79GGT$RP*@}^7RrSs z!ct+Gu)I*?k!ogE66EP$nqWV{v4~9{pT9JdqdNH?utnxM@N)zK@P(gfi)izb2-)?3s-)l@ow zc}Rz_VJk1x!Sd>bh^+=8a%;2@1#^;0c}3-Fr>*rK*dU9y z79hs*1;xmxXBF${MJJMlT}Ugl`UFy`y3uoW%GFKt~OEbl-XT$TUmnNkMuGJsRZVAdL<8u?!%-d-@wY( zk($`X>WU54)zuZ1^>xhQ8EUJ@>Zu;=41NXJX%81r=JM;Llr0&>7EGfW9sK*dES_GJdth-)NVi+C6p z8Q)6+;fP0YktuzRW2xmMspX@oO9PQu7{x^<^_2vo5!XqLV-Sz!B4c|?0(!&^oFmfM z7aNGfmv|h#w4d)U9pDE_Px8Y_C4mI2NZ}&WhSQ4zsrZt{K@WW(9q|kuo;} zHKv<8qMJKzF!og+$?CTctUc-)&0H|VU&t&Up0i{)Y3aBjsjp_-klr6RZkXHe3uu+O zv47;0G5RZ!T%6n#NTG8Vj({0>&aBlRR}!`*T?Wlysz&nY#zp$|H`oEO6>dvQz*BnB zgxR*VQki#!18~?Fz{ja<_82N-g%CxbT$CuwSJFUJ3jJ_VDW5=-7bC1^@LmT0ox$HN zF4ZSwaTd9t!VRR2Z6HDdEiT9Q_VRjw&oKPA3@=|&p_bYk>7FI#Af8B{Sz->3lT;2w zPK@)1C8fDi``D@Dvl;cQ#DH6tg=;!R*d_z9n z_$`A>h^HHuv0gOuOGRm^FqlWNQh`HCn1gq4OXaDYgEytn*Oon-L4E?GiJJ9}_70!J z)h=o`yU9J^)xi%b(8JQlmY?KPY2Auur4ru<2UaxlDkHf7-KciebteRWK_3A=(bh>O zoRsALwAJ0Mc8|~Dc1hk!WYZD*b~~}Vd@TGkDJAE18LA|;dJh}%b}z=55E$VR2*jw>UKC4glNa|&sVN91fqRZ?ryQnK1k1+%| zZK8$0%4uUQ{FQh*w{pM#TQJ^3#&vVJ0R%6}7))VsA;On+4Cbj3FGc*7$Q*C;v7At-;R>Q$9+2urU35H%a@-j2pyu>W8wKx7GO}nt%~uSL<*Sdz+}}gxyor!r9b@ z(%iyMt&{If>;OCYeg`|tk_TasI#V(^4^O5LWEo;C7`})Y9^b^+P3tNes;!On)&`+w zLrr6irJDHbu|{rd@y?`H)7s&1wt@OEB_w-rn7s%VfukYKh;2sD06B$G4me~&)`pTBlGCzmQ?0C z{0X|36!~Y_7{q8yfqCtCT!VC&qfK%M9Y`DV89j)70S7aiZ($;0NvE&lDDztYnsk$H z=I?kJy49Pho577cXs>sUiuwKdbmbdH`b)1yW{?fO?d27>ZcMk+`fl_sD9uN@T~nmV zskRvrASx(;qCdDW!Z+Bt9_5TqhljGcJJHRZ0L>3ZytS8-n0#Q6b6CZ%x5z;Z) z+{Fm#f+ADG7q+(@I1JkaX2^UGPBOWHOpz~Z%`DM|ZJ2CtLA2Osx@5(OkoH;O;7q!a zq+JMbV@ifu3Z;A=oPQ>M7*)Z0#HSAcp*@_1$5;sBx<5DRqM9cfTI3B%mQ2gIKRhaw z&%(T1u=wM*v<8n-P&OQ+)LxDclkl%dp@M`e)- zD(dNk44YceG{;N~0mYDE%bO*`rnM-9FvzflH)BSU{X*3EBZSD%&>>o-@_T+R=dkyfTt7hop%+mL=yQ6v=g=1q!K@LkZgEoX(aMv#GXa) z90J_C$qO9BKjf!?icuAiNdh|zCXyQKOkt8@uf&Gy5U{`&wS%lhP=kOuu6GbaEroAD zEMIdO3tmUS;u;nwgdw59{6Eag1wRGFu*{64UJGCdDa7<+W2Pzt(6onxvkztx_9-t# z$M!BbSiZl!&(d!h)#r~!oA)U$$0Z$X+TYao<L1h`t{E;^@uFfhyLvQz4I~%R!yvh! z4Tp2S=ghiWF^6N*%pnd_+W-zP4&dWd6?+UdvckVL2UI=Kdd;UrFv>J@D50<1S3s|9 z%+Pt%&01*2ein6e7%Dmc$ir*0cs1R2C#0!mA?@>MK(Xah6SgcTZK!S32ues1E2mZo zDkxD+D^X*+=%HjZPs6Ji1Itghc2cUYwl$aGQgOVu+#o6qMnNl$Ic^R9(&Xoe3JR>ZDLXo$U z`;bs(Oeo`!(+-{o%q%KnnQW$I|HILbHYC$ezZv6Kaq-DhEar^F1)$-3E5&P5dIMof$L_ox!SCfr~e}sV;m!@cDS8g@D)L2 zVNY_1meL$Q3uVHm@a2ZV%49YI6AypK?TZnxTp+;FHW_Ll1wqF@#qSsL4{uJOA5$g& z;ou*rCdi0|zrpc;3*hF7qd$QIBo4HO!ojZ`IG|uzo;t~zr-zfG(up}ii7Q_qlC&TZ zET4c|H_IoiVX1`1nMVS48o&gC80MtChZgw{A?d+1LLbt$8w$kmmR*yI#XQ1d9GH<} zLJUk@x?drsfF@Oosaqx1GR`x7wZSW>+br5f7EWKCsFJiA?q;-ZXW^a~qnrt+5%d~G zEW~3x`qSW{@8$(l3Ab{3OP13mkHhJ0(EhlN_dkpTGldL?izU|WZf6@@7?Jq@406Q> z;4^hFu&#p;f*B`U_N1<{wIdTje(s%iVp7bu=Fm6i;982IJ-zxwvJ5DS>Ay#EEVr_f z969}@A1^v&9`54#7ir)3)5;l5lm3JG>c1i85tw-fF(vfl?`O(jME#Syfs%)wtSh@8 zg(s7)xu6TWxt^EEsqRPybT2-#C+$UKhX$G2-sP}D=a->?U@73=pu zB^+G_y`D-=q}KW76}ZQW3R|~By7vLinFmC*x;YKybo^*G45oWEaWMYa-LljY=*ILn zxE(&SNTSZLtv4MnY=48Kw%~0V*HF#p14{r3Rm=HxpSDT}gyoJk=gU^+A$6T`fe+P>BaytL`VVL~v@y601`?NUj7bCrvHvZ5A;{4hV+xfBy^MEC@B_M#L z{d8i<2r~6NG8Lpu6HjCUQ=w_jdWf{fMW!| zF~I2!7}x%^nYRoccsh>1m0h)!UOZU@bgECaX{&e`SX6E&@co&{5m)%^-9Ay((&h8P zkzC2D9i2AF)v6I!V|9zWThuhTy$<%BWI28M+0c%>zuFNr#JL!kd zKAQ17^p+SY7i9%SBHtm(JT5PUx$q6Q2Nu)+Tm^rezWdywN^~mZ4Q!98V&+jqjpXDY zmoOn-k>5!^MVeGn(to$c=k}PL_U%5hjy6A^r((iCL?3wmy=?SFu|639INgk_R5Si2i5{uMEFA1t>Ip9EaH zq*VXUvFrl`jQ}9E58vaN$9G^i#-J2*kEZz9d?0y`{^w6u^Ka7Z(_3?{!T{cQXglfV zg9mzitdfphrI0N((icyM^R0uwIQ`^5kl;B>-#>$Ki0;)=d1^mQ2PvJ-76-&okZ+I> z={Pz_x4oY1e-c}0*0|fH!XMzP3SO&SZH|?zqajy+42qZ84H+PO7(Ccra zamW9BHSfm*(Qcizvjs0!n0FMj4R2CC+0lXER}fL{u{rHNXvvHOKI!=IKAa2eXP4un z&p-bhJP@l4dZQv4|NFC4g0b?uy0ze-OIzf!~E?yuu`dl1}< zfEf^Oa-;`enJJkN^CNf(fGLA?BJM)qM&O~(kEHnTL-Yd#mk{LQ81oRA5ac14k02ib z<|2q0K>>n71Vsois30W>n4O+Sj2S<(*iw8gLvRk~U{SOWu`aF&-vMRv37TFvRw2OF z-dN~&{hM45_qJj?T(L0D9Zycbrj1lC`-B5fKi7KbN9X1hC2YNy3aI6L-V=ZFr z=*$9OxH2bw2$epGvXj?VEJI$xZQ)&D9UdFpK2pI2A-Ly58gRIGIJ$6bc?znG7!|s) z1SzR5@KXx>+D|^9-yTa9m?r%STk-dZ{0=E1AUOPWq~$-@9I%P7+r{L4tat@0kekvH m&Dcb<3@%r~_2?C3$KYSb9#lt?2;O*wLl|%+4)*=pEBin5kMey0 delta 9406 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 jdiZMs{7G diff --git a/rbxl-importer/src/converter.py b/rbxl-importer/src/converter.py index 7509a40..c86bbf8 100644 --- a/rbxl-importer/src/converter.py +++ b/rbxl-importer/src/converter.py @@ -264,8 +264,32 @@ class Converter: 'sounds': [], 'glbModels': [], 'scripts': [], + # Команды PvP (Roblox Battle): {id, name, color_hex, auto_assignable} + 'teams': [], + # Spawn-точки команд (для SpawnLocation.TeamColor) + 'team_spawns': [], # {team_color_hex, x, y, z} } + # Эвристика для Roblox Battle: Model с именем "TeamBeacon X" → + # команда X. PvP-карты часто используют этот паттерн вместо Team-инстансов. + TEAM_BEACON_COLORS = { + 'Black': '#1f1f1f', 'Blue': '#0d69ac', 'Red': '#c4281c', + 'Green': '#4b9740', 'White': '#f2f3f3', 'Yellow': '#f5cd30', + 'Orange': '#d97e29', 'Purple': '#6b327a', + } + for inst in self.model.instances: + name = inst.properties.get('Name', '') + if (inst.class_name == 'Model' and isinstance(name, str) + and name.startswith('TeamBeacon ')): + team_name = name.replace('TeamBeacon ', '').strip() + color = TEAM_BEACON_COLORS.get(team_name, '#cccccc') + scene['teams'].append({ + 'id': f'team_{len(scene["teams"]) + 1}', + 'name': team_name, + 'color_hex': color, + 'auto_assignable': True, + }) + # Обходим все instances и конвертим for inst in self.model.instances: self._convert_one(inst, scene) @@ -331,8 +355,12 @@ class Converter: elif cls == 'Workspace': # Workspace = root, его свойства мапим на scene.worldSize и т.п. pass + elif cls == 'Team': + # PvP-команда: имя + цвет в scene.teams[]. + self._convert_team(inst, scene) elif cls in ('Camera', 'Terrain', 'StarterPlayer', 'StarterGui', 'StarterPack', 'StarterCharacterScripts', 'Players', + 'Teams', 'ReplicatedStorage', 'ServerScriptService', 'ServerStorage', 'SoundService', 'TweenService', 'RunService', 'UserInputService', 'HttpService', 'DataStoreService', @@ -631,10 +659,37 @@ class Converter: # ─── Spawn ─── + def _convert_team(self, inst: Instance, scene: Dict) -> None: + """Roblox Team → scene.teams[].""" + props = inst.properties + name = str(props.get('Name', 'Team')) + # TeamColor — BrickColor код, мапим в hex через существующую таблицу + team_color = props.get('TeamColor') + color_hex = '#ffffff' + if isinstance(team_color, BrickColor): + color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc') + scene['teams'].append({ + 'id': f'team_{len(scene["teams"]) + 1}', + 'name': name, + 'color_hex': color_hex, + 'auto_assignable': bool(props.get('AutoAssignable', True)), + }) + def _convert_spawn(self, inst: Instance, scene: Dict) -> None: props = inst.properties cf = props.get('CFrame') pos, _ = cframe_to_pos_rot(cf, self.scale) + + # TeamColor (если есть) → spawn для команды. + team_color = props.get('TeamColor') + if isinstance(team_color, BrickColor): + color_hex = BRICKCOLOR_TO_HEX.get(team_color.code, '#cccccc') + scene['team_spawns'].append({ + 'team_color_hex': color_hex, + 'x': pos['x'], 'y': pos['y'] + 1.5, 'z': pos['z'], + 'neutral': not bool(props.get('Neutral', True)) and team_color.code != 0, + }) + # Spawn должен быть чуть выше — Roblox SpawnLocation это плоская плита, # юзер появляется на её верхней грани. scene['spawnPoint'] = { diff --git a/src/editor/engine/GameRuntime.js b/src/editor/engine/GameRuntime.js index 6a0ae5a..47acba5 100644 --- a/src/editor/engine/GameRuntime.js +++ b/src/editor/engine/GameRuntime.js @@ -21,6 +21,7 @@ import { PhysicsWorld } from './PhysicsWorld'; import { LabelManager } from './LabelManager'; import { handleLuaCommand, unpackRobloxLuaCode, parseRobloxLuaMeta } from './rbxl-lua-integration.js'; import { LuaSharedSandbox } from './lua/LuaSharedSandbox.js'; +import { RbxlHudOverlay } from './RbxlHudOverlay.js'; export class GameRuntime { constructor(scene3d) { @@ -247,6 +248,45 @@ export class GameRuntime { // partlcle-эффекты есть у Tool. При equip покажем у руки. this._rbxlPendingParticles = this._rbxlPendingParticles || []; this._rbxlPendingParticles.push(payload); + } else if (cmd === 'mouseIconChanged') { + // Roblox Mouse.Icon → CSS cursor на canvas + try { + const canvas = this.scene3d?.engine?.getRenderingCanvas?.(); + if (canvas) canvas.style.cursor = payload.cssCursor || 'default'; + } catch (_) {} + } else if (cmd === 'hudMessage') { + // Roblox Message/Hint в верхней трети экрана + try { + this._ensureRbxlHud(); + if (payload.visible && payload.text) { + this._rbxlHud.showMessage(payload.text); + } else { + this._rbxlHud.hideMessage(); + } + } catch (_) {} + } else if (cmd === 'killFeed') { + // Кастомное событие от нашего creator-tag tracker'а + try { + this._ensureRbxlHud(); + this._rbxlHud.addKillFeed(payload.killer, payload.victim, payload.weapon); + } catch (_) {} + } else if (cmd === 'winShow') { + try { + this._ensureRbxlHud(); + this._rbxlHud.showWin(payload.text || 'WIN!'); + } catch (_) {} + } else if (cmd === 'leaderstatSet') { + // Roblox leaderstats: IntValue.Value меняется → HUD. + try { + const lm = this.scene3d?.leaderstats; + if (lm) { + const statName = String(payload.statName || 'Stat'); + if (!lm._defs.some(d => d.name === statName)) { + lm.define(statName, { initial: 0 }); + } + lm.set(lm._meId || 'me', statName, Number(payload.value) || 0); + } + } catch (_) {} } else { this._handleCommand(null, cmd, payload); } @@ -576,6 +616,15 @@ export class GameRuntime { return null; } + /** Создаёт DOM-overlay для импортированных Roblox-карт (KillFeed, + * Message, WinGui). Лениво — только при первом hudMessage/killFeed. */ + _ensureRbxlHud() { + if (this._rbxlHud) return; + const canvas = this.scene3d?.engine?.getRenderingCanvas?.(); + const parent = canvas?.parentElement || document.body; + this._rbxlHud = new RbxlHudOverlay(parent); + } + /** Регистрирует Roblox-Tool в InventoryUI как item в hotbar. * Слушает смену активного слота → шлёт equipTool/unequipTool в Lua-shim. * Слушает клики ЛКМ → шлёт mouseButton1Down (Tool.Activated fires там). */ @@ -719,6 +768,9 @@ export class GameRuntime { this._rbxlToolHooks = false; this._rbxlActiveSlot = -1; this._rbxlPendingParticles = null; + // Очищаем Roblox HUD overlay (KillFeed/Message/WinGui) + try { this._rbxlHud?.dispose(); } catch (_) {} + this._rbxlHud = null; // Удаляем все объекты, которые скрипты наспавнили через // game.scene.spawn/clone — иначе после Stop они остаются на сцене // и накапливаются при повторных запусках. @@ -4196,6 +4248,16 @@ export class GameRuntime { } else { player.hp = target; } + } else if (payload.prop === 'jumpVelocity') { + // Bouncer (батут): Lua-скрипт даёт игроку Y-velocity = N + try { + if (player._vy !== undefined) player._vy = Number(payload.value) || 0; + else if (player.velocity) player.velocity.y = Number(payload.value) || 0; + } catch (_) {} + } else if (payload.prop === 'walkSpeed') { + try { player.walkSpeed = Number(payload.value) || player.walkSpeed; } catch (_) {} + } else if (payload.prop === 'jumpPower') { + try { player.jumpPower = Number(payload.value) || player.jumpPower; } catch (_) {} } return; } @@ -4495,7 +4557,10 @@ export class GameRuntime { }); } } - return { blocks, models, primitives }; + // Teams и team_spawns из projectData (импортированные из .rbxl) + const teams = this.projectData?.scene?.teams || []; + const teamSpawns = this.projectData?.scene?.team_spawns || []; + return { blocks, models, primitives, teams, teamSpawns }; } // Разобрать ref-строку ('primitive:N' / 'model:N' / 'block:x,y,z') в target diff --git a/src/editor/engine/RbxlHudOverlay.js b/src/editor/engine/RbxlHudOverlay.js new file mode 100644 index 0000000..ae17084 --- /dev/null +++ b/src/editor/engine/RbxlHudOverlay.js @@ -0,0 +1,177 @@ +/** + * RbxlHudOverlay — DOM-оверлей с HUD-элементами для импортированных + * Roblox-карт: KillFeed (правый верх), Message/Hint (центр), WinGui. + * + * Один контейнер `.rbxl-hud-overlay` на canvas.parentElement, в нём дочерние + * блоки по типу. Стили inline, ничего не зависит от CSS приложения. + * + * API: + * const hud = new RbxlHudOverlay(canvasParent); + * hud.addKillFeed(killer, victim, weapon) + * hud.showMessage(text, opts) + * hud.hideMessage() + * hud.showWin(text) + * hud.dispose() + */ + +export class RbxlHudOverlay { + constructor(parent) { + this._parent = parent || document.body; + this._root = null; + this._killFeed = null; + this._message = null; + this._winBox = null; + this._killEntries = []; // [{el, expireAt}] + this._mount(); + } + + _mount() { + if (this._root) return; + const root = document.createElement('div'); + root.className = 'rbxl-hud-overlay'; + Object.assign(root.style, { + position: 'absolute', + inset: '0', + pointerEvents: 'none', + zIndex: '999', + fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif', + }); + this._parent.appendChild(root); + this._root = root; + + // KillFeed — правый верхний угол + const kf = document.createElement('div'); + Object.assign(kf.style, { + position: 'absolute', + top: '60px', + right: '12px', + display: 'flex', + flexDirection: 'column', + gap: '6px', + maxWidth: '320px', + pointerEvents: 'none', + }); + root.appendChild(kf); + this._killFeed = kf; + + // Message — центр сверху (Roblox Message по центру экрана, + // но в верхней трети чтобы не мешать игре) + const msg = document.createElement('div'); + Object.assign(msg.style, { + position: 'absolute', + top: '15%', + left: '50%', + transform: 'translateX(-50%)', + padding: '10px 24px', + background: 'rgba(0,0,0,0.6)', + color: '#fff', + fontSize: '22px', + fontWeight: '600', + borderRadius: '6px', + textShadow: '0 2px 4px rgba(0,0,0,0.8)', + display: 'none', + pointerEvents: 'none', + }); + root.appendChild(msg); + this._message = msg; + + // WinGui — большая надпись по центру + const win = document.createElement('div'); + Object.assign(win.style, { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + padding: '24px 48px', + background: 'rgba(0,0,0,0.75)', + color: '#ffd86b', + fontSize: '48px', + fontWeight: '800', + borderRadius: '12px', + textShadow: '0 4px 8px rgba(0,0,0,0.8)', + display: 'none', + pointerEvents: 'none', + }); + root.appendChild(win); + this._winBox = win; + + // Тик для авто-исчезновения KillFeed entries (через 5с) + this._tickInterval = setInterval(() => this._cleanupKills(), 500); + } + + addKillFeed(killer, victim, weapon) { + if (!this._killFeed) return; + const entry = document.createElement('div'); + Object.assign(entry.style, { + background: 'rgba(0,0,0,0.55)', + color: '#fff', + padding: '6px 10px', + borderRadius: '4px', + fontSize: '13px', + display: 'flex', + gap: '6px', + alignItems: 'center', + animation: 'rbxlHudFadeIn 0.3s', + }); + const killerEl = document.createElement('span'); + killerEl.textContent = String(killer || '?'); + killerEl.style.color = '#5bd1e8'; + const arrow = document.createElement('span'); + arrow.textContent = weapon ? `→ [${weapon}] →` : '→'; + arrow.style.color = '#ff9a52'; + const victimEl = document.createElement('span'); + victimEl.textContent = String(victim || '?'); + victimEl.style.color = '#f87a7a'; + entry.appendChild(killerEl); + entry.appendChild(arrow); + entry.appendChild(victimEl); + this._killFeed.appendChild(entry); + this._killEntries.push({ el: entry, expireAt: performance.now() + 5000 }); + // Keep only last 8 + while (this._killEntries.length > 8) { + const old = this._killEntries.shift(); + try { old.el.remove(); } catch (_) {} + } + } + + _cleanupKills() { + const now = performance.now(); + const keep = []; + for (const e of this._killEntries) { + if (e.expireAt < now) { try { e.el.remove(); } catch (_) {} } + else keep.push(e); + } + this._killEntries = keep; + } + + showMessage(text, opts = {}) { + if (!this._message) return; + this._message.textContent = String(text || ''); + this._message.style.display = text ? 'block' : 'none'; + if (opts.duration) { + clearTimeout(this._msgTimer); + this._msgTimer = setTimeout(() => this.hideMessage(), opts.duration); + } + } + + hideMessage() { + if (this._message) this._message.style.display = 'none'; + } + + showWin(text) { + if (!this._winBox) return; + this._winBox.textContent = String(text || ''); + this._winBox.style.display = 'block'; + // Auto-hide через 6с + clearTimeout(this._winTimer); + this._winTimer = setTimeout(() => { if (this._winBox) this._winBox.style.display = 'none'; }, 6000); + } + + dispose() { + try { this._root?.remove(); } catch (_) {} + clearInterval(this._tickInterval); + clearTimeout(this._msgTimer); + clearTimeout(this._winTimer); + this._root = null; + } +} diff --git a/src/editor/engine/lua/RobloxShim.js b/src/editor/engine/lua/RobloxShim.js index 59db590..d4c96aa 100644 --- a/src/editor/engine/lua/RobloxShim.js +++ b/src/editor/engine/lua/RobloxShim.js @@ -239,7 +239,29 @@ function makeInstanceMethods() { this.Parent = undefined; } }, - Clone: function () { return undefined; }, + Clone: function () { + // Поверхностный клон — достаточно для большинства Roblox-паттернов + // (Tool/Pellet/Bomb клонируются и parent'ятся в Workspace). + // Глубокий клон не делаем — Children копируются по ссылке (как в Roblox + // Clone() это deep copy, но у нас нет полной physical model). + try { + const copy = Object.assign({}, this); + copy.Children = (this.Children || []).slice(); + copy.Parent = undefined; + return copy; + } catch (_) { + return undefined; + } + }, + // Старый Roblox API: lowercase :clone() + clone: function () { return this.Clone && this.Clone(); }, + // model:makeJoints() — заглушка (Welds мы не делаем) + MakeJoints: function () {}, + makeJoints: function () {}, + BreakJoints: function () {}, + breakJoints: function () {}, + Remove: function () { this.Parent = undefined; }, + remove: function () { this.Parent = undefined; }, GetAttribute: function (n) { return (this.Attributes || {})[n]; }, SetAttribute: function (n, v) { if (!this.Attributes) this.Attributes = {}; @@ -742,6 +764,13 @@ export function registerRobloxShim(lua, opts) { localPlayer.Children.push(playerGui); localPlayer.PlayerGui = playerGui; localPlayer.DisplayName = 'Player'; + localPlayer.Name = 'Player'; + localPlayer.Neutral = true; // не в команде по умолчанию + localPlayer.Team = undefined; + localPlayer.TeamColor = { Name: 'White', Color: new RbxColor3(1, 1, 1) }; + localPlayer.Kick = function () {}; + localPlayer.LoadCharacter = function () {}; + localPlayer.HasAppearanceLoaded = function () { return true; }; // Backpack — инвентарь, содержит Tools. В Roblox StarterPack автоматически // клонируется в Backpack каждого спавнящегося игрока. const backpack = newInstance('Backpack', 'Backpack'); @@ -762,7 +791,18 @@ export function registerRobloxShim(lua, opts) { m.WheelForward = makeSignal(); m.WheelBackward = makeSignal(); m.Idle = makeSignal(); - m.Icon = ''; + // m.Icon reactive — меняет CSS cursor на canvas + let _icon = ''; + Object.defineProperty(m, 'Icon', { + get() { return _icon; }, + set(v) { + _icon = String(v || ''); + // rbxassetid → стрелочный курсор-прицел (наш дефолт) + const cssCursor = _icon && _icon.includes('rbxasset') + ? 'crosshair' : (_icon ? 'crosshair' : 'default'); + send('mouseIconChanged', { icon: _icon, cssCursor }); + }, + }); m.X = 0; m.Y = 0; m.ViewSizeX = 1920; m.ViewSizeY = 1080; m.Hit = { Position: new RbxVector3(0, 0, 0), p: new RbxVector3(0, 0, 0), @@ -803,7 +843,26 @@ export function registerRobloxShim(lua, opts) { const v = Math.max(0, (this.Health || 100) - (Number(n) || 0)); this.Health = v; this.HealthChanged.Fire(v); - if (v === 0) this.Died.Fire(); + if (v === 0) { + // Creator-tag: ищем creator-ObjectValue в Humanoid.Children для kill feed + let killerName = null; + for (const c of (this.Children || [])) { + if (c && c.Name === 'creator' && c.Value) { + killerName = String(c.Value.Name || c.Value.DisplayName || '?'); + break; + } + } + if (killerName) { + send('killFeed', { killer: killerName, victim: localPlayer.Name || 'Player', weapon: '' }); + } + this.Died.Fire(); + // В Roblox после Died игрок респавнится — у нас через playerSet=respawn + setTimeout(() => { + this.Health = this.MaxHealth || 100; + this.HealthChanged.Fire(this.Health); + send('playerSet', { prop: 'health', value: this.Health }); + }, 2000); + } send('playerSet', { prop: 'health', value: v }); }; humanoid.MoveTo = function () {}; @@ -836,6 +895,12 @@ export function registerRobloxShim(lua, opts) { makeService('StarterPack'); makeService('StarterPlayer'); + // Teams сервис — PvP-команды (TeamBeacon Black/Blue/Red/Green в Roblox Battle) + const teams = makeService('Teams'); + teams.Children = []; + teams.GetTeams = function () { return teams.Children.slice(); }; + teams.GetChildren = function () { return teams.Children.slice(); }; + const uis = makeService('UserInputService'); uis.InputBegan = makeSignal(); uis.InputChanged = makeSignal(); @@ -1165,6 +1230,51 @@ export function registerRobloxShim(lua, opts) { inst = newInstance('BindableEvent', 'BindableEvent'); inst.Event = makeSignal(); inst.Fire = function (...a) { this.Event.Fire(...a); }; + } else if (className === 'BindableFunction') { + // BindableFunction — синхронный RPC внутри клиента. + // OnInvoke = single-callback; Invoke вызывает его и возвращает значение. + inst = newInstance('BindableFunction', 'BindableFunction'); + inst.OnInvoke = undefined; // юзер ставит function + inst.Invoke = function (...args) { + if (typeof this.OnInvoke === 'function') { + try { return this.OnInvoke(...args); } catch (_) { return undefined; } + } + return undefined; + }; + } else if (className === 'RemoteFunction') { + inst = newInstance('RemoteFunction', 'RemoteFunction'); + inst.OnServerInvoke = undefined; + inst.OnClientInvoke = undefined; + inst.InvokeServer = function (...args) { + if (typeof this.OnServerInvoke === 'function') { + try { return this.OnServerInvoke(localPlayer, ...args); } catch (_) {} + } + return undefined; + }; + inst.InvokeClient = function (_p, ...args) { + if (typeof this.OnClientInvoke === 'function') { + try { return this.OnClientInvoke(...args); } catch (_) {} + } + return undefined; + }; + } else if (className === 'Message' || className === 'Hint') { + // Roblox Message — текстовая надпись по центру экрана, + // когда .Parent = workspace или nil. Hint — то же но мельче. + inst = newInstance(className, className); + let _txt = ''; + Object.defineProperty(inst, 'Text', { + get() { return _txt; }, + set(v) { _txt = String(v || ''); send('hudMessage', { kind: className, text: _txt, visible: !!inst.Parent }); }, + }); + // При смене Parent: nil → скрываем, workspace → показываем + const _origParent = Object.getOwnPropertyDescriptor(inst, 'Parent'); + Object.defineProperty(inst, 'Parent', { + get() { return this._parent; }, + set(v) { + this._parent = v; + send('hudMessage', { kind: className, text: _txt, visible: !!v }); + }, + }); } else if (className === 'Humanoid') { inst = newInstance('Humanoid', 'Humanoid'); inst.Health = 100; inst.MaxHealth = 100; @@ -1229,6 +1339,26 @@ export function registerRobloxShim(lua, opts) { || className === 'ScrollingFrame') { inst = newGuiInstance(className); guiByLocalRef.set(inst.__guiLocalRef, inst); + } else if (className === 'Team') { + inst = newInstance('Team', 'Team'); + inst.TeamColor = { Name: 'Bright red', Color: new RbxColor3(0.77, 0.2, 0.2) }; + inst.Score = 0; + inst.AutoAssignable = true; + inst.PlayerAdded = makeSignal(); + inst.PlayerRemoved = makeSignal(); + inst.GetPlayers = function () { + return (players?.Children || []).filter(p => p.Team === this); + }; + // Регистрация в teams сервисе при Parent = teams + Object.defineProperty(inst, 'Parent', { + get() { return this._parent; }, + set(v) { + this._parent = v; + if (v === teams && !teams.Children.includes(this)) { + teams.Children.push(this); + } + }, + }); } else if (className === 'Tool' || className === 'HopperBin') { inst = newInstance(className, 'Tool'); inst.Equipped = makeSignal(); @@ -1265,18 +1395,54 @@ export function registerRobloxShim(lua, opts) { || className === 'Vector3Value' || className === 'Color3Value' || className === 'BrickColorValue' || className === 'RayValue') { inst = newInstance(className, className); - inst.Value = className === 'BoolValue' ? false + let _val = className === 'BoolValue' ? false : className === 'StringValue' ? '' : (className === 'IntValue' || className === 'NumberValue') ? 0 : undefined; inst.Changed = makeSignal(); + // Реактивное поле Value — фейерим Changed + обновляем leaderstats + // если этот *Value лежит внутри leaderstats-родителя (Roblox-pattern). + Object.defineProperty(inst, 'Value', { + get() { return _val; }, + set(v) { + _val = v; + try { inst.Changed.Fire(v); } catch (_) {} + // Если этот IntValue — leaderstat (родитель Name=leaderstats): + if (inst.Parent && inst.Parent.Name === 'leaderstats') { + send('leaderstatSet', { + playerName: inst.Parent.Parent?.Name || 'Player', + statName: inst.Name || 'Stat', + value: Number(v) || 0, + }); + } + }, + }); } else if (className === 'BodyForce' || className === 'BodyVelocity' || className === 'BodyPosition' || className === 'BodyGyro' || className === 'BodyAngularVelocity' || className === 'BodyThrust') { inst = newInstance(className, className); + let _vel = new RbxVector3(0, 0, 0); + Object.defineProperty(inst, 'velocity', { + get() { return _vel; }, + set(v) { + _vel = v; + // Эвристика батута: BodyVelocity с +Y и Parent=Torso/HRP + // = "толкаем игрока вверх". Если это игрок — шлём jumpVelocity. + if (className === 'BodyVelocity' && v && v.Y > 10) { + const p = inst.Parent; + if (p && (p.Name === 'Torso' || p.Name === 'HumanoidRootPart' || + p.Name === 'UpperTorso')) { + send('playerSet', { prop: 'jumpVelocity', value: v.Y * 0.3 }); + } + } + }, + }); + Object.defineProperty(inst, 'Velocity', { + get() { return _vel; }, + set(v) { inst.velocity = v; }, + }); inst.force = new RbxVector3(0, 0, 0); inst.Force = inst.force; - inst.Velocity = new RbxVector3(0, 0, 0); inst.MaxForce = new RbxVector3(0, 0, 0); inst.P = 1000; inst.D = 100; } else if (className === 'Weld' || className === 'WeldConstraint' @@ -1478,8 +1644,32 @@ export function registerRobloxShim(lua, opts) { workspace.Children.push(part); partById.set(Number(p.id), part); } + // Teams из импорта .rbxl — создаём Team-инстансы в teams сервисе + const teamsList = snap?.teams || []; + if (teamsList.length > 0 && teams.Children.length === 0) { + for (const t of teamsList) { + const team = newInstance('Team', String(t.name || 'Team')); + team.TeamColor = { + Name: String(t.name || 'White'), + Color: RbxColor3.fromHex(t.color_hex || '#ffffff'), + }; + team.Score = 0; + team.AutoAssignable = !!t.auto_assignable; + team.PlayerAdded = makeSignal(); + team.PlayerRemoved = makeSignal(); + team._parent = teams; + teams.Children.push(team); + } + // Авто-назначение игрока в первую auto_assignable команду + const first = teams.Children.find(t => t.AutoAssignable); + if (first) { + localPlayer.Team = first; + localPlayer.TeamColor = first.TeamColor; + localPlayer.Neutral = false; + } + } // eslint-disable-next-line no-console - console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts`); + console.log(`[shim] DataModel: workspace has ${workspace.Children.length} children, ${partById.size} parts, ${teams.Children.length} teams`); } catch (e) { send('log', { level: 'error', text: `[shim onSceneSnapshot] ${e?.message || e}` }); }