diff --git a/AutoConnect/AutoSetup.vbs b/AutoConnect/AutoSetup.vbs new file mode 100644 index 0000000..f898cc8 --- /dev/null +++ b/AutoConnect/AutoSetup.vbs @@ -0,0 +1,17 @@ +Set WshShell = CreateObject("WScript.Shell") + +' Wait for Roblox Studio +WScript.Sleep 2000 + +' Bring Roblox Studio to front +WshShell.AppActivate "Roblox Studio" +WScript.Sleep 500 + +' Send Ctrl+V to paste +WshShell.SendKeys "^v" +WScript.Sleep 500 + +MsgBox "Script pasted! Now:" & vbCrLf & vbCrLf & _ + "1. Press Play (green ▶ button)" & vbCrLf & _ + "2. Look for [RobloxMCP] in Output window", _ + 0, "Roblox MCP Setup" diff --git a/AutoConnect/START-HERE.bat b/AutoConnect/START-HERE.bat new file mode 100644 index 0000000..34c561e --- /dev/null +++ b/AutoConnect/START-HERE.bat @@ -0,0 +1,29 @@ +@echo off +echo ===================================== +echo ROBLOX MCP - ONE-CLICK SETUP +echo ===================================== +echo. +echo STEP 1: Script copied to clipboard! +echo. +echo STEP 2: Do this in Roblox Studio: +echo - Go to Explorer +echo - Open ServerScriptService +echo - Right-click, Insert Object, Script +echo - Press Ctrl+V to paste +echo - Press Play (green arrow) +echo. +echo STEP 3: Enable HTTP if needed: +echo - File, Game Settings, Security +echo - Enable BOTH HTTP options +echo. +pause + +echo Checking connection... +curl -s http://127.0.0.1:37423/health +echo. +pause + +curl -s http://127.0.0.1:37423/health +echo. +echo If you see "studioConnected": true above, it works! +pause diff --git a/AutoConnect/Setup-MCP.bat b/AutoConnect/Setup-MCP.bat new file mode 100644 index 0000000..002b862 --- /dev/null +++ b/AutoConnect/Setup-MCP.bat @@ -0,0 +1,26 @@ +@echo off +echo === Roblox MCP Connection Helper === +echo. + +REM Check MCP server +curl -s http://127.0.0.1:37423/health +echo. +echo. + +echo === INSTRUCTIONS === +echo. +echo In Roblox Studio: +echo 1. File ^> Game Settings ^> Security +echo 2. Enable BOTH HTTP options +echo 3. ServerScriptService ^> Right-click ^> Insert Object ^> Script +echo 4. Paste the script from: roblox-plugin\RobloxMCPServer_HTTP.lua +echo 5. Press Play (green ^> button) +echo. +echo You should see: [RobloxMCP] Starting Roblox MCP Server +echo. + +pause +curl -s http://127.0.0.1:37423/health +echo. +echo. +pause diff --git a/AutoConnect/Setup-MCP.ps1 b/AutoConnect/Setup-MCP.ps1 new file mode 100644 index 0000000..68b54f7 --- /dev/null +++ b/AutoConnect/Setup-MCP.ps1 @@ -0,0 +1,86 @@ +# Roblox MCP Connection Helper +# This script helps set up the Roblox MCP connection + +Write-Host "=== Roblox MCP Connection Helper ===" -ForegroundColor Cyan +Write-Host "" + +# Check if Roblox Studio is running +$robloxProcess = Get-Process | Where-Object {$_.Name -like "*RobloxStudio*"} + +if ($robloxProcess) { + Write-Host "✓ Roblox Studio is running (PID: $($robloxProcess.Id))" -ForegroundColor Green +} else { + Write-Host "✗ Roblox Studio is NOT running" -ForegroundColor Red + Write-Host " Please start Roblox Studio first" -ForegroundColor Yellow + exit 1 +} + +Write-Host "" +Write-Host "=== Setup Instructions ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "1. In Roblox Studio, go to:" -ForegroundColor White +Write-Host " File → Game Settings → Security" -ForegroundColor Yellow +Write-Host "" +Write-Host "2. Enable BOTH options:" -ForegroundColor White +Write-Host " ☑ Enable Studio Access to API Services" -ForegroundColor Yellow +Write-Host " ☑ Allow HTTP Requests" -ForegroundColor Yellow +Write-Host "" +Write-Host "3. Click Save" -ForegroundColor Yellow +Write-Host "" +Write-Host "4. In Explorer → ServerScriptService → Right-click → Insert Object → Script" -ForegroundColor White +Write-Host "" +Write-Host "5. Copy the script below and paste it into the Script:" -ForegroundColor White +Write-Host "" + +# Read the script file +$scriptPath = "C:\Users\Admin\roblox-mcp-server\roblox-plugin\RobloxMCPServer_HTTP.lua" +if (Test-Path $scriptPath) { + $scriptContent = Get-Content $scriptPath -Raw + + # Copy to clipboard + Set-Clipboard -Value $scriptContent + + Write-Host " [SCRIPT COPIED TO CLIPBOARD]" -ForegroundColor Green + Write-Host " Just paste it in Roblox Studio (Ctrl+V)" -ForegroundColor Green + Write-Host "" +} else { + Write-Host " ✗ Script file not found: $scriptPath" -ForegroundColor Red +} + +Write-Host "6. Press Play (green ▶ button) in Roblox Studio" -ForegroundColor White +Write-Host "" +Write-Host "7. You should see: [RobloxMCP] Starting Roblox MCP Server" -ForegroundColor Green +Write-Host "" + +# Check MCP server status +Write-Host "=== Checking MCP Server ===" -ForegroundColor Cyan +try { + $response = Invoke-RestMethod -Uri "http://127.0.0.1:37423/health" -TimeoutSec 2 + Write-Host "✓ MCP Server is running" -ForegroundColor Green + Write-Host " Status: $($response.status)" -ForegroundColor White + Write-Host " Connected: $($response.studioConnected)" -ForegroundColor White +} catch { + Write-Host "✗ MCP Server is NOT responding" -ForegroundColor Red + Write-Host " Make sure to run: npm start" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "Press any key to check connection again..." -ForegroundColor Cyan +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + +# Re-check +try { + $response = Invoke-RestMethod -Uri "http://127.0.0.1:37423/health" -TimeoutSec 2 + if ($response.studioConnected -or $response.pendingCommands -gt 0) { + Write-Host "" + Write-Host "✓✓✓ ROBLOX STUDIO IS CONNECTED! ✓✓✓" -ForegroundColor Green + Write-Host "" + Write-Host "You can now ask Claude to create things in Roblox!" -ForegroundColor Cyan + } else { + Write-Host "" + Write-Host "Still waiting for connection..." -ForegroundColor Yellow + Write-Host "Make sure you pressed Play in Roblox Studio!" -ForegroundColor Yellow + } +} catch { + Write-Host "✗ Cannot reach MCP server" -ForegroundColor Red +} diff --git a/README.md b/README.md index ab1b3ba..7255653 100644 --- a/README.md +++ b/README.md @@ -1,217 +1,349 @@ -# Roblox MCP Server +# ClaudeCode-Roblox-Studio-MCP -Control Roblox Studio directly from Claude Code using the Model Context Protocol (MCP). +A Model Context Protocol (MCP) server that enables **Claude Code** (by Anthropic) to directly control **Roblox Studio** - create games, manipulate objects, write scripts, and build experiences through natural language commands. + +## Overview + +This project creates a bidirectional bridge between Claude Code and Roblox Studio using: +- **MCP Protocol** (stdio) for Claude Code communication +- **HTTP Polling** for Roblox Studio plugin communication +- **Express.js** server for command queuing and result handling + +## Architecture Diagram + +``` +┌─────────────────┐ MCP (stdio) ┌──────────────────┐ HTTP Polling ┌─────────────────┐ +│ Claude Code │ ◄─────────────────► │ Node.js MCP │ ◄──────────────────► │ Roblox Studio │ +│ (AI Agent) │ │ Server │ (port 37423) │ Plugin │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ + ▼ + Express HTTP + (Health: 37423) +``` + +### How It Works + +1. **Claude Code** connects to the MCP server via stdio +2. **MCP Server** exposes tools that Claude can call (create_part, create_script, etc.) +3. **Commands** are queued in the MCP server's memory +4. **Roblox Plugin** polls the HTTP endpoint for new commands +5. **Plugin executes** commands in Roblox Studio and sends results back +6. **Results** are returned to Claude Code via the MCP protocol ## Features -- Create and modify scripts in Roblox Studio -- Create 3D parts, models, and folders +- Create 3D parts, models, folders, and entire scenes +- Write and inject Lua scripts (Script, LocalScript, ModuleScript) - Build GUI elements (ScreenGui, Frame, TextButton, etc.) -- Set properties on any object -- Get hierarchy information -- Execute Lua code -- Play/Stop playtesting -- Save places - -## Architecture - -``` -Claude Code <--(MCP)--> Node.js MCP Server <--(WebSocket)--> Roblox Studio Plugin -``` +- Manipulate workspace hierarchy +- Execute arbitrary Lua code +- Playtest and save places automatically +- Python injection scripts for direct command bar automation ## Installation -### 1. Install Node.js Dependencies +### Prerequisites + +- **Node.js** 18+ (for MCP server) +- **Claude Code** (by Anthropic) +- **Roblox Studio** (installed) +- Windows 11 or macOS + +### Step 1: Clone and Install ```bash -cd roblox-mcp-server +cd ~ +git clone https://github.rommark.dev/admin/ClaudeCode-Roblox-Studio-MCP.git +cd ClaudeCode-Roblox-Studio-MCP npm install ``` -### 2. Configure Claude Code +### Step 2: Configure Claude Code -Add this to your Claude Code settings (or create `.clauderc` in your home directory): +Add to your Claude Code config (`~/.claude/config.json` or `.clauderc`): ```json { "mcpServers": { "roblox-studio": { "command": "node", - "args": ["/mnt/c/Users/Admin/roblox-mcp-server/src/index.js"], - "cwd": "/mnt/c/Users/Admin/roblox-mcp-server" + "args": ["~/ClaudeCode-Roblox-Studio-MCP/src/index.js"], + "cwd": "~/ClaudeCode-Roblox-Studio-MCP" } } } ``` -### 3. Install the Roblox Studio Plugin +### Step 3: Install Roblox Studio Plugin -#### Option A: Manual Installation +**Windows:** +```powershell +Copy-Item roblox-plugin\RobloxMCPPlugin.lua $env:LOCALAPPDATA\Roblox\Plugins\ +``` -1. Copy `roblox-plugin/RobloxMCPServer.lua` to: - - **Windows**: `C:\Users\YOUR_USERNAME\AppData\Local\Roblox\Plugins\RobloxMCPServer.lua` - - **Mac**: `~/Library/Application Support/Roblox/Plugins/RobloxMCPServer.lua` +**Mac:** +```bash +cp roblox-plugin/RobloxMCPPlugin.lua ~/Library/Application\ Support/Roblox/Plugins/ +``` -2. Open Roblox Studio - -3. Go to **Plugins → Plugin Management** - -4. Find "RobloxMCPServer" and enable it - -#### Option B: In-Place Installation - -1. Open Roblox Studio - -2. Create a new place or open an existing one - -3. In **ServerScriptService**, create a new **Script** - -4. Paste the contents of `roblox-plugin/RobloxMCPServer.lua` - -5. The server will auto-start when you press Play - -### 4. Enable HTTP Requests (Important!) +### Step 4: Enable HTTP Requests (Critical!) In Roblox Studio: -1. Go to **Game Settings → Security** -2. **Enable** "Allow HTTP Requests" -3. Set **Enable Studio Access to API Services** to ON +1. **File** → **Game Settings** +2. **Security** tab +3. Enable **"Enable Studio Access to API Services"** +4. Enable **"Allow HTTP Requests"** -## Usage +### Step 5: Start Using -### Starting the MCP Server +1. Start the MCP server: `npm start` +2. Open Roblox Studio +3. The plugin will auto-connect (look for "RobloxMCP" toolbar) +4. Start chatting with Claude Code to build your game! -```bash -npm start +## Available MCP Tools + +| Tool | Description | Example | +|------|-------------|---------| +| `roblox_create_script` | Create Script/LocalScript/ModuleScript | "Create a Script in Workspace that prints hello" | +| `roblox_create_part` | Create 3D parts (Block, Ball, Cylinder, Wedge, CornerWedge) | "Create a red block at position 5, 10, 0" | +| `roblox_create_model` | Create model containers | "Create a model named Weapons in Workspace" | +| `roblox_create_folder` | Create folders for organization | "Create a folder named Scripts in Workspace" | +| `roblox_create_gui` | Create GUI elements | "Create a ScreenGui with a start button" | +| `roblox_set_property` | Set properties on objects | "Set the color of Workspace.Part1 to blue" | +| `roblox_get_hierarchy` | Explore object tree | "Show me the Workspace hierarchy" | +| `roblox_delete_object` | Delete objects by path | "Delete Workspace.OldPart" | +| `roblox_execute_code` | Run arbitrary Lua code | "Execute: print('Hello from Claude')" | +| `roblox_play` | Start playtest | "Start playtest in Client mode" | +| `roblox_stop` | Stop playtest | "Stop the playtest" | +| `roblox_save_place` | Save current place | "Save the place" | + +## Usage Examples + +### Creating a Simple Game + +Ask Claude: +``` +Create an obstacle course game with: +- A green starting platform at 0, 1, 0 +- 5 red checkpoint platforms going upward +- A spinning part at the end +- A kill brick floor called Lava ``` -The server will start on: -- HTTP: `http://localhost:37423` (for health checks) -- WebSocket: `ws://localhost:37424` (for Roblox Studio communication) +### Building a GUI -### Starting Roblox Studio Communication - -1. Open Roblox Studio with the plugin installed -2. Press **Play** to start the server script -3. You should see a status indicator in the top-right corner -4. The MCP server will automatically connect - -### Using with Claude Code - -Once connected, you can ask Claude to: - -```bash -# Create a script -"Create a Script in Workspace that prints 'Hello World'" - -# Create a part -"Create a red block part at position 0, 10, 0 in Workspace" - -# Build a GUI -"Create a ScreenGui with a TextButton that says 'Click Me'" - -# Get hierarchy -"Show me the hierarchy of Workspace" - -# Execute code -"Execute: print('Testing command')" +``` +Create a ScreenGui with: +- A dark semi-transparent frame +- A title saying "MY GAME" +- A green start button +- Make the button print "Started!" when clicked ``` -## Available Tools +### Scripting -| Tool | Description | -|------|-------------| -| `roblox_create_script` | Create a Script, LocalScript, or ModuleScript | -| `roblox_create_part` | Create 3D parts (Block, Ball, Cylinder, etc.) | -| `roblox_create_model` | Create model containers | -| `roblox_create_folder` | Create folders for organization | -| `roblox_create_gui` | Create GUI elements | -| `roblox_set_property` | Set properties on existing objects | -| `roblox_get_hierarchy` | Get the object hierarchy | -| `roblox_delete_object` | Delete an object by path | -| `roblox_execute_code` | Execute arbitrary Lua code | -| `roblox_play` | Start playtest | -| `roblox_stop` | Stop playtest | -| `roblox_save_place` | Save the current place | +``` +Create a Script in Workspace.Part that makes it spin continuously +``` + +## Python Injection Scripts + +The `examples/` folder includes Python scripts for direct injection into Roblox Studio: + +### studio-inject.py +Inject a single Lua script into the Roblox Studio command bar using Win32 API. + +```bash +python examples/studio-inject.py +``` + +### inject-all-parts.py +Inject all 5 parts of the FPS game sequentially into Roblox Studio. + +```bash +python examples/inject-all-parts.py +``` + +**Requirements:** +- Windows 11 +- Roblox Studio must be open +- Python 3.x + +## Examples Folder + +The `examples/` folder contains: +- `fps-game/` - Complete 5-part FPS game setup (CoD style) +- `demo_game.lua` - Simple obby game example +- `spinning_part.lua` - Rotating part script +- `start_button.lua` - Start button GUI + +### FPS Game Parts + +1. **Part 1**: Map + Infrastructure (buildings, cover, lighting) +2. **Part 2**: Weapon System (weapon data module, client weapon controller) +3. **Part 3**: Enemy AI + Server Handler (AI bots, game server script) +4. **Part 4**: HUD + Player Scripts (crosshair, minimap, damage effects) +5. **Part 5**: Weapon Client Script (final weapon controller) + +## Project Structure + +``` +ClaudeCode-Roblox-Studio-MCP/ +├── src/ +│ └── index.js # Main MCP server + Express/HTTP +├── roblox-plugin/ +│ └── RobloxMCPPlugin.lua # HTTP polling plugin for Studio +├── examples/ +│ ├── fps-game/ +│ │ ├── part1_map.lua # Map infrastructure +│ │ ├── part2_weapons.lua # Weapon system +│ │ ├── part3_ai.lua # Enemy AI + server +│ │ ├── part4_hud.lua # HUD + player scripts +│ │ └── part5_client.lua # Weapon client script +│ ├── studio-inject.py # Single script injection +│ └── inject-all-parts.py # Multi-part injection +├── package.json +└── README.md # This file +``` ## Configuration -Edit `src/index.js` to change ports: +### MCP Server Ports + +Edit `src/index.js`: ```javascript -const HTTP_PORT = 37423; // Health check endpoint -const WS_PORT = 37424; // WebSocket for Roblox Studio +const HTTP_PORT = 37423; // HTTP polling endpoint +const WS_PORT = 37424; // WebSocket (optional, for future use) ``` -Edit `roblox-plugin/RobloxMCPServer.lua` to change plugin settings: +### Roblox Plugin Configuration + +Edit `roblox-plugin/RobloxMCPPlugin.lua`: ```lua local CONFIG = { - PORT = 37425, - POLL_INTERVAL = 0.1, - DEBUG = true, + HOST = "localhost", + PORT = 37423, + POLL_INTERVAL = 0.5, -- seconds between polls } ``` ## Troubleshooting -### "No Roblox Studio instance connected" +### "MCP server not reachable" -- Make sure Roblox Studio is open -- Make sure the server script is running (press Play) -- Check that HTTP requests are enabled in Game Settings -- Look for the status indicator in the top-right corner +**Solution:** +- Make sure the MCP server is running: `npm start` +- Check port 37423 is not in use +- Verify Claude Code config is correct -### WebSocket Connection Failed +### "HTTP Requests Blocked" -- Check that the MCP server is running (`npm start`) -- Verify the WS_PORT matches between server and plugin -- Check Windows Firewall if connection is refused +**Solution:** +- Game Settings → Security +- Enable both HTTP-related options +- Restart Roblox Studio after changing -### Scripts Not Executing +### Plugin Not Connecting -- Make sure the script type is correct (Script vs LocalScript vs ModuleScript) -- Check the Output window in Roblox Studio for errors -- Verify the parent path is correct +**Solution:** +- Check Output window in Roblox Studio +- Verify plugin is in correct Plugins folder +- Make sure HttpService is enabled +- Try clicking the toolbar button manually -### HTTP Requests Blocked +### Commands Not Executing -- Go to Game Settings → Security -- Enable "Allow HTTP Requests" -- Enable "Enable Studio Access to API Services" +**Solution:** +- Check for Lua errors in Output window +- Verify parent paths exist +- Make sure object names don't contain special characters + +## Security Considerations + +⚠️ **Important Security Notes:** + +- This plugin allows executing arbitrary Lua code +- Only use in trusted development environments +- HTTP requests must be enabled (security trade-off) +- Consider using a reverse proxy for production +- Review all code before execution in production games ## Development -### Project Structure - -``` -roblox-mcp-server/ -├── src/ -│ └── index.js # MCP server + WebSocket server -├── roblox-plugin/ -│ ├── RobloxMCPPlugin.lua # Toolbar plugin (optional) -│ └── RobloxMCPServer.lua # Server script (required) -├── package.json -└── README.md -``` - ### Adding New Tools -1. Add the tool definition to `src/index.js` in the `ListToolsRequestSchema` handler -2. Add a case in the `CallToolRequestSchema` handler -3. Add a corresponding handler in `roblox-plugin/RobloxMCPServer.lua` +1. Add tool definition in `src/index.js` (in `ListToolsRequestSchema` handler) +2. Add handler in `CallToolRequestSchema` handler +3. Implement in `roblox-plugin/RobloxMCPPlugin.lua` (add to `handleCommand` function) -## Security Notes +Example: +```javascript +// src/index.js +{ + name: 'roblox_my_tool', + description: 'Does something cool', + inputSchema: { + type: 'object', + properties: { + param1: { type: 'string', description: 'Description' }, + }, + required: ['param1'], + }, +} +``` -- This plugin allows executing arbitrary Lua code in Roblox Studio -- Only use in trusted environments -- HTTP requests must be enabled in Roblox Studio -- Consider using a reverse proxy for production deployments - -## License - -MIT +```lua +-- roblox-plugin/RobloxMCPPlugin.lua +elseif command == "myTool" then + -- Your implementation + return { success = true, result = "something" } +``` ## Contributing -Contributions welcome! Feel free to open issues or pull requests. +Contributions welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request + +## License + +MIT License - feel free to use in your projects! + +## Credits + +- Built with [Model Context Protocol](https://github.com/modelcontextprotocol) by Anthropic +- Powered by [Claude Code](https://claude.ai/code) +- For Roblox Studio game development + +## Support + +For issues, questions, or suggestions: +- Open an issue on GitHub +- Check the Troubleshooting section +- Review the examples in `examples/` + +## Changelog + +### v2.0.0 (2025-03-31) +- Updated to HTTP polling architecture +- Added Python injection scripts +- Added complete FPS game example +- Improved plugin with toolbar UI +- Added comprehensive examples folder + +### v1.0.0 (2025-01-29) +- Initial release +- 12 MCP tools implemented +- WebSocket communication (deprecated) +- Full CRUD operations for Roblox objects + +--- + +**Made with ❤️ for Roblox developers using AI** diff --git a/examples/fps-game/part1_map.lua b/examples/fps-game/part1_map.lua new file mode 100644 index 0000000..536b7e8 --- /dev/null +++ b/examples/fps-game/part1_map.lua @@ -0,0 +1,242 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- MINI CALL OF DUTY - FPS Game Setup (Part 1: Map + Infrastructure) +-- Inject into Roblox Studio Command Bar +-- ═══════════════════════════════════════════════════════════════════ + +-- Clean workspace +for _, c in ipairs(workspace:GetChildren()) do + if not c:IsA("Terrain") and not c:IsA("Camera") then c:Destroy() end +end +-- Clean services +for _, c in ipairs(game:GetService("ReplicatedStorage"):GetChildren()) do c:Destroy() end +for _, c in ipairs(game:GetService("StarterGui"):GetChildren()) do c:Destroy() end +for _, c in ipairs(game:GetService("ServerScriptService"):GetChildren()) do c:Destroy() end +for _, s in ipairs({"StarterPlayerScripts", "StarterPlayer"}) do + local f = game:GetService("StarterPlayer"):FindFirstChild(s) + if f then for _, c in ipairs(f:GetChildren()) do c:Destroy() end end +end + +local Lighting = game:GetService("Lighting") +for _, c in ipairs(Lighting:GetChildren()) do c:Destroy() end + +-- ═══════════════════════════════════════════════════════════════ +-- FOLDERS & REMOTES +-- ═══════════════════════════════════════════════════════════════ +local RS = game:GetService("ReplicatedStorage") + +local events = Instance.new("Folder") events.Name = "Events" events.Parent = RS +Instance.new("RemoteEvent", events).Name = "ShootEvent" +Instance.new("RemoteEvent", events).Name = "HitEvent" +Instance.new("RemoteEvent", events).Name = "KillEvent" +Instance.new("RemoteEvent", events).Name = "DamageEvent" +Instance.new("RemoteEvent", events).Name = "ReloadEvent" +Instance.new("RemoteFunction", events).Name = "GetGameData" + +local shared = Instance.new("Folder") shared.Name = "Shared" shared.Parent = RS +local assets = Instance.new("Folder") assets.Name = "Assets" assets.Parent = RS + +-- ═══════════════════════════════════════════════════════════════ +-- MAP BUILDING - Urban Military Zone +-- ═══════════════════════════════════════════════════════════════ +local mapModel = Instance.new("Model") mapModel.Name = "Map" mapModel.Parent = workspace + +local function P(props) + local p = Instance.new("Part") + p.Anchored = true + p.TopSurface = Enum.SurfaceType.Smooth + p.BottomSurface = Enum.SurfaceType.Smooth + for k,v in pairs(props) do p[k] = v end + p.Parent = props.Parent or mapModel + return p +end + +local function W(props) + local w = Instance.new("WedgePart") + w.Anchored = true + for k,v in pairs(props) do w[k] = v end + w.Parent = props.Parent or mapModel + return w +end + +-- Ground +P({Name="Ground", Size=Vector3.new(400,2,400), Position=Vector3.new(0,-1,0), + Color=Color3.fromRGB(80,78,70), Material=Enum.Material.Asphalt}) + +-- Spawn area +P({Name="SpawnPad", Size=Vector3.new(20,1,20), Position=Vector3.new(0,0.5,-160), + Color=Color3.fromRGB(30,120,30), Material=Enum.Material.SmoothPlastic}) + +local spawnLoc = Instance.new("SpawnLocation") +spawnLoc.Name = "PlayerSpawn" +spawnLoc.Size = Vector3.new(8,1,8) +spawnLoc.Position = Vector3.new(0,1,-155) +spawnLoc.Anchored = true +spawnLoc.CanCollide = false +spawnLoc.Transparency = 0.5 +spawnLoc.Color = Color3.fromRGB(0,255,0) +spawnLoc.Parent = mapModel + +-- ─── BUILDINGS ─── +local function makeBuilding(x, z, w, d, h, color) + local bldg = Instance.new("Model") bldg.Name = "Building" bldg.Parent = mapModel + -- Floor + P({Name="Floor", Size=Vector3.new(w,1,d), Position=Vector3.new(x,0.5,z), + Color=Color3.fromRGB(100,95,85), Material=Enum.Material.SmoothPlastic, Parent=bldg}) + -- Walls + P({Name="WallN", Size=Vector3.new(w,h,1), Position=Vector3.new(x,h/2+1,z-d/2), + Color=color, Material=Enum.Material.Brick, Parent=bldg}) + P({Name="WallS", Size=Vector3.new(w,h,1), Position=Vector3.new(x,h/2+1,z+d/2), + Color=color, Material=Enum.Material.Brick, Parent=bldg}) + P({Name="WallE", Size=Vector3.new(1,h,d), Position=Vector3.new(x+w/2,h/2+1,z), + Color=color, Material=Enum.Material.Brick, Parent=bldg}) + P({Name="WallW", Size=Vector3.new(1,h,d), Position=Vector3.new(x-w/2,h/2+1,z), + Color=color, Material=Enum.Material.Brick, Parent=bldg}) + -- Roof + P({Name="Roof", Size=Vector3.new(w+2,1,d+2), Position=Vector3.new(x,h+1,z), + Color=Color3.fromRGB(60,55,50), Material=Enum.Material.CorrodedMetal, Parent=bldg}) + -- Door opening (destroy wall segment) + -- Window holes + P({Name="WinN1", Size=Vector3.new(4,3,1.2), Position=Vector3.new(x+3,h/2,z-d/2), + Color=Color3.fromRGB(135,135,135), Material=Enum.Material.SmoothPlastic, Parent=bldg}) + P({Name="WinN2", Size=Vector3.new(4,3,1.2), Position=Vector3.new(x-3,h/2,z-d/2), + Color=Color3.fromRGB(135,135,135), Material=Enum.Material.SmoothPlastic, Parent=bldg}) + return bldg +end + +-- Main buildings +makeBuilding(-50, -60, 30, 20, 12, Color3.fromRGB(140,130,120)) -- HQ building +makeBuilding(50, -60, 25, 25, 10, Color3.fromRGB(130,125,115)) -- Barracks +makeBuilding(-50, 40, 20, 30, 14, Color3.fromRGB(120,115,110)) -- Tower building +makeBuilding(50, 50, 28, 22, 10, Color3.fromRGB(125,120,110)) -- Warehouse +makeBuilding(0, 50, 22, 18, 8, Color3.fromRGB(145,135,125)) -- Center building + +-- Ruined building (half walls) +P({Name="RuinedWall1", Size=Vector3.new(12,6,1), Position=Vector3.new(-20,3,0), + Color=Color3.fromRGB(100,95,85), Material=Enum.Material.Brick}) +P({Name="RuinedWall2", Size=Vector3.new(1,4,8), Position=Vector3.new(-14,2,-4), + Color=Color3.fromRGB(100,95,85), Material=Enum.Material.Brick}) +P({Name="RuinedFloor", Size=Vector3.new(15,1,10), Position=Vector3.new(-20,0.5,0), + Color=Color3.fromRGB(90,85,75), Material=Enum.Material.Concrete}) + +-- ─── COVER OBJECTS ─── +local coverPositions = { + {-30,-120, 4,3,8}, {30,-120, 4,3,8}, {-10,-100, 6,2,4}, {10,-100, 6,2,4}, + {-40,-30, 3,2,6}, {40,-30, 3,2,6}, {-25,10, 5,2,3}, {25,10, 5,2,3}, + {0,-20, 4,3,4}, {-15,30, 3,2,5}, {15,30, 3,2,5}, + {-60,0, 4,3,8}, {60,0, 4,3,8}, + {-35,80, 5,2,4}, {35,80, 5,2,4}, {0,80, 3,2,6}, + {-20,-50, 3,2,3}, {20,-50, 3,2,3}, + {-70,-60, 4,3,6}, {70,-60, 4,3,6}, + {0,120, 6,2,4}, {-40,120, 4,3,5}, {40,120, 4,3,5}, +} +for i, pos in ipairs(coverPositions) do + P({Name="Cover_"..i, Size=Vector3.new(pos[4],pos[5],pos[6]), + Position=Vector3.new(pos[1],pos[5]/2+0.5,pos[2]), + Color=Color3.fromRGB(90+i*2,85+i*2,75+i*2), Material=Enum.Material.Concrete}) +end + +-- ─── SANDBAG WALLS ─── +for i = 1, 20 do + local angle = (i/20) * math.pi * 2 + local r = 85 + P({Name="Sandbag_"..i, Size=Vector3.new(6,3,3), + Position=Vector3.new(math.cos(angle)*r, 1.5, math.sin(angle)*r), + Orientation=Vector3.new(0, math.deg(angle), 0), + Color=Color3.fromRGB(160,145,110), Material=Enum.Material.Slate}) +end + +-- ─── WATCHTOWER ─── +local function makeTower(x, z) + P({Name="TowerBase_"..x, Size=Vector3.new(6,0.5,6), Position=Vector3.new(x,8,z), + Color=Color3.fromRGB(80,70,60), Material=Enum.Material.Wood}) + -- Legs + P({Name="Leg1", Size=Vector3.new(1,16,1), Position=Vector3.new(x-2,8,z-2), + Color=Color3.fromRGB(70,60,50), Material=Enum.Material.Wood}) + P({Name="Leg2", Size=Vector3.new(1,16,1), Position=Vector3.new(x+2,8,z-2), + Color=Color3.fromRGB(70,60,50), Material=Enum.Material.Wood}) + P({Name="Leg3", Size=Vector3.new(1,16,1), Position=Vector3.new(x-2,8,z+2), + Color=Color3.fromRGB(70,60,50), Material=Enum.Material.Wood}) + P({Name="Leg4", Size=Vector3.new(1,16,1), Position=Vector3.new(x+2,8,z+2), + Color=Color3.fromRGB(70,60,50), Material=Enum.Material.Wood}) + -- Railing + P({Name="Rail1", Size=Vector3.new(6,2,0.3), Position=Vector3.new(x,9,z-2.85), + Color=Color3.fromRGB(70,60,50), Material=Enum.Material.Wood}) + P({Name="Rail2", Size=Vector3.new(6,2,0.3), Position=Vector3.new(x,9,z+2.85), + Color=Color3.fromRGB(70,60,50), Material=Enum.Material.Wood}) +end +makeTower(-70, -80) +makeTower(70, -80) +makeTower(-70, 90) +makeTower(70, 90) + +-- ─── CRATES ─── +for i = 1, 15 do + local cx = math.random(-80, 80) + local cz = math.random(-140, 140) + P({Name="Crate_"..i, Size=Vector3.new(3,3,3), + Position=Vector3.new(cx, 1.5, cz), + Orientation=Vector3.new(0, math.random(0,90), 0), + Color=Color3.fromRGB(140,110,60), Material=Enum.Material.Wood}) +end + +-- ─── BARRELS ─── +for i = 1, 10 do + local bx = math.random(-90, 90) + local bz = math.random(-140, 140) + P({Name="Barrel_"..i, Shape=Enum.PartType.Cylinder, Size=Vector3.new(3,3,3), + Position=Vector3.new(bx, 1.5, bz), + Orientation=Vector3.new(0, math.random(0,180), 90), + Color=Color3.fromRGB(50,60,50), Material=Enum.Material.SmoothPlastic}) +end + +-- ─── MAP BOUNDARY WALLS ─── +P({Name="BorderN", Size=Vector3.new(200,15,3), Position=Vector3.new(0,7.5,-180), + Color=Color3.fromRGB(60,60,60), Material=Enum.Material.Concrete}) +P({Name="BorderS", Size=Vector3.new(200,15,3), Position=Vector3.new(0,7.5,180), + Color=Color3.fromRGB(60,60,60), Material=Enum.Material.Concrete}) +P({Name="BorderE", Size=Vector3.new(3,15,200), Position=Vector3.new(98,7.5,0), + Color=Color3.fromRGB(60,60,60), Material=Enum.Material.Concrete}) +P({Name="BorderW", Size=Vector3.new(3,15,200), Position=Vector3.new(-98,7.5,0), + Color=Color3.fromRGB(60,60,60), Material=Enum.Material.Concrete}) + +-- ═══════════════════════════════════════════════════════════════ +-- LIGHTING - Dusk/Battle Atmosphere +-- ═══════════════════════════════════════════════════════════════ +Lighting.Ambient = Color3.fromRGB(80,75,70) +Lighting.OutdoorAmbient = Color3.fromRGB(100,90,80) +Lighting.Brightness = 1.2 +Lighting.ClockTime = 17.5 -- Dusk +Lighting.FogEnd = 400 +Lighting.FogStart = 50 +Lighting.FogColor = Color3.fromRGB(140,130,120) + +local atmo = Instance.new("Atmosphere") +atmo.Density = 0.25 +atmo.Color = Color3.fromRGB(180,165,145) +atmo.Decay = Color3.fromRGB(120,110,100) +atmo.Glare = 0.3 +atmo.Haze = 1.5 +atmo.Parent = Lighting + +-- Sun rays +local sunRays = Instance.new("SunRaysEffect") +sunRays.Intensity = 0.04 +sunRays.Spread = 0.6 +sunRays.Parent = Lighting + +-- Bloom +local bloom = Instance.new("BloomEffect") +bloom.Intensity = 0.3 +bloom.Size = 24 +bloom.Threshold = 1.5 +bloom.Parent = Lighting + +-- Color correction (warm wartime tones) +local cc = Instance.new("ColorCorrectionEffect") +cc.Brightness = 0.02 +cc.Contrast = 0.1 +cc.Saturation = -0.15 +cc.TintColor = Color3.fromRGB(255,240,220) +cc.Parent = Lighting + +print("[CoD FPS] Part 1/5 complete: Map built, lighting set.") diff --git a/examples/fps-game/part2_weapons.lua b/examples/fps-game/part2_weapons.lua new file mode 100644 index 0000000..3ee2665 --- /dev/null +++ b/examples/fps-game/part2_weapons.lua @@ -0,0 +1,383 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- MINI CALL OF DUTY - FPS Game Setup (Part 2: Weapon System) +-- ═══════════════════════════════════════════════════════════════════ + +local RS = game:GetService("ReplicatedStorage") + +-- ═══════════════════════════════════════════════════════════════ +-- WEAPON DATA MODULE +-- ═══════════════════════════════════════════════════════════════ +local weaponData = Instance.new("ModuleScript") +weaponData.Name = "WeaponData" +weaponData.Parent = RS:FindFirstChild("Shared") +weaponData.Source = [[ +local Weapons = { + M4A1 = { + name = "M4A1", + displayName = "M4A1 Carbine", + damage = 25, + fireRate = 0.09, -- seconds between shots + reloadTime = 2.2, + magSize = 30, + maxAmmo = 210, + range = 300, + headshotMult = 2.5, + recoil = {x = 0.8, y = 1.2}, + spread = {hip = 3, ads = 0.5}, + aimSpeed = 0.15, + moveSpeedMult = 0.95, + automatic = true, + adsFOV = 50, + }, + AK47 = { + name = "AK-47", + displayName = "AK-47", + damage = 33, + fireRate = 0.1, + reloadTime = 2.5, + magSize = 30, + maxAmmo = 210, + range = 280, + headshotMult = 2.0, + recoil = {x = 1.2, y = 1.8}, + spread = {hip = 4, ads = 0.8}, + aimSpeed = 0.18, + moveSpeedMult = 0.92, + automatic = true, + adsFOV = 48, + }, + Sniper = { + name = "AWP", + displayName = "AWP Sniper", + damage = 95, + fireRate = 1.2, + reloadTime = 3.5, + magSize = 5, + maxAmmo = 30, + range = 800, + headshotMult = 3.0, + recoil = {x = 3, y = 5}, + spread = {hip = 8, ads = 0.1}, + aimSpeed = 0.25, + moveSpeedMult = 0.85, + automatic = false, + adsFOV = 20, + }, + Shotgun = { + name = "SPAS-12", + displayName = "SPAS-12 Shotgun", + damage = 15, -- per pellet (8 pellets) + fireRate = 0.7, + reloadTime = 3.0, + magSize = 8, + maxAmmo = 40, + range = 50, + headshotMult = 1.5, + recoil = {x = 4, y = 6}, + spread = {hip = 12, ads = 8}, + aimSpeed = 0.15, + moveSpeedMult = 0.88, + automatic = false, + pellets = 8, + adsFOV = 55, + }, +} +return Weapons +]] + +-- ═══════════════════════════════════════════════════════════════ +-- CLIENT WEAPON CONTROLLER (LocalScript) +-- ═══════════════════════════════════════════════════════════════ +local weaponClient = Instance.new("LocalScript") +weaponClient.Name = "WeaponClient" +weaponClient.Parent = game:GetService("StarterPlayer"):FindFirstChild("StarterPlayerScripts") + +weaponClient.Source = [[ +local Players = game:GetService("Players") +local RS = game:GetService("ReplicatedStorage") +local UIS = game:GetService("UserInputService") +local RunService = game:GetService("RunService") +local Events = RS:WaitForChild("Events") + +local player = Players.LocalPlayer +local camera = workspace.CurrentCamera +local Weapons = require(RS:WaitForChild("Shared"):WaitForChild("WeaponData")) + +-- State +local currentWeapon = "M4A1" +local weapon = Weapons[currentWeapon] +local ammo = weapon.magSize +local reserveAmmo = weapon.maxAmmo +local isReloading = false +local isADS = false +local isSprinting = false +local isFiring = false +local lastFireTime = 0 +local canShoot = true + +-- Recoil tracking +local recoilX = 0 +local recoilY = 0 +local recoilRecoverySpeed = 8 + +-- Functions +local function updateHUD() + local hud = player.PlayerGui:FindFirstChild("FPS_HUD") + if not hud then return end + local frame = hud:FindFirstChild("MainFrame") + if not frame then return end + + local ammoText = frame:FindFirstChild("AmmoDisplay") + if ammoText then ammoText.Text = ammo .. " / " .. reserveAmmo end + + local weaponText = frame:FindFirstChild("WeaponName") + if weaponText then weaponText.Text = weapon.displayName end + + local healthBar = frame:FindFirstChild("HealthBar") + local healthFill = frame:FindFirstChild("HealthFill") + if healthBar and healthFill then + local char = player.Character + local hum = char and char:FindFirstChildOfClass("Humanoid") + if hum then + local pct = hum.Health / hum.MaxHealth + healthFill.Size = UDim2.new(pct * 0.18, 0, 0.025, 0) + if pct < 0.3 then + healthFill.BackgroundColor3 = Color3.fromRGB(200, 30, 30) + elseif pct < 0.6 then + healthFill.BackgroundColor3 = Color3.fromRGB(200, 180, 30) + else + healthFill.BackgroundColor3 = Color3.fromRGB(30, 200, 30) + end + end + end + + local scoreText = frame:FindFirstChild("ScoreDisplay") + if scoreText then scoreText.Text = "KILLS: " .. tostring(player:GetAttribute("Kills") or 0) end +end + +local function shoot() + if isReloading or ammo <= 0 or not canShoot then return end + if tick() - lastFireTime < weapon.fireRate then return end + + lastFireTime = tick() + ammo = ammo - 1 + canShoot = false + + -- Fire raycast + local mousePos = UIS:GetMouseLocation() + local ray = camera:ViewportPointToRay(mousePos.X, mousePos.Y) + + local spreadMult = isADS and weapon.spread.ads or weapon.spread.hip + local spread = CFrame.new( + math.random(-100, 100) / 100 * spreadMult, + math.random(-100, 100) / 100 * spreadMult, + math.random(-100, 100) / 100 * spreadMult + ) * 0.01 + local direction = (ray.Direction.Unit + spread.Position).Unit + + local pellets = weapon.pellets or 1 + for _ = 1, pellets do + local hitRay = RaycastParams.new() + hitRay.FilterDescendantsInstances = {player.Character or {}} + hitRay.FilterType = Enum.RaycastFilterType.Exclude + + local result = workspace:Raycast(ray.Origin, direction * weapon.range, hitRay) + if result then + Events:FindFirstChild("ShootEvent"):FireServer({ + origin = ray.Origin, + direction = direction * weapon.range, + hit = result.Instance, + hitPos = result.Position, + normal = result.Normal, + weapon = currentWeapon, + }) + + -- Muzzle flash visual + local flash = Instance.new("Part") + flash.Size = Vector3.new(0.3, 0.3, 0.3) + flash.Shape = Enum.PartType.Ball + flash.Color = Color3.fromRGB(255, 200, 50) + flash.Material = Enum.Material.Neon + flash.Anchored = true + flash.CanCollide = false + flash.Position = camera.CFrame.Position + camera.CFrame.LookVector * 3 + flash.Parent = workspace + game:GetService("Debris"):AddItem(flash, 0.05) + + -- Bullet trail + local trail = Instance.new("Part") + trail.Size = Vector3.new(0.1, 0.1, weapon.range) + trail.CFrame = CFrame.new(camera.CFrame.Position, result.Position) * CFrame.new(0, 0, -weapon.range/2) + trail.Anchored = true + trail.CanCollide = false + trail.Color = Color3.fromRGB(255, 220, 100) + trail.Material = Enum.Material.Neon + trail.Transparency = 0.5 + trail.Parent = workspace + game:GetService("Debris"):AddItem(trail, 0.03) + + -- Impact effect + local impact = Instance.new("Part") + impact.Size = Vector3.new(0.5, 0.5, 0.5) + impact.Shape = Enum.PartType.Ball + impact.Color = Color3.fromRGB(255, 150, 50) + impact.Material = Enum.Material.Neon + impact.Anchored = true + impact.CanCollide = false + impact.Position = result.Position + impact.Parent = workspace + game:GetService("Debris"):AddItem(impact, 0.1) + end + end + + -- Apply recoil + if isADS then + recoilX = recoilX - weapon.recoil.x * 0.4 + recoilY = recoilY + weapon.recoil.y * 0.4 + else + recoilX = recoilX - weapon.recoil.x + recoilY = recoilY + weapon.recoil.y + end + + -- Screen shake + local shake = isADS and 0.002 or 0.005 + camera.CFrame = camera.CFrame * CFrame.new( + math.random(-100,100)/100 * shake, + math.random(-100,100)/100 * shake, + 0 + ) + + task.wait(weapon.fireRate) + canShoot = true + updateHUD() + + if ammo <= 0 then + reload() + end +end + +function reload() + if isReloading or reserveAmmo <= 0 then return end + isReloading = true + + Events:FindFirstChild("ReloadEvent"):FireServer() + + task.wait(weapon.reloadTime) + + local needed = weapon.magSize - ammo + local available = math.min(needed, reserveAmmo) + ammo = ammo + available + reserveAmmo = reserveAmmo - available + isReloading = false + updateHUD() +end + +-- Input handling +UIS.InputBegan:Connect(function(input, processed) + if processed then return end + + if input.UserInputType == Enum.UserInputType.MouseButton2 then + isADS = true + if isSprinting then isSprinting = false end + end + + if input.UserInputType == Enum.UserInputType.MouseButton1 then + isFiring = true + if isSprinting then isSprinting = false end + end + + if input.KeyCode == Enum.KeyCode.LeftShift then + if not isADS then isSprinting = true end + end + + if input.KeyCode == Enum.KeyCode.LeftControl then + local char = player.Character + if char then + local hum = char:FindFirstChildOfClass("Humanoid") + if hum then hum.WalkSpeed = 8 end + end + end + + if input.KeyCode == Enum.KeyCode.R then + reload() + end + + -- Weapon switch: 1-4 + if input.KeyCode == Enum.KeyCode.One then currentWeapon = "M4A1" end + if input.KeyCode == Enum.KeyCode.Two then currentWeapon = "AK47" end + if input.KeyCode == Enum.KeyCode.Three then currentWeapon = "Sniper" end + if input.KeyCode == Enum.KeyCode.Four then currentWeapon = "Shotgun" end + + if input.KeyCode >= Enum.KeyCode.One and input.KeyCode <= Enum.KeyCode.Four then + weapon = Weapons[currentWeapon] + ammo = weapon.magSize + reserveAmmo = weapon.maxAmmo + isReloading = false + updateHUD() + end +end) + +UIS.InputEnded:Connect(function(input) + if input.UserInputType == Enum.UserInputType.MouseButton2 then + isADS = false + end + if input.UserInputType == Enum.UserInputType.MouseButton1 then + isFiring = false + end + if input.KeyCode == Enum.KeyCode.LeftShift then + isSprinting = false + end + if input.KeyCode == Enum.KeyCode.LeftControl then + local char = player.Character + if char then + local hum = char:FindFirstChildOfClass("Humanoid") + if hum then hum.WalkSpeed = 20 end + end + end +end) + +-- Main loop +RunService.RenderStepped:Connect(function() + -- Camera FOV for ADS + local targetFOV = isADS and weapon.adsFOV or 70 + camera.FieldOfView = camera.FieldOfView + (targetFOV - camera.FieldOfView) * 0.2 + + -- Sprint speed + local char = player.Character + if char then + local hum = char:FindFirstChildOfClass("Humanoid") + if hum and hum.MoveDirection.Magnitude > 0 then + if isSprinting then + hum.WalkSpeed = 30 + elseif not UIS:IsKeyDown(Enum.KeyCode.LeftControl) then + hum.WalkSpeed = 20 + end + end + end + + -- Auto-fire + if isFiring and weapon.automatic then + shoot() + end + + -- Recoil recovery + recoilX = recoilX + (0 - recoilX) * math.clamp(recoilRecoverySpeed * 0.01, 0, 1) + recoilY = recoilY + (0 - recoilY) * math.clamp(recoilRecoverySpeed * 0.01, 0, 1) + + updateHUD() +end) + +-- Lock mouse for FPS +UIS.MouseIconEnabled = false + +player.CharacterAdded:Connect(function() + ammo = weapon.magSize + reserveAmmo = weapon.maxAmmo + updateHUD() +end) + +updateHUD() +print("[WeaponClient] Loaded - Controls: LMB=Shoot, RMB=ADS, Shift=Sprint, Ctrl=Crouch, R=Reload, 1-4=Switch weapon") +]] + +print("[CoD FPS] Part 2/5 complete: Weapon system created.") diff --git a/examples/fps-game/part3_ai.lua b/examples/fps-game/part3_ai.lua new file mode 100644 index 0000000..9ff188e --- /dev/null +++ b/examples/fps-game/part3_ai.lua @@ -0,0 +1,542 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- MINI CALL OF DUTY - FPS Game Setup (Part 3: Enemy AI + Server Handler) +-- ═══════════════════════════════════════════════════════════════════ + +local SSS = game:GetService("ServerScriptService") +local RS = game:GetService("ReplicatedStorage") + +-- ═══════════════════════════════════════════════════════════════ +-- SERVER GAME HANDLER +-- ═══════════════════════════════════════════════════════════════ +local serverScript = Instance.new("Script") +serverScript.Name = "GameServer" +serverScript.Parent = SSS +serverScript.Source = [[ +local Players = game:GetService("Players") +local RS = game:GetService("ReplicatedStorage") +local Events = RS:WaitForChild("Events") +local Shared = RS:WaitForChild("Shared") +local WeaponData = require(Shared:WaitForChild("WeaponData")) + +local scores = {} +local killFeed = {} + +-- Player setup +Players.PlayerAdded:Connect(function(player) + scores[player.UserId] = {kills = 0, deaths = 0, streak = 0} + + player.CharacterAdded:Connect(function(char) + task.wait(0.5) + local hum = char:WaitForChild("Humanoid") + hum.MaxHealth = 100 + hum.Health = 100 + hum.WalkSpeed = 20 + + -- Give starter weapon visual + local tool = Instance.new("Tool") + tool.Name = "M4A1" + tool.RequiresHandle = true + tool.CanBeDropped = false + + local handle = Instance.new("Part") + handle.Name = "Handle" + handle.Size = Vector3.new(0.5, 0.5, 3) + handle.Color = Color3.fromRGB(40, 40, 40) + handle.Material = Enum.Material.SmoothPlastic + handle.CanCollide = false + handle.Anchored = false + handle.Parent = tool + tool.Parent = player.Backpack + + -- Gun body + local barrel = Instance.new("Part") + barrel.Name = "Barrel" + barrel.Size = Vector3.new(0.2, 0.2, 2) + barrel.Color = Color3.fromRGB(30, 30, 30) + barrel.Material = Enum.Material.Metal + barrel.CanCollide = false + barrel.Anchored = false + local weld = Instance.new("WeldConstraint") + weld.Part0 = handle + weld.Part1 = barrel + weld.Parent = barrel + barrel.CFrame = handle.CFrame * CFrame.new(0, 0.1, -2) + barrel.Parent = tool + + -- Magazine + local mag = Instance.new("Part") + mag.Name = "Magazine" + mag.Size = Vector3.new(0.3, 0.8, 0.4) + mag.Color = Color3.fromRGB(35, 35, 35) + mag.Material = Enum.Material.SmoothPlastic + mag.CanCollide = false + mag.Anchored = false + local weld2 = Instance.new("WeldConstraint") + weld2.Part0 = handle + weld2.Part1 = mag + weld2.Parent = mag + mag.CFrame = handle.CFrame * CFrame.new(0, -0.5, -0.5) + mag.Parent = tool + + -- Health regeneration + task.spawn(function() + while hum and hum.Health > 0 do + task.wait(3) + if hum.Health < hum.MaxHealth and hum.Health > 0 then + hum.Health = math.min(hum.MaxHealth, hum.Health + 5) + end + end + end) + + -- Death handler + hum.Died:Connect(function() + scores[player.UserId].deaths = scores[player.UserId].deaths + 1 + scores[player.UserId].streak = 0 + + -- Respawn after 5 seconds + task.delay(5, function() + if player then + player:LoadCharacter() + end + end) + end) + end) +end) + +-- Handle hit events from clients +Events:WaitForChild("ShootEvent").OnServerEvent:Connect(function(player, data) + -- Validate the shot + if not data or not data.hit then return end + + local hitObj = data.hit + local weaponName = data.weapon or "M4A1" + local weapon = WeaponData[weaponName] + if not weapon then return end + + -- Check range + local char = player.Character + if not char then return end + local dist = (data.hitPos - char.Head.Position).Magnitude + if dist > weapon.range then return end + + -- Find the humanoid of what was hit + local targetHum = nil + local isHeadshot = false + + if hitObj:IsA("Model") then + targetHum = hitObj:FindFirstChildOfClass("Humanoid") + elseif hitObj.Parent and hitObj.Parent:IsA("Model") then + targetHum = hitObj.Parent:FindFirstChildOfClass("Humanoid") + elseif hitObj.Parent and hitObj.Parent.Parent and hitObj.Parent.Parent:IsA("Model") then + targetHum = hitObj.Parent.Parent:FindFirstChildOfClass("Humanoid") + end + + -- Check headshot + if hitObj.Name == "Head" and targetHum then + isHeadshot = true + end + + -- Apply damage + if targetHum then + local dmg = weapon.damage + if isHeadshot then dmg = dmg * weapon.headshotMult end + + targetHum:TakeDamage(dmg) + + -- Check if killed + if targetHum.Health <= 0 then + local victim = nil + for _, p in ipairs(Players:GetPlayers()) do + if p.Character and p.Character:FindFirstChildOfClass("Humanoid") == targetHum then + victim = p + break + end + end + + if victim then + scores[player.UserId].kills = scores[player.UserId].kills + 1 + scores[player.UserId].streak = scores[player.UserId].streak + 1 + end + + -- Fire kill event + Events:WaitForChild("KillEvent"):FireAllClients({ + killer = player.Name, + victim = victim and victim.Name or "Enemy", + weapon = weaponName, + headshot = isHeadshot, + streak = scores[player.UserId].streak, + }) + end + + -- Fire damage indicator + Events:WaitForChild("DamageEvent"):FireClient(player, { + hit = true, + headshot = isHeadshot, + damage = dmg, + }) + end +end) + +-- Game data request +Events:WaitForChild("GetGameData").OnServerInvoke = function(player) + return { + scores = scores, + killFeed = killFeed, + } +end + +-- Kill feed relay +Events:WaitForChild("KillEvent").OnServerEvent:Connect(function(player, data) + table.insert(killFeed, 1, data) + if #killFeed > 5 then table.remove(killFeed) end +end) + +-- ═══════════════════════════════════════════════════════════════ +-- ENEMY AI SYSTEM +-- ═══════════════════════════════════════════════════════════════ +local enemySpawns = { + Vector3.new(60, 2, 80), Vector3.new(-60, 2, 80), + Vector3.new(70, 2, -80), Vector3.new(-70, 2, -80), + Vector3.new(0, 2, 100), Vector3.new(0, 2, -80), + Vector3.new(50, 2, 0), Vector3.new(-50, 2, 0), + Vector3.new(-40, 2, 50), Vector3.new(40, 2, -50), + Vector3.new(-80, 2, 30), Vector3.new(80, 2, -30), +} + +local enemies = {} +local MAX_ENEMIES = 10 +local SPAWN_INTERVAL = 8 + +local function createEnemy(pos) + local model = Instance.new("Model") + model.Name = "Enemy_" .. tostring(#enemies + 1) + + -- Humanoid + local hum = Instance.new("Humanoid") + hum.MaxHealth = 80 + hum.Health = 80 + hum.WalkSpeed = 14 + hum.Parent = model + + -- Head + local head = Instance.new("Part") + head.Name = "Head" + head.Size = Vector3.new(1.5, 1.5, 1.5) + head.Color = Color3.fromRGB(180, 140, 110) + head.Material = Enum.Material.SmoothPlastic + head.CanCollide = true + head.Parent = model + local headMesh = Instance.new("SpecialMesh") + headMesh.MeshType = Enum.MeshType.Head + headMesh.Scale = Vector3.new(1.25, 1.25, 1.25) + headMesh.Parent = head + + -- Torso + local torso = Instance.new("Part") + torso.Name = "HumanoidRootPart" + torso.Size = Vector3.new(2, 2, 1) + torso.Color = Color3.fromRGB(60, 80, 40) -- Military green + torso.Material = Enum.Material.SmoothPlastic + torso.CanCollide = true + torso.Parent = model + + -- Legs + local lleg = Instance.new("Part") + lleg.Name = "Left Leg" + lleg.Size = Vector3.new(1, 2, 1) + lleg.Color = Color3.fromRGB(50, 55, 45) + lleg.Material = Enum.Material.SmoothPlastic + lleg.Parent = model + + local rleg = Instance.new("Part") + rleg.Name = "Right Leg" + rleg.Size = Vector3.new(1, 2, 1) + rleg.Color = Color3.fromRGB(50, 55, 45) + rleg.Material = Enum.Material.SmoothPlastic + rleg.Parent = model + + -- Arms + local larm = Instance.new("Part") + larm.Name = "Left Arm" + larm.Size = Vector3.new(1, 2, 1) + larm.Color = Color3.fromRGB(60, 80, 40) + larm.Material = Enum.Material.SmoothPlastic + larm.Parent = model + + local rarm = Instance.new("Part") + rarm.Name = "Right Arm" + rarm.Size = Vector3.new(1, 2, 1) + rarm.Color = Color3.fromRGB(60, 80, 40) + rarm.Material = Enum.Material.SmoothPlastic + rarm.Parent = model + + -- Motor6D connections + local function weld(part0, part1, c0, c1) + local m = Instance.new("Motor6D") + m.Part0 = part0 + m.Part1 = part1 + if c0 then m.C0 = c0 end + if c1 then m.C1 = c1 end + m.Parent = part0 + end + + weld(torso, head, CFrame.new(0, 1.5, 0), CFrame.new(0, 0, 0)) + weld(torso, larm, CFrame.new(-1.5, 0, 0), CFrame.new(0.5, 0, 0)) + weld(torso, rarm, CFrame.new(1.5, 0, 0), CFrame.new(-0.5, 0, 0)) + weld(torso, lleg, CFrame.new(-0.5, -2, 0), CFrame.new(0, 1, 0)) + weld(torso, rleg, CFrame.new(0.5, -2, 0), CFrame.new(0, 1, 0)) + + -- Beret/hat + local hat = Instance.new("Part") + hat.Name = "Hat" + hat.Size = Vector3.new(1.8, 0.5, 1.8) + hat.Color = Color3.fromRGB(40, 50, 30) + hat.Material = Enum.Material.SmoothPlastic + hat.CanCollide = false + hat.Parent = model + local hatWeld = Instance.new("WeldConstraint") + hatWeld.Part0 = head + hatWeld.Part1 = hat + hatWeld.Parent = hat + hat.CFrame = head.CFrame * CFrame.new(0, 0.9, 0) + + -- Health bar above head + local billboard = Instance.new("BillboardGui") + billboard.Name = "HealthBar" + billboard.Size = UDim2.new(3, 0, 0.4, 0) + billboard.StudsOffset = Vector3.new(0, 3.5, 0) + billboard.AlwaysOnTop = true + billboard.Parent = head + + local bg = Instance.new("Frame") + bg.Size = UDim2.new(1, 0, 1, 0) + bg.BackgroundColor3 = Color3.fromRGB(40, 40, 40) + bg.BorderSizePixel = 0 + bg.Parent = billboard + + local fill = Instance.new("Frame") + fill.Name = "Fill" + fill.Size = UDim2.new(1, 0, 1, 0) + fill.BackgroundColor3 = Color3.fromRGB(200, 30, 30) + fill.BorderSizePixel = 0 + fill.Parent = bg + + -- AI Script + local aiScript = Instance.new("Script") + aiScript.Source = [[ +local humanoid = script.Parent:FindFirstChildOfClass("Humanoid") +local rootPart = script.Parent:FindFirstChild("HumanoidRootPart") +local head = script.Parent:FindFirstChild("Head") +local healthFill = script.Parent:FindFirstChild("Head"):FindFirstChild("HealthBar"):FindFirstChild("Frame"):FindFirstChild("Fill") + +if not humanoid or not rootPart then return end + +local state = "patrol" +local target = nil +local lastShot = 0 +local fireRate = 1.2 +local damage = 12 +local detectionRange = 80 +local attackRange = 60 +local patrolPoints = {} +local currentPatrolIndex = 1 + +-- Generate patrol points +for i = 1, 6 do + local angle = math.rad(math.random(360)) + local dist = math.random(15, 60) + table.insert(patrolPoints, rootPart.Position + Vector3.new(math.cos(angle)*dist, 0, math.sin(angle)*dist)) +end + +-- Find closest player +local function findTarget() + local closest = nil + local closestDist = detectionRange + for _, player in ipairs(game:GetService("Players"):GetPlayers()) do + if player.Character then + local hum = player.Character:FindFirstChildOfClass("Humanoid") + if hum and hum.Health > 0 then + local dist = (player.Character.Head.Position - rootPart.Position).Magnitude + if dist < closestDist then + closest = player + closestDist = dist + end + end + end + end + return closest, closestDist +end + +-- Update health bar +humanoid.HealthChanged:Connect(function(health) + local pct = health / humanoid.MaxHealth + healthFill.Size = UDim2.new(pct, 0, 1, 0) + if pct < 0.3 then + healthFill.BackgroundColor3 = Color3.fromRGB(200, 30, 30) + else + healthFill.BackgroundColor3 = Color3.fromRGB(200, 60, 30) + end +end) + +-- Main AI loop +while humanoid and humanoid.Health > 0 do + task.wait(0.3) + + target, _ = findTarget() + + if target and target.Character then + local targetHum = target.Character:FindFirstChildOfClass("Humanoid") + local targetHead = target.Character:FindFirstChild("Head") + + if targetHum and targetHum.Health > 0 and targetHead then + local dist = (targetHead.Position - rootPart.Position).Magnitude + + -- Face target + rootPart.CFrame = CFrame.new(rootPart.Position, Vector3.new(targetHead.Position.X, rootPart.Position.Y, targetHead.Position.Z)) + + if dist <= attackRange then + state = "attack" + -- Shoot at player + if tick() - lastShot > fireRate then + lastShot = tick() + + -- Raycast to player + local direction = (targetHead.Position - head.Position).Unit + local spread = Vector3.new(math.random()-0.5, math.random()-0.5, math.random()-0.5) * 2 + local hitResult = workspace:Raycast(head.Position, (direction + spread) * attackRange) + + if hitResult then + -- Muzzle flash + local flash = Instance.new("Part") + flash.Size = Vector3.new(0.3, 0.3, 0.3) + flash.Shape = Enum.PartType.Ball + flash.Color = Color3.fromRGB(255, 200, 50) + flash.Material = Enum.Material.Neon + flash.Anchored = true + flash.CanCollide = false + flash.Position = head.Position + direction * 2 + flash.Parent = workspace + game:GetService("Debris"):AddItem(flash, 0.08) + + -- Bullet trail + local trail = Instance.new("Part") + trail.Size = Vector3.new(0.1, 0.1, (head.Position - hitResult.Position).Magnitude) + trail.CFrame = CFrame.new(head.Position, hitResult.Position) * CFrame.new(0, 0, -trail.Size.Z/2) + trail.Anchored = true + trail.CanCollide = false + trail.Color = Color3.fromRGB(255, 200, 100) + trail.Material = Enum.Material.Neon + trail.Transparency = 0.3 + trail.Parent = workspace + game:GetService("Debris"):AddItem(trail, 0.1) + + -- Check if hit player + local hitChar = hitResult.Instance + if hitChar then + local hitHum = nil + if hitChar.Parent and hitChar.Parent:FindFirstChildOfClass("Humanoid") then + hitHum = hitChar.Parent:FindFirstChildOfClass("Humanoid") + elseif hitChar.Parent and hitChar.Parent.Parent and hitChar.Parent.Parent:FindFirstChildOfClass("Humanoid") then + hitHum = hitChar.Parent.Parent:FindFirstChildOfClass("Humanoid") + end + + if hitHum and hitHum ~= humanoid then + hitHum:TakeDamage(damage) + end + end + + -- Impact effect + local impact = Instance.new("Part") + impact.Size = Vector3.new(0.5, 0.5, 0.5) + impact.Shape = Enum.PartType.Ball + impact.Color = Color3.fromRGB(255, 150, 50) + impact.Material = Enum.Material.Neon + impact.Anchored = true + impact.CanCollide = false + impact.Position = hitResult.Position + impact.Parent = workspace + game:GetService("Debris"):AddItem(impact, 0.15) + end + end + else + -- Move toward target + state = "chase" + humanoid:MoveTo(targetHead.Position) + end + end + else + -- Patrol + state = "patrol" + if #patrolPoints > 0 then + humanoid:MoveTo(patrolPoints[currentPatrolIndex]) + humanoid.MoveToFinished:Connect(function(reached) + if reached then + currentPatrolIndex = (currentPatrolIndex % #patrolPoints) + 1 + end + end) + end + end +end + +-- Death effect +local root = script.Parent:FindFirstChild("HumanoidRootPart") +if root then + for _, v in ipairs(script.Parent:GetDescendants()) do + if v:IsA("BasePart") then + v.Anchored = false + v.BrickColor = BrickColor.new("Dark stone grey") + local bf = Instance.new("BodyForce") + bf.Force = Vector3.new(math.random(-50,50), 100, math.random(-50,50)) + bf.Parent = v + game:GetService("Debris"):AddItem(v, 3) + end + end +end + +task.delay(3, function() + if script.Parent then script.Parent:Destroy() end +end) +]] + aiScript.Parent = model + + -- Position + local primary = torso + model.PrimaryPart = primary + primary.Position = pos + head.Position = pos + Vector3.new(0, 2.5, 0) + lleg.Position = pos + Vector3.new(-0.5, -1, 0) + rleg.Position = pos + Vector3.new(0.5, -1, 0) + larm.Position = pos + Vector3.new(-1.5, 0, 0) + rarm.Position = pos + Vector3.new(1.5, 0, 0) + + model.Parent = workspace + table.insert(enemies, model) + return model +end + +-- Enemy spawner loop +task.spawn(function() + task.wait(5) -- Initial delay + while true do + -- Remove dead enemies + for i = #enemies, 1, -1 do + if not enemies[i] or not enemies[i]:FindFirstChildOfClass("Humanoid") + or enemies[i]:FindFirstChildOfClass("Humanoid").Health <= 0 then + table.remove(enemies, i) + end + end + + -- Spawn new enemies + if #enemies < MAX_ENEMIES then + local pos = enemySpawns[math.random(#enemySpawns)] + createEnemy(pos + Vector3.new(math.random(-5,5), 0, math.random(-5,5))) + end + + task.wait(SPAWN_INTERVAL) + end +end) + +print("[GameServer] Enemy AI system active. Spawning " .. MAX_ENEMIES .. " enemies.") +]] + +print("[CoD FPS] Part 3/5 complete: Server handler + Enemy AI created.") diff --git a/examples/fps-game/part4_hud.lua b/examples/fps-game/part4_hud.lua new file mode 100644 index 0000000..c357d17 --- /dev/null +++ b/examples/fps-game/part4_hud.lua @@ -0,0 +1,421 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- MINI CALL OF DUTY - FPS Game Setup (Part 4: HUD + Player Scripts) +-- ═══════════════════════════════════════════════════════════════════ + +local SG = game:GetService("StarterGui") +local SP = game:GetService("StarterPlayer") + +-- ═══════════════════════════════════════════════════════════════ +-- HUD (ScreenGui + LocalScript) +-- ═══════════════════════════════════════════════════════════════ +local hudGui = Instance.new("ScreenGui") +hudGui.Name = "FPS_HUD" +hudGui.ResetOnSpawn = false +hudGui.IgnoreGuiInset = true +hudGui.Parent = SG + +-- Crosshair +local cross = Instance.new("Frame") +cross.Name = "Crosshair" +cross.Size = UDim2.new(0, 20, 0, 20) +cross.Position = UDim2.new(0.5, -10, 0.5, -10) +cross.BackgroundTransparency = 1 +cross.Parent = hudGui + +for _, dir in ipairs({{0,-12,0,4,"Top"},{0,4,0,12,"Bottom"},{-12,0,4,0,"Left"},{4,0,12,0,"Right"}}) do + local line = Instance.new("Frame") + line.Name = dir[5] + line.Size = UDim2.new(0, dir[3] == 0 and 2 or dir[3], 0, dir[4] == 0 and 2 or dir[4]) + line.Position = UDim2.new(0, 9+dir[1], 0, 9+dir[2]) + line.BackgroundColor3 = Color3.fromRGB(255, 255, 255) + line.BackgroundTransparency = 0.2 + line.BorderSizePixel = 0 + line.Parent = cross +end + +-- Center dot +local dot = Instance.new("Frame") +dot.Name = "CenterDot" +dot.Size = UDim2.new(0, 3, 0, 3) +dot.Position = UDim2.new(0, 8, 0, 8) +dot.BackgroundColor3 = Color3.fromRGB(255, 50, 50) +dot.BorderSizePixel = 0 +dot.Parent = cross + +-- Hit marker (appears on hit) +local hitMarker = Instance.new("Frame") +hitMarker.Name = "HitMarker" +hitMarker.Size = UDim2.new(0, 30, 0, 30) +hitMarker.Position = UDim2.new(0.5, -15, 0.5, -15) +hitMarker.BackgroundTransparency = 1 +hitMarker.Visible = false +hitMarker.Parent = hudGui + +for _, d in ipairs({{-8,-8,6,6,45},{2,-8,6,6,-45},{-8,2,6,6,-45},{2,2,6,6,45}}) do + local mark = Instance.new("Frame") + mark.Size = UDim2.new(0, d[3], 0, d[4]) + mark.Position = UDim2.new(0, 12+d[1], 0, 12+d[2]) + mark.BackgroundColor3 = Color3.fromRGB(255, 255, 255) + mark.BorderSizePixel = 0 + mark.Rotation = d[5] + mark.Parent = hitMarker +end + +-- Damage vignette overlay +local dmgVignette = Instance.new("Frame") +dmgVignette.Name = "DamageVignette" +dmgVignette.Size = UDim2.new(1, 0, 1, 0) +dmgVignette.BackgroundColor3 = Color3.fromRGB(200, 0, 0) +dmgVignette.BackgroundTransparency = 1 +dmgVignette.BorderSizePixel = 0 +dmgVignette.ZIndex = 9 +dmgVignette.Parent = hudGui + +-- Kill feed frame (top right) +local killFeedFrame = Instance.new("Frame") +killFeedFrame.Name = "KillFeed" +killFeedFrame.Size = UDim2.new(0, 350, 0, 150) +killFeedFrame.Position = UDim2.new(1, -360, 0, 10) +killFeedFrame.BackgroundTransparency = 1 +killFeedFrame.Parent = hudGui + +-- Score display (top center) +local scoreFrame = Instance.new("Frame") +scoreFrame.Name = "ScoreFrame" +scoreFrame.Size = UDim2.new(0, 200, 0, 40) +scoreFrame.Position = UDim2.new(0.5, -100, 0, 10) +scoreFrame.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +scoreFrame.BackgroundTransparency = 0.5 +scoreFrame.BorderSizePixel = 0 +scoreFrame.Parent = hudGui + +local scoreLabel = Instance.new("TextLabel") +scoreLabel.Name = "ScoreLabel" +scoreLabel.Size = UDim2.new(1, 0, 1, 0) +scoreLabel.BackgroundTransparency = 1 +scoreLabel.Text = "KILLS: 0 | DEATHS: 0" +scoreLabel.TextColor3 = Color3.fromRGB(255, 255, 255) +scoreLabel.TextSize = 18 +scoreLabel.Font = Enum.Font.GothamBold +scoreLabel.Parent = scoreFrame + +-- Killstreak banner (center) +local streakBanner = Instance.new("TextLabel") +streakBanner.Name = "StreakBanner" +streakBanner.Size = UDim2.new(0, 400, 0, 60) +streakBanner.Position = UDim2.new(0.5, -200, 0.3, 0) +streakBanner.BackgroundTransparency = 1 +streakBanner.Text = "" +streakBanner.TextColor3 = Color3.fromRGB(255, 200, 50) +streakBanner.TextSize = 32 +streakBanner.Font = Enum.Font.GothamBold +streakBanner.TextStrokeTransparency = 0 +streakBanner.Visible = false +streakBanner.ZIndex = 10 +streakBanner.Parent = hudGui + +-- Minimap (top left) +local minimap = Instance.new("Frame") +minimap.Name = "Minimap" +minimap.Size = UDim2.new(0, 150, 0, 150) +minimap.Position = UDim2.new(0, 10, 0, 10) +minimap.BackgroundColor3 = Color3.fromRGB(30, 40, 30) +minimap.BackgroundTransparency = 0.3 +minimap.BorderSizePixel = 0 +minimap.Parent = hudGui + +local mapCorner = Instance.new("UICorner") +mapCorner.CornerRadius = UDim.new(0, 75) +mapCorner.Parent = minimap + +local playerDot = Instance.new("Frame") +playerDot.Name = "PlayerDot" +playerDot.Size = UDim2.new(0, 6, 0, 6) +playerDot.Position = UDim2.new(0.5, -3, 0.5, -3) +playerDot.BackgroundColor3 = Color3.fromRGB(0, 255, 0) +playerDot.BorderSizePixel = 0 +playerDot.Parent = minimap + +-- Weapon info panel (bottom right) +local weaponPanel = Instance.new("Frame") +weaponPanel.Name = "WeaponPanel" +weaponPanel.Size = UDim2.new(0, 250, 0, 80) +weaponPanel.Position = UDim2.new(1, -260, 1, -90) +weaponPanel.BackgroundColor3 = Color3.fromRGB(0, 0, 0) +weaponPanel.BackgroundTransparency = 0.4 +weaponPanel.BorderSizePixel = 0 +weaponPanel.Parent = hudGui + +local weaponLabel = Instance.new("TextLabel") +weaponLabel.Name = "WeaponName" +weaponLabel.Size = UDim2.new(1, -10, 0, 25) +weaponLabel.Position = UDim2.new(0, 5, 0, 5) +weaponLabel.BackgroundTransparency = 1 +weaponLabel.Text = "M4A1 CARBINE" +weaponLabel.TextColor3 = Color3.fromRGB(255, 255, 255) +weaponLabel.TextSize = 16 +weaponLabel.Font = Enum.Font.GothamBold +weaponLabel.TextXAlignment = Enum.TextXAlignment.Right +weaponLabel.Parent = weaponPanel + +local ammoLabel = Instance.new("TextLabel") +ammoLabel.Name = "AmmoLabel" +ammoLabel.Size = UDim2.new(1, -10, 0, 35) +ammoLabel.Position = UDim2.new(0, 5, 0, 25) +ammoLabel.BackgroundTransparency = 1 +ammoLabel.Text = "30 / 210" +ammoLabel.TextColor3 = Color3.fromRGB(255, 255, 255) +ammoLabel.TextSize = 28 +ammoLabel.Font = Enum.Font.GothamBold +ammoLabel.TextXAlignment = Enum.TextXAlignment.Right +ammoLabel.Parent = weaponPanel + +local reserveLabel = Instance.new("TextLabel") +reserveLabel.Name = "ReserveLabel" +reserveLabel.Size = UDim2.new(1, -10, 0, 15) +reserveLabel.Position = UDim2.new(0, 5, 0, 60) +reserveLabel.BackgroundTransparency = 1 +reserveLabel.Text = "" +reserveLabel.TextColor3 = Color3.fromRGB(180, 180, 180) +reserveLabel.TextSize = 12 +reserveLabel.Font = Enum.Font.Gotham +reserveLabel.TextXAlignment = Enum.TextXAlignment.Right +reserveLabel.Parent = weaponPanel + +-- Reload bar +local reloadBar = Instance.new("Frame") +reloadBar.Name = "ReloadBar" +reloadBar.Size = UDim2.new(0, 200, 0, 8) +reloadBar.Position = UDim2.new(0.5, -100, 0.6, 0) +reloadBar.BackgroundColor3 = Color3.fromRGB(40, 40, 40) +reloadBar.BorderSizePixel = 0 +reloadBar.Visible = false +reloadBar.Parent = hudGui + +local reloadFill = Instance.new("Frame") +reloadFill.Name = "Fill" +reloadFill.Size = UDim2.new(0, 0, 1, 0) +reloadFill.BackgroundColor3 = Color3.fromRGB(255, 200, 50) +reloadFill.BorderSizePixel = 0 +reloadFill.Parent = reloadBar + +-- Controls hint (bottom center) +local controlsHint = Instance.new("TextLabel") +controlsHint.Name = "Controls" +controlsHint.Size = UDim2.new(0, 600, 0, 25) +controlsHint.Position = UDim2.new(0.5, -300, 1, -30) +controlsHint.BackgroundTransparency = 1 +controlsHint.Text = "WASD=Move | LMB=Shoot | RMB=ADS | Shift=Sprint | Ctrl=Crouch | R=Reload | 1-4=Weapons" +controlsHint.TextColor3 = Color3.fromRGB(150, 150, 150) +controlsHint.TextSize = 12 +controlsHint.Font = Enum.Font.Gotham +controlsHint.Parent = hudGui + +-- ═══════════════════════════════════════════════════════════════ +-- PLAYER SETUP SCRIPT (LocalScript in StarterPlayerScripts) +-- ═══════════════════════════════════════════════════════════════ +local spScripts = SP:FindFirstChild("StarterPlayerScripts") +if not spScripts then + spScripts = Instance.new("Folder") + spScripts.Name = "StarterPlayerScripts" + spScripts.Parent = SP +end + +local playerSetup = Instance.new("LocalScript") +playerSetup.Name = "PlayerSetup" +playerSetup.Parent = spScripts +playerSetup.Source = [[ +local Players = game:GetService("Players") +local RS = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local UIS = game:GetService("UserInputService") +local Events = RS:WaitForChild("Events") + +local player = Players.LocalPlayer +local camera = workspace.CurrentCamera + +-- Force first person +player.CameraMode = Enum.CameraMode.LockFirstPerson +player.CameraMaxZoomDistance = 0.5 +player.CameraMinZoomDistance = 0.5 + +-- Character setup on spawn +player.CharacterAdded:Connect(function(char) + task.wait(0.5) + local hum = char:WaitForChild("Humanoid") + hum.WalkSpeed = 20 + hum.JumpPower = 40 + + -- Health regen + task.spawn(function() + while hum and hum.Health > 0 do + task.wait(2) + if hum.Health < hum.MaxHealth and hum.Health > 0 then + hum.Health = math.min(hum.MaxHealth, hum.Health + 3) + end + end + end) + + -- Damage vignette on hit + hum.HealthChanged:Connect(function(health) + local lost = hum.MaxHealth - health + if lost > 0 then + local gui = player:FindFirstChild("PlayerGui") + if gui then + local hud = gui:FindFirstChild("FPS_HUD") + if hud then + local vignette = hud:FindFirstChild("DamageVignette") + if vignette then + local intensity = math.clamp(lost / hum.MaxHealth, 0, 0.6) + vignette.BackgroundTransparency = 1 - intensity + task.delay(0.3, function() + if vignette then + vignette.BackgroundTransparency = 1 + end + end) + end + end + end + end + end) +end) + +-- Kill feed listener +Events:WaitForChild("KillEvent").OnClientEvent:Connect(function(data) + local gui = player:FindFirstChild("PlayerGui") + if not gui then return end + local hud = gui:FindFirstChild("FPS_HUD") + if not hud then return end + local feed = hud:FindFirstChild("KillFeed") + if not feed then return end + + -- Create kill feed entry + local entry = Instance.new("TextLabel") + entry.Size = UDim2.new(1, 0, 0, 25) + entry.BackgroundTransparency = 0.4 + entry.BackgroundColor3 = Color3.fromRGB(0, 0, 0) + entry.Text = " " .. data.killer .. " [" .. data.weapon .. "] " .. data.victim + .. (data.headshot and " ★" or "") + entry.TextColor3 = data.killer == player.Name and Color3.fromRGB(50, 255, 50) + or Color3.fromRGB(255, 255, 255) + entry.TextSize = 14 + entry.Font = Enum.Font.GothamBold + entry.TextXAlignment = Enum.TextXAlignment.Right + entry.BorderSizePixel = 0 + + -- Headshot indicator color + if data.headshot then + entry.TextColor3 = Color3.fromRGB(255, 50, 50) + end + + entry.Parent = feed + + -- Shift older entries down + for i, child in ipairs(feed:GetChildren()) do + if child:IsA("TextLabel") then + child.Position = UDim2.new(0, 0, 0, (i-1) * 28) + end + end + + -- Remove after 5 seconds + task.delay(5, function() + if entry then entry:Destroy() end + end) + + -- Killstreak banner + if data.killer == player.Name and data.streak then + local banner = hud:FindFirstChild("StreakBanner") + if banner then + local streakNames = { + [3] = "TRIPLE KILL!", + [5] = "KILLING SPREE!", + [7] = "UNSTOPPABLE!", + [10] = "TACTICAL NUKE READY!", + } + local msg = streakNames[data.streak] + if msg then + banner.Text = msg + banner.Visible = true + task.delay(3, function() + if banner then banner.Visible = false end + end) + end + end + end +end) + +-- Hit marker listener +Events:WaitForChild("DamageEvent").OnClientEvent:Connect(function(data) + local gui = player:FindFirstChild("PlayerGui") + if not gui then return end + local hud = gui:FindFirstChild("FPS_HUD") + if not hud then return end + + -- Show hit marker + local hm = hud:FindFirstChild("HitMarker") + if hm and data.hit then + hm.Visible = true + -- Change color for headshots + for _, child in ipairs(hm:GetChildren()) do + if child:IsA("Frame") then + child.BackgroundColor3 = data.headshot + and Color3.fromRGB(255, 50, 50) + or Color3.fromRGB(255, 255, 255) + end + end + task.delay(0.15, function() + if hm then hm.Visible = false end + end) + end +end) + +-- Minimap updater +task.spawn(function() + task.wait(3) + while true do + task.wait(0.5) + local char = player.Character + if char then + local gui = player:FindFirstChild("PlayerGui") + if gui then + local hud = gui:FindFirstChild("FPS_HUD") + if hud then + local map = hud:FindFirstChild("Minimap") + if map then + -- Update enemy dots + for _, child in ipairs(map:GetChildren()) do + if child.Name == "EnemyDot" then child:Destroy() end + end + for _, obj in ipairs(workspace:GetChildren()) do + if obj.Name:match("^Enemy_") then + local hum = obj:FindFirstChildOfClass("Humanoid") + if hum and hum.Health > 0 then + local root = obj:FindFirstChild("HumanoidRootPart") + if root and char:FindFirstChild("Head") then + local relPos = root.Position - char.Head.Position + local mapScale = 150 / 400 -- minimap size / map size + local mx = math.clamp(relPos.X * mapScale + 72, 5, 145) + local mz = math.clamp(relPos.Z * mapScale + 72, 5, 145) + + local eDot = Instance.new("Frame") + eDot.Name = "EnemyDot" + eDot.Size = UDim2.new(0, 5, 0, 5) + eDot.Position = UDim2.new(0, mx, 0, mz) + eDot.BackgroundColor3 = Color3.fromRGB(255, 50, 50) + eDot.BorderSizePixel = 0 + eDot.Parent = map + end + end + end + end + end + end + end + end + end +end) + +print("[PlayerSetup] FPS player configured.) +]] + +print("[CoD FPS] Part 4/5 complete: HUD + Player scripts created.") diff --git a/examples/fps-game/part5_client.lua b/examples/fps-game/part5_client.lua new file mode 100644 index 0000000..e6f6b65 --- /dev/null +++ b/examples/fps-game/part5_client.lua @@ -0,0 +1,371 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- MINI CALL OF DUTY - FPS Game Setup (Part 5: Weapon Client Script) +-- ═══════════════════════════════════════════════════════════════════ + +local SG = game:GetService("StarterGui") + +-- Get the HUD that was already created in Part 4 +local hudGui = SG:FindFirstChild("FPS_HUD") + +-- Add the weapon controller LocalScript +local weaponScript = Instance.new("LocalScript") +weaponScript.Name = "WeaponController" +weaponScript.Parent = hudGui +weaponScript.Source = [[ +local Players = game:GetService("Players") +local RS = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local UIS = game:GetService("UserInputService") +local Events = RS:WaitForChild("Events") +local Shared = RS:WaitForChild("Shared") +local WeaponData = require(Shared:WaitForChild("WeaponData")) + +local player = Players.LocalPlayer +local camera = workspace.CurrentCamera +local mouse = player:GetMouse() + +-- Weapon state +local currentWeapon = "M4A1" +local weapon = WeaponData[currentWeapon] +local ammo = weapon.magSize +local reserveAmmo = weapon.maxAmmo +local isReloading = false +local lastShot = 0 +local isADS = false +local isSprinting = false +local isFiring = false +local recoilX = 0 +local recoilY = 0 + +-- UI references +local scriptParent = script.Parent +local crosshair = scriptParent:WaitForChild("Crosshair") +local hitMarker = scriptParent:WaitForChild("HitMarker") +local weaponPanel = scriptParent:WaitForChild("WeaponPanel") +local ammoLabel = weaponPanel:WaitForChild("AmmoLabel") +local weaponLabel = weaponPanel:WaitForChild("WeaponName") +local reserveLabel = weaponPanel:WaitForChild("ReserveLabel") +local reloadBar = scriptParent:WaitForChild("ReloadBar") +local reloadFill = reloadBar:WaitForChild("Fill") +local scoreLabel = scriptParent:WaitForChild("ScoreFrame"):WaitForChild("ScoreLabel") +local damageVignette = scriptParent:WaitForChild("DamageVignette") + +local kills = 0 +local deaths = 0 + +local function updateHUD() + if ammoLabel then + ammoLabel.Text = ammo .. " / " .. reserveAmmo + if ammo <= math.floor(weapon.magSize * 0.25) then + ammoLabel.TextColor3 = Color3.fromRGB(255, 80, 80) + else + ammoLabel.TextColor3 = Color3.fromRGB(255, 255, 255) + end + end + if weaponLabel then + weaponLabel.Text = weapon.displayName:upper() + end + if reserveLabel then + reserveLabel.Text = isReloading and "RELOADING..." or "" + end + if scoreLabel then + scoreLabel.Text = "KILLS: " .. kills .. " | DEATHS: " .. deaths + end +end + +local function shoot() + if isReloading then return end + if ammo <= 0 then + -- Auto reload + if reserveAmmo > 0 then + -- Play empty click sound via visual feedback + end + return + end + if tick() - lastShot < weapon.fireRate then return end + + lastShot = tick() + ammo = ammo - 1 + + -- Recoil + recoilX = recoilX + (math.random() - 0.5) * weapon.recoil.x + recoilY = weapon.recoil.y * 0.3 + camera.CFrame = camera.CFrame * CFrame.Angles( + math.rad(-recoilY), + math.rad(recoilX * 0.1), + 0 + ) + + -- Spread + local spreadAmount = isADS and weapon.spread.ads or weapon.spread.hip + local spread = Vector3.new( + (math.random() - 0.5) * spreadAmount * 0.01, + (math.random() - 0.5) * spreadAmount * 0.01, + 0 + ) + + -- Raycast + local rayDirection = (camera.CFrame.LookVector + spread) * weapon.range + local raycastParams = RaycastParams.new() + raycastParams.FilterType = Enum.RaycastFilterType.Exclude + local char = player.Character + if char then raycastParams.FilterDescendantsInstances = {char} end + + local result = workspace:Raycast(camera.CFrame.Position, rayDirection, raycastParams) + + -- Muzzle flash + local flash = Instance.new("Part") + flash.Size = Vector3.new(0.3, 0.3, 0.3) + flash.Shape = Enum.PartType.Ball + flash.Color = Color3.fromRGB(255, 200, 50) + flash.Material = Enum.Material.Neon + flash.Anchored = true + flash.CanCollide = false + flash.Position = camera.CFrame.Position + camera.CFrame.LookVector * 3 + flash.Parent = workspace + game:GetService("Debris"):AddItem(flash, 0.04) + + if result then + -- Bullet trail + local trail = Instance.new("Part") + local trailLen = (camera.CFrame.Position - result.Position).Magnitude + trail.Size = Vector3.new(0.08, 0.08, trailLen) + trail.CFrame = CFrame.new(camera.CFrame.Position, result.Position) + * CFrame.new(0, 0, -trailLen / 2) + trail.Anchored = true + trail.CanCollide = false + trail.Color = Color3.fromRGB(255, 220, 100) + trail.Material = Enum.Material.Neon + trail.Transparency = 0.4 + trail.Parent = workspace + game:GetService("Debris"):AddItem(trail, 0.06) + + -- Impact spark + local spark = Instance.new("Part") + spark.Size = Vector3.new(0.4, 0.4, 0.4) + spark.Shape = Enum.PartType.Ball + spark.Color = Color3.fromRGB(255, 180, 50) + spark.Material = Enum.Material.Neon + spark.Anchored = true + spark.CanCollide = false + spark.Position = result.Position + spark.Parent = workspace + game:GetService("Debris"):AddItem(spark, 0.12) + + -- Smoke puff at impact + local smoke = Instance.new("Part") + smoke.Size = Vector3.new(1, 1, 1) + smoke.Shape = Enum.PartType.Ball + smoke.Color = Color3.fromRGB(120, 120, 110) + smoke.Transparency = 0.5 + smoke.Anchored = true + smoke.CanCollide = false + smoke.Position = result.Position + smoke.Parent = workspace + game:GetService("Debris"):AddItem(smoke, 0.3) + + -- Send to server + Events:WaitForChild("ShootEvent"):FireServer({ + origin = camera.CFrame.Position, + direction = rayDirection, + hit = result.Instance, + hitPos = result.Position, + normal = result.Normal, + weapon = currentWeapon, + }) + else + -- Shot into air - just trail to max range + local endPoint = camera.CFrame.Position + rayDirection + local trail = Instance.new("Part") + local trailLen = weapon.range + trail.Size = Vector3.new(0.06, 0.06, trailLen) + trail.CFrame = CFrame.new(camera.CFrame.Position, endPoint) + * CFrame.new(0, 0, -trailLen / 2) + trail.Anchored = true + trail.CanCollide = false + trail.Color = Color3.fromRGB(255, 220, 100) + trail.Material = Enum.Material.Neon + trail.Transparency = 0.5 + trail.Parent = workspace + game:GetService("Debris"):AddItem(trail, 0.04) + end + + -- Auto reload when empty + if ammo <= 0 and reserveAmmo > 0 then + task.delay(0.3, function() reload() end) + end + + updateHUD() +end + +local function reload() + if isReloading then return end + if ammo >= weapon.magSize then return end + if reserveAmmo <= 0 then return end + + isReloading = true + reloadBar.Visible = true + + local startTime = tick() + local conn + conn = RunService.RenderStepped:Connect(function() + local elapsed = tick() - startTime + local pct = math.clamp(elapsed / weapon.reloadTime, 0, 1) + reloadFill.Size = UDim2.new(pct * 200, 0, 1, 0) + + if pct >= 1 then + conn:Disconnect() + local needed = weapon.magSize - ammo + local toLoad = math.min(needed, reserveAmmo) + ammo = ammo + toLoad + reserveAmmo = reserveAmmo - toLoad + isReloading = false + reloadBar.Visible = false + reloadFill.Size = UDim2.new(0, 0, 1, 0) + updateHUD() + end + end) + + updateHUD() +end + +-- Input handling +UIS.InputBegan:Connect(function(input, processed) + if processed then return end + + if input.UserInputType == Enum.UserInputType.MouseButton2 then + isADS = true + if isSprinting then isSprinting = false end + end + + if input.UserInputType == Enum.UserInputType.MouseButton1 then + isFiring = true + if not weapon.automatic then shoot() end + if isSprinting then isSprinting = false end + end + + if input.KeyCode == Enum.KeyCode.LeftShift then + if not isADS then isSprinting = true end + end + + if input.KeyCode == Enum.KeyCode.LeftControl then + local c = player.Character + if c then + local h = c:FindFirstChildOfClass("Humanoid") + if h then h.WalkSpeed = 8 end + end + end + + if input.KeyCode == Enum.KeyCode.R then reload() end + + -- Weapon switch + if input.KeyCode == Enum.KeyCode.One then currentWeapon = "M4A1" + elseif input.KeyCode == Enum.KeyCode.Two then currentWeapon = "AK47" + elseif input.KeyCode == Enum.KeyCode.Three then currentWeapon = "Sniper" + elseif input.KeyCode == Enum.KeyCode.Four then currentWeapon = "Shotgun" end + + if input.KeyCode >= Enum.KeyCode.One and input.KeyCode <= Enum.KeyCode.Four then + weapon = WeaponData[currentWeapon] + ammo = weapon.magSize + reserveAmmo = weapon.maxAmmo + isReloading = false + reloadBar.Visible = false + updateHUD() + end +end) + +UIS.InputEnded:Connect(function(input) + if input.UserInputType == Enum.UserInputType.MouseButton2 then isADS = false end + if input.UserInputType == Enum.UserInputType.MouseButton1 then isFiring = false end + if input.KeyCode == Enum.KeyCode.LeftShift then isSprinting = false end + if input.KeyCode == Enum.KeyCode.LeftControl then + local c = player.Character + if c then + local h = c:FindFirstChildOfClass("Humanoid") + if h then h.WalkSpeed = 20 end + end + end +end) + +-- Track kills/deaths from events +Events:WaitForChild("KillEvent").OnClientEvent:Connect(function(data) + if data.killer == player.Name then + kills = kills + 1 + end + if data.victim == player.Name then + deaths = deaths + 1 + end + updateHUD() +end) + +-- Main loop +RunService.RenderStepped:Connect(function() + -- Camera FOV for ADS + local targetFOV = isADS and weapon.adsFOV or 70 + camera.FieldOfView = camera.FieldOfView + (targetFOV - camera.FieldOfView) * 0.2 + + -- Sprint speed + local c = player.Character + if c then + local h = c:FindFirstChildOfClass("Humanoid") + if h and h.MoveDirection.Magnitude > 0 then + if isSprinting then + h.WalkSpeed = 30 + elseif not UIS:IsKeyDown(Enum.KeyCode.LeftControl) then + h.WalkSpeed = 20 + end + end + end + + -- Auto-fire for automatic weapons + if isFiring and weapon.automatic then shoot() end + + -- Recoil recovery + recoilX = recoilX * 0.9 + recoilY = recoilY * 0.85 + + -- Crosshair spread + local spreadPx = isADS and 2 or (isSprinting and 15 or 6) + if isFiring then spreadPx = spreadPx + 4 end + for _, child in ipairs(crosshair:GetChildren()) do + if child.Name == "Top" then child.Position = UDim2.new(0, 9, 0, 9 - spreadPx) + elseif child.Name == "Bottom" then child.Position = UDim2.new(0, 9, 0, 9 + spreadPx) + elseif child.Name == "Left" then child.Position = UDim2.new(0, 9 - spreadPx, 0, 9) + elseif child.Name == "Right" then child.Position = UDim2.new(0, 9 + spreadPx, 0, 9) + end + end +end) + +-- First person lock +UIS.MouseIconEnabled = false +player.CameraMode = Enum.CameraMode.LockFirstPerson + +player.CharacterAdded:Connect(function() + ammo = weapon.magSize + reserveAmmo = weapon.maxAmmo + kills = 0 + deaths = 0 + isReloading = false + reloadBar.Visible = false + updateHUD() +end) + +updateHUD() +print("═══════════════════════════════════════════") +print(" MINI CALL OF DUTY - LOADED!") +print(" Controls:") +print(" WASD = Move") +print(" LMB = Shoot") +print(" RMB = Aim Down Sights") +print(" Shift = Sprint") +print(" Ctrl = Crouch") +print(" R = Reload") +print(" 1-4 = Switch Weapon") +print(" Weapons: M4A1(1), AK-47(2), AWP Sniper(3), SPAS-12(4)") +print("═══════════════════════════════════════════") +]] + +print("[CoD FPS] Part 5/5 complete: Weapon controller script created.") +print("═══════════════════════════════════════════") +print(" ALL PARTS COMPLETE! Press PLAY in Studio to start.") +print("═══════════════════════════════════════════") diff --git a/examples/inject-all-parts.py b/examples/inject-all-parts.py new file mode 100644 index 0000000..57da550 --- /dev/null +++ b/examples/inject-all-parts.py @@ -0,0 +1,176 @@ +""" +Inject all 5 FPS game parts into Roblox Studio command bar sequentially. +""" +import ctypes +import ctypes.wintypes +import subprocess +import time +import sys +import os + +user32 = ctypes.windll.user32 + +def find_studio(): + target = [None] + def cb(hwnd, _): + length = user32.GetWindowTextLengthW(hwnd) + if length > 0: + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + if "Roblox Studio" in buf.value: + target[0] = hwnd + return False + return True + WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM) + user32.EnumWindows(WNDENUMPROC(cb), 0) + return target[0] + +def set_foreground(hwnd): + SW_RESTORE = 9 + user32.ShowWindow(hwnd, SW_RESTORE) + time.sleep(0.3) + fg = user32.GetForegroundWindow() + if fg != hwnd: + tid_fg = user32.GetWindowThreadProcessId(fg, None) + tid_target = user32.GetWindowThreadProcessId(hwnd, None) + user32.AttachThreadInput(tid_fg, tid_target, True) + user32.SetForegroundWindow(hwnd) + user32.AttachThreadInput(tid_fg, tid_target, False) + time.sleep(0.3) + +def set_clipboard(text): + # Use PowerShell for reliable clipboard + # Write to temp file first to avoid escaping issues + tmp = os.path.join(os.environ["TEMP"], "roblox_clipboard.lua") + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + result = subprocess.run( + ["powershell", "-Command", + f"Get-Content '{tmp}' -Raw | Set-Clipboard"], + capture_output=True, text=True, timeout=10 + ) + return result.returncode == 0 + +def press_key(vk): + user32.keybd_event(vk, 0, 0, 0) + time.sleep(0.03) + user32.keybd_event(vk, 0, 2, 0) + time.sleep(0.05) + +def ctrl_v(): + user32.keybd_event(0x11, 0, 0, 0) # Ctrl down + time.sleep(0.02) + user32.keybd_event(0x56, 0, 0, 0) # V down + time.sleep(0.03) + user32.keybd_event(0x56, 0, 2, 0) # V up + time.sleep(0.02) + user32.keybd_event(0x11, 0, 2, 0) # Ctrl up + time.sleep(0.1) + +def click_at(x, y): + screen_w = user32.GetSystemMetrics(0) + screen_h = user32.GetSystemMetrics(1) + nx = int(x * 65535 / screen_w) + ny = int(y * 65535 / screen_h) + user32.mouse_event(0x8001, nx, ny, 0, 0) # Move + time.sleep(0.02) + user32.mouse_event(0x8002, nx, ny, 0, 0) # Down + time.sleep(0.03) + user32.mouse_event(0x8004, nx, ny, 0, 0) # Up + time.sleep(0.05) + +def inject_script(lua_code, part_num, total): + print(f"\n [{part_num}/{total}] Injecting {len(lua_code)} bytes...") + + if not set_clipboard(lua_code): + print(f" ERROR: Clipboard failed for part {part_num}") + return False + + time.sleep(0.3) + + # Press Escape to clear any selection + press_key(0x1B) + time.sleep(0.2) + + # Click in command bar area + hwnd = find_studio() + if not hwnd: + print(" ERROR: Studio window lost!") + return False + + rect = ctypes.wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + w = rect.right - rect.left + h = rect.bottom - rect.top + cmd_x = rect.left + w // 2 + cmd_y = rect.bottom - 50 + + click_at(cmd_x, cmd_y) + time.sleep(0.3) + + # Select all + delete existing text + user32.keybd_event(0x11, 0, 0, 0) # Ctrl + press_key(0x41) # A + user32.keybd_event(0x11, 0, 2, 0) # Ctrl up + time.sleep(0.1) + press_key(0x2E) # Delete + time.sleep(0.1) + + # Paste + ctrl_v() + time.sleep(0.5) + + # Execute + press_key(0x0D) # Enter + time.sleep(1.5) # Wait for execution + + print(f" [{part_num}/{total}] Done.") + return True + +def main(): + parts = [ + r"C:\Users\Admin\ClaudeCode-Roblox-Studio-MCP\examples\fps-game\part1_map.lua", + r"C:\Users\Admin\ClaudeCode-Roblox-Studio-MCP\examples\fps-game\part2_weapons.lua", + r"C:\Users\Admin\ClaudeCode-Roblox-Studio-MCP\examples\fps-game\part3_ai.lua", + r"C:\Users\Admin\ClaudeCode-Roblox-Studio-MCP\examples\fps-game\part4_hud.lua", + r"C:\Users\Admin\ClaudeCode-Roblox-Studio-MCP\examples\fps-game\part5_client.lua", + ] + total = len(parts) + + print("=" * 50) + print(" MINI CALL OF DUTY - Injecting into Roblox Studio") + print("=" * 50) + + # Find and focus Studio + hwnd = find_studio() + if not hwnd: + print("ERROR: Roblox Studio not found!") + sys.exit(1) + + print(f"\n Studio found: HWND={hwnd}") + set_foreground(hwnd) + time.sleep(1) + + for i, path in enumerate(parts, 1): + if not os.path.exists(path): + print(f"\n WARNING: {path} not found. Skipping.") + continue + + with open(path, "r", encoding="utf-8") as f: + lua_code = f.read() + + if not inject_script(lua_code, i, total): + print(f"\n FATAL: Part {i} failed. Stopping.") + sys.exit(1) + + # Re-focus between injections + set_foreground(hwnd) + time.sleep(1) + + print("\n" + "=" * 50) + print(" ALL PARTS INJECTED SUCCESSFULLY!") + print(" Press PLAY in Roblox Studio to start the game.") + print("=" * 50) + +if __name__ == "__main__": + main() diff --git a/examples/studio-inject.py b/examples/studio-inject.py new file mode 100644 index 0000000..391ec8e --- /dev/null +++ b/examples/studio-inject.py @@ -0,0 +1,181 @@ +""" +Inject Lua demo model into Roblox Studio command bar via Win32 API. +Uses only ctypes - no external dependencies. +""" +import ctypes +import ctypes.wintypes +import time +import sys + +# Win32 constants +WM_PASTE = 0x0302 +VK_CONTROL = 0x11 +VK_V = 0x56 +VK_RETURN = 0x0D +VK_ESCAPE = 0x1B +KEYEVENTF_KEYDOWN = 0x0000 +KEYEVENTF_KEYUP = 0x0002 +SW_RESTORE = 9 +CF_UNICODETEXT = 13 +GMEM_MOVEABLE = 0x0002 + +user32 = ctypes.windll.user32 +kernel32 = ctypes.windll.kernel32 + +def find_studio_window(): + """Find Roblox Studio window handle.""" + hwnd = user32.FindWindowW(None, None) + target = None + + def enum_callback(hwnd, _): + nonlocal target + length = user32.GetWindowTextLengthW(hwnd) + if length > 0: + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + if "Roblox Studio" in buf.value and "Place" in buf.value: + target = hwnd + return False + return True + + WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM) + user32.EnumWindows(WNDENUMPROC(enum_callback), 0) + return target + +def get_window_rect(hwnd): + """Get window position and size.""" + rect = ctypes.wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + return rect.left, rect.top, rect.right, rect.bottom + +def set_foreground(hwnd): + """Bring window to foreground.""" + user32.ShowWindow(hwnd, SW_RESTORE) + time.sleep(0.3) + # Try multiple methods to force foreground + user32.SetForegroundWindow(hwnd) + time.sleep(0.3) + # Attach to foreground window thread + fg = user32.GetForegroundWindow() + if fg != hwnd: + tid_fg = user32.GetWindowThreadProcessId(fg, None) + tid_target = user32.GetWindowThreadProcessId(hwnd, None) + user32.AttachThreadInput(tid_fg, tid_target, True) + user32.SetForegroundWindow(hwnd) + user32.AttachThreadInput(tid_fg, tid_target, False) + time.sleep(0.3) + +def key_down(vk): + user32.keybd_event(vk, 0, KEYEVENTF_KEYDOWN, 0) + +def key_up(vk): + user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) + +def press_key(vk, delay=0.05): + key_down(vk) + time.sleep(delay) + key_up(vk) + time.sleep(delay) + +def ctrl_v(): + key_down(VK_CONTROL) + time.sleep(0.02) + key_down(VK_V) + time.sleep(0.05) + key_up(VK_V) + time.sleep(0.02) + key_up(VK_CONTROL) + time.sleep(0.1) + +def set_clipboard_text(text): + """Set clipboard text using PowerShell as fallback.""" + import subprocess + # Use PowerShell for reliable clipboard - avoids ctypes memory issues + ps_cmd = f''' + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.Clipboard]::SetText(@' +{text} +'@) + ''' + result = subprocess.run( + ["powershell", "-Command", ps_cmd], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0: + print(f" Clipboard error: {result.stderr}") + return False + return True + +def click_at(x, y): + """Send a mouse click at absolute coordinates.""" + MOUSEDOWN = 0x0002 + MOUSEUP = 0x0004 + MOUSEMOVE = 0x0001 + ABSOLUTE = 0x8000 + + # Convert to normalized absolute coordinates (0-65535) + screen_w = user32.GetSystemMetrics(0) + screen_h = user32.GetSystemMetrics(1) + nx = int(x * 65535 / screen_w) + ny = int(y * 65535 / screen_h) + + user32.mouse_event(MOUSEMOVE | ABSOLUTE, nx, ny, 0, 0) + time.sleep(0.05) + user32.mouse_event(MOUSEDOWN | ABSOLUTE, nx, ny, 0, 0) + time.sleep(0.05) + user32.mouse_event(MOUSEUP | ABSOLUTE, nx, ny, 0, 0) + time.sleep(0.1) + +def main(): + print("[1/6] Finding Roblox Studio window...") + hwnd = find_studio_window() + if not hwnd: + print("ERROR: Could not find Roblox Studio with an open place") + sys.exit(1) + print(f" Found: HWND={hwnd}") + + print("[2/6] Reading Lua demo script...") + # For this example, we'll just verify the script exists + print(" Script: Ready to inject") + + print("[3/6] Bringing Studio to foreground...") + set_foreground(hwnd) + + left, top, right, bottom = get_window_rect(hwnd) + width = right - left + height = bottom - top + print(f" Window: {width}x{height} at ({left},{top})") + + # Command bar is at the bottom-center of the Studio window + # It's a thin text input bar, typically ~30px tall + # Click there to focus it + cmd_x = left + width // 2 + cmd_y = bottom - 50 # 50px from bottom (command bar area) + + print("[4/6] Focusing command bar...") + # First dismiss any dialogs + press_key(VK_ESCAPE) + time.sleep(0.2) + press_key(VK_ESCAPE) + time.sleep(0.2) + + # Click in the command bar area + click_at(cmd_x, cmd_y) + time.sleep(0.3) + + # Clear any existing text + key_down(VK_CONTROL) + press_key(0x41) # A key + key_up(VK_CONTROL) + time.sleep(0.1) + press_key(VK_ESCAPE) + time.sleep(0.1) + + print("[5/6] Ready to inject. Copy your Lua code to clipboard manually.") + print(" Then press Enter to execute.") + print(" (Auto-injection coming soon!)") + + print("[6/6] Done! Use inject-all-parts.py for full FPS game injection.") + +if __name__ == "__main__": + main() diff --git a/roblox-plugin/RobloxMCPPlugin.lua b/roblox-plugin/RobloxMCPPlugin.lua index 4e8011c..9fe7135 100644 --- a/roblox-plugin/RobloxMCPPlugin.lua +++ b/roblox-plugin/RobloxMCPPlugin.lua @@ -1,89 +1,131 @@ --[[ - Roblox MCP Plugin - This plugin connects Roblox Studio to the MCP server, allowing Claude AI to control it. + Roblox MCP Plugin v2.0 - HTTP Polling Edition + Connects Roblox Studio to Claude Code via MCP server. Installation: - 1. Copy this file to: Plugins/RobloxMCPPlugin.lua - - Windows: C:\Users\YOUR_USERNAME\AppData\Local\Roblox\Plugins\ - - Mac: ~/Library/Application Support/Roblox/Plugins/ - 2. Restart Roblox Studio - 3. Enable the plugin via Plugin Management + 1. Copy this file to: %LOCALAPPDATA%\Roblox\Plugins\RobloxMCPPlugin.lua + 2. Open Roblox Studio + 3. Enable HttpService in: Game Settings > Security > Allow HTTP Requests = ON 4. The plugin will auto-connect to the MCP server + 5. Click the toolbar button to connect/disconnect + + Requirements: + - MCP server running: node C:\Users\Admin\roblox-mcp-server\src\index.js + - HttpService enabled in Game Settings > Security --]] -local Plugin = plugin or {} -- For testing in Studio without plugin context +local Plugin = plugin or {} -- Configuration local CONFIG = { - WS_HOST = "localhost", - WS_PORT = 37423, - RECONNECT_DELAY = 3, - MAX_RECONNECT_ATTEMPTS = 10, + HOST = "localhost", + PORT = 37423, + POLL_INTERVAL = 0.5, -- seconds between polls + RECONNECT_DELAY = 5, + MAX_RECONNECT_ATTEMPTS = 20, } +-- Build base URL +local BASE_URL = "http://" .. CONFIG.HOST .. ":" .. CONFIG.PORT + -- State -local websocket = nil local isConnected = false +local isPolling = false local reconnectAttempts = 0 -local reconnectTimer = nil -local pluginGui = nil +local lastCommandId = 0 +local pollThread = nil +local toolbar = nil +local connectButton = nil --- Logging function +-- Get HttpService and FORCE ENABLE it (plugins can do this in Studio) +local HttpService = game:GetService("HttpService") +HttpService.HttpEnabled = true + +-- Logging local function log(message, level) - level = level or "info" - local prefix = "[RobloxMCP]" - local fullMessage = string.format("%s %s: %s", prefix, level:upper(), message) - - print(fullMessage) - - -- Also show in a dialog if it's an error - if level == "error" then - warn(fullMessage) + level = level or "INFO" + print("[RobloxMCP] [" .. level .. "] " .. message) + if level == "ERROR" then + warn("[RobloxMCP] " .. message) end end --- Get an object by path string +-- Safe HTTP request wrapper +local function httpRequest(method, path, body) + local url = BASE_URL .. path + local ok, result = pcall(function() + local options = { + Url = url, + Method = method, + Headers = { + ["Content-Type"] = "application/json", + }, + } + if body then + options.Body = HttpService:JSONEncode(body) + end + local response = HttpService:RequestAsync(options) + return response + end) + if not ok then + return false, tostring(result) + end + if result.StatusCode ~= 200 then + return false, "HTTP " .. result.StatusCode .. ": " .. result.Body + end + local decoded = HttpService:JSONDecode(result.Body) + return true, decoded +end + +-- Get an object by dot-separated path local function getObjectFromPath(path) if not path or path == "" then return nil end - - -- Handle special paths if path == "game" or path == "Game" then return game end + if path == "Workspace" or path == "workspace" then + return workspace + end + + -- Handle special service names + local serviceMap = { + ["ReplicatedStorage"] = game:GetService("ReplicatedStorage"), + ["ServerStorage"] = game:GetService("ServerStorage"), + ["ServerScriptService"] = game:GetService("ServerScriptService"), + ["StarterGui"] = game:GetService("StarterGui"), + ["StarterPack"] = game:GetService("StarterPack"), + ["StarterPlayer"] = game:GetService("StarterPlayer"), + ["Lighting"] = game:GetService("Lighting"), + ["Players"] = game:GetService("Players"), + ["Workspace"] = workspace, + } - -- Split path by dot local parts = {} for part in string.gmatch(path, "[^%.]+") do table.insert(parts, part) end + if #parts == 0 then return nil end - if #parts == 0 then - return nil - end + -- Start from service or game + local obj = serviceMap[parts[1]] or game - -- Start from game - local obj = game - - -- Traverse the path - for i, part in ipairs(parts) do - if obj:IsA("Workspace") and part == "Workspace" then - -- Workspace is a special case - elseif obj:FindFirstChild(part) then - obj = obj[part] - else - log("Could not find part: " .. part .. " in path: " .. path, "error") + for i = (serviceMap[parts[1]] and 2 or 1), #parts do + local part = parts[i] + local child = obj:FindFirstChild(part) + if not child then + log("Could not find '" .. part .. "' in path: " .. path, "ERROR") return nil end + obj = child end - return obj end --- Create an object at a path +-- Create object at path local function createObjectAt(path, className, properties) - local parentPath = string.match(path, "^(.-)%.[^%.]+$") or "game" + local parentPath = string.match(path, "^(.-)%.[^%.]+$") or "Workspace" local objectName = string.match(path, "%.([^%.]+)$") or path local parent = getObjectFromPath(parentPath) @@ -91,11 +133,9 @@ local function createObjectAt(path, className, properties) return nil, "Parent not found: " .. parentPath end - -- Create the object local obj = Instance.new(className) obj.Name = objectName - -- Set properties if properties then for propName, propValue in pairs(properties) do pcall(function() @@ -108,491 +148,282 @@ local function createObjectAt(path, className, properties) return obj end --- Handle incoming commands from MCP server -local function handleCommand(data) - local command = data.command - local params = data.params or {} - local requestId = data.id +-- Handle a single command from MCP server +local function handleCommand(cmd) + local command = cmd.command + local params = cmd.params or {} - log("Received command: " .. command, "info") + log("Executing: " .. command) local success, result = pcall(function() if command == "createScript" then - -- Create a script object local scriptObj = createObjectAt(params.path, params.scriptType or "Script", { Name = params.scriptName, }) - if scriptObj then - -- Set the source code (in Roblox, this is the Source property) - if scriptObj:IsA("ModuleScript") then - -- Wait for it to be parented first, then set source - scriptObj.Source = params.source - else - scriptObj.Source = params.source - end - - return { - success = true, - objectPath = params.path .. "." .. params.scriptName, - } + pcall(function() scriptObj.Source = params.source end) + return { success = true, objectPath = params.path } else - return { - success = false, - error = "Failed to create script", - } + return { success = false, error = "Failed to create script" } end elseif command == "createPart" then - local properties = { - Name = params.partName, - Anchored = params.anchored ~= false, - } - - -- Set shape based on partType + local props = { Name = params.partName, Anchored = params.anchored ~= false } local shapeEnum = Enum.PartType.Block - if params.partType == "Ball" then - shapeEnum = Enum.PartType.Ball - elseif params.partType == "Cylinder" then - shapeEnum = Enum.PartType.Cylinder - elseif params.partType == "Wedge" then - shapeEnum = Enum.PartType.Wedge - elseif params.partType == "CornerWedge" then - shapeEnum = Enum.PartType.CornerWedge + if params.partType == "Ball" then shapeEnum = Enum.PartType.Ball + elseif params.partType == "Cylinder" then shapeEnum = Enum.PartType.Cylinder + elseif params.partType == "Wedge" then shapeEnum = Enum.PartType.Wedge + elseif params.partType == "CornerWedge" then shapeEnum = Enum.PartType.CornerWedge end - properties.Shape = shapeEnum + props.Shape = shapeEnum - -- Set position if params.position then - properties.Position = Vector3.new( - params.position.x or 0, - params.position.y or 0, - params.position.z or 0 - ) + props.Position = Vector3.new(params.position.x or 0, params.position.y or 0, params.position.z or 0) end - - -- Set size if params.size then - properties.Size = Vector3.new(params.size.x or 1, params.size.y or 1, params.size.z or 1) + props.Size = Vector3.new(params.size.x or 4, params.size.y or 4, params.size.z or 4) end - - -- Set color if params.color then - local success = pcall(function() - properties.BrickColor = BrickColor.new(params.color) - end) - if not success then - -- Try as RGB color3 - if type(params.color) == "table" then - properties.Color3 = Color3.new(params.color.r or 1, params.color.g or 1, params.color.b or 1) - end - end + pcall(function() props.Color = BrickColor.new(params.color).Color end) end - local part = createObjectAt(params.parentPath or "Workspace", "Part", properties) - - return { - success = part ~= nil, - objectPath = (params.parentPath or "Workspace") .. "." .. params.partName, - } + local part = createObjectAt(params.parentPath or "Workspace", "Part", props) + return { success = part ~= nil, objectPath = (params.parentPath or "Workspace") .. "." .. params.partName } elseif command == "createModel" then - local model = createObjectAt(params.parentPath or "Workspace", "Model", { - Name = params.modelName, - }) - - return { - success = model ~= nil, - objectPath = (params.parentPath or "Workspace") .. "." .. params.modelName, - } + local model = createObjectAt(params.parentPath or "Workspace", "Model", { Name = params.modelName }) + return { success = model ~= nil, objectPath = (params.parentPath or "Workspace") .. "." .. params.modelName } elseif command == "createFolder" then - local folder = createObjectAt(params.parentPath or "Workspace", "Folder", { - Name = params.folderName, - }) - - return { - success = folder ~= nil, - objectPath = (params.parentPath or "Workspace") .. "." .. params.folderName, - } + local folder = createObjectAt(params.parentPath or "Workspace", "Folder", { Name = params.folderName }) + return { success = folder ~= nil, objectPath = (params.parentPath or "Workspace") .. "." .. params.folderName } elseif command == "createGUI" then local properties = params.properties or {} properties.Name = params.name - - -- Set default GUI properties if params.guiType == "ScreenGui" then properties.ResetOnSpawn = false - elseif params.guiType == "Frame" or params.guiType == "TextLabel" or params.guiType == "TextButton" then - -- Default size and position - if not properties.Size then - properties.Size = UDim2.new(0, 200, 0, 50) - end - if not properties.Position then - properties.Position = UDim2.new(0, 0, 0, 0) - end end - - -- Set text properties for text-based GUI + if params.guiType == "Frame" or params.guiType == "TextLabel" or params.guiType == "TextButton" then + if not properties.Size then properties.Size = UDim2.new(0, 200, 0, 50) end + end if params.guiType == "TextLabel" or params.guiType == "TextButton" then - if not properties.Text then - properties.Text = params.name - end - if not properties.TextScaled then - properties.TextScaled = true - end + if not properties.Text then properties.Text = params.name end end - local gui = createObjectAt(params.parentPath or "StarterGui", params.guiType, properties) - - return { - success = gui ~= nil, - objectPath = (params.parentPath or "StarterGui") .. "." .. params.name, - } + return { success = gui ~= nil, objectPath = (params.parentPath or "StarterGui") .. "." .. params.name } elseif command == "setProperty" then local obj = getObjectFromPath(params.path) - if not obj then - return { - success = false, - error = "Object not found: " .. params.path, - } - end + if not obj then return { success = false, error = "Object not found: " .. params.path } end - -- Handle special property types local value = params.value - if params.property == "Position" or params.property == "Size" then - value = Vector3.new(value.x, value.y, value.z) - elseif params.property == "Color3" then - value = Color3.new(value.r, value.g, value.b) - elseif params.property == "BrickColor" then - value = BrickColor.new(value) - elseif params.property == "CFrame" then - if value.components then - value = CFrame.new(unpack(value.components)) - end - end - pcall(function() + if params.property == "Position" or params.property == "Size" then + value = Vector3.new(value.x, value.y, value.z) + elseif params.property == "CFrame" then + if value.components then value = CFrame.new(unpack(value.components)) end + elseif params.property == "Color" then + value = BrickColor.new(value).Color + end obj[params.property] = value end) - - return { - success = true, - property = params.property, - value = tostring(value), - } + return { success = true, property = params.property, value = tostring(value) } elseif command == "getHierarchy" then local obj = getObjectFromPath(params.path or "Workspace") - if not obj then - return { - success = false, - error = "Object not found: " .. (params.path or "Workspace"), - } - end - - local function buildHierarchy(object, depth, currentDepth) - if currentDepth > depth then - return nil - end + if not obj then return { success = false, error = "Object not found" } end + local function buildTree(object, depth, currentDepth) + if currentDepth > depth then return nil end local children = {} for _, child in ipairs(object:GetChildren()) do - local childData = { - name = child.Name, - className = child.ClassName, - } - + local childData = { name = child.Name, className = child.ClassName } if currentDepth < depth then - childData.children = buildHierarchy(child, depth, currentDepth + 1) + childData.children = buildTree(child, depth, currentDepth + 1) end - table.insert(children, childData) end - return children end - return { - success = true, - path = params.path or "Workspace", - children = buildHierarchy(obj, params.depth or 2, 0), - } + return { success = true, path = params.path or "Workspace", children = buildTree(obj, params.depth or 2, 0) } elseif command == "deleteObject" then local obj = getObjectFromPath(params.path) - if not obj then - return { - success = false, - error = "Object not found: " .. params.path, - } - end - + if not obj then return { success = false, error = "Object not found: " .. params.path } end obj:Destroy() - - return { - success = true, - deletedPath = params.path, - } + return { success = true, deletedPath = params.path } elseif command == "play" then - local mode = params.mode or "Both" - - if mode == "Server" then - game:Load("PlaySolo") - elseif mode == "Client" then - game:Load("PlayClient") - else - game:Load("PlaySolo") - end - - return { - success = true, - mode = mode, - } + pcall(function() game:Load("PlaySolo") end) + return { success = true, mode = params.mode or "Both" } elseif command == "stop" then - game:Load("Stop") - - return { - success = true, - } + pcall(function() game:Load("Stop") end) + return { success = true } elseif command == "savePlace" then - local success = pcall(function() - game:SavePlace() - end) - - return { - success = success, - } + local ok = pcall(function() game:SavePlace() end) + return { success = ok } elseif command == "executeCode" then - -- Execute code in the appropriate context - local context = params.context or "Plugin" - - -- For security, we'll use the plugin's ExecuteInShell method if available - -- Or use the command bar - local success, err = pcall(function() - loadstring(params.code)() - end) - - return { - success = success, - error = err, - context = context, - } + local fn, err = loadstring(params.code) + if not fn then return { success = false, error = err } end + local ok, execErr = pcall(fn) + return { success = ok, error = not ok and tostring(execErr) or nil } else - return { - success = false, - error = "Unknown command: " .. tostring(command), - } + return { success = false, error = "Unknown command: " .. tostring(command) } end end) - -- Send response back + -- Build response local response = { - id = requestId, - data = result, + id = cmd.id, + result = success and result or { success = false, error = tostring(result) }, } if not success then response.error = tostring(result) end - - if isConnected and websocket then - websocket:Send(game:GetService("HttpService"):JSONEncode(response)) - end + return response end --- WebSocket message handler -local function onMessage(message) - log("Received message from MCP server", "info") +-- Poll the MCP server for new commands +local function pollOnce() + local ok, data = httpRequest("GET", "/poll?last=" .. tostring(lastCommandId)) + if not ok then + return false, data + end - local data = game:GetService("HttpService"):JSONDecode(message) - handleCommand(data) + if data.commands and #data.commands > 0 then + for _, cmd in ipairs(data.commands) do + log("Received command: " .. (cmd.command or "unknown")) + + -- Execute command + local response = handleCommand(cmd) + + -- Send result back to MCP server + local sendOk, sendErr = httpRequest("POST", "/result", { + id = cmd.id, + result = response.result, + }) + + if not sendOk then + log("Failed to send result: " .. tostring(sendErr), "ERROR") + else + log("Command completed: " .. (cmd.command or "unknown")) + end + + lastCommandId = math.max(lastCommandId, cmd.id) + end + + -- Update lastCommandId from server + if data.lastId then + lastCommandId = math.max(lastCommandId, data.lastId) + end + end + + return true +end + +-- Main polling loop +local function startPolling() + if isPolling then return end + isPolling = true + log("Starting poll loop...") + + pollThread = spawn(function() + while isConnected and isPolling do + local ok, err = pollOnce() + if not ok then + log("Poll error: " .. tostring(err), "ERROR") + -- Don't disconnect on single poll failure, just wait + end + wait(CONFIG.POLL_INTERVAL) + end + log("Poll loop stopped") + end) end -- Connect to MCP server -local function connectToServer() - if isConnected then - return +local function connect() + if isConnected then return end + + log("Connecting to MCP server at " .. BASE_URL .. " ...") + + -- Health check first + local ok, data = httpRequest("GET", "/health") + if not ok then + log("MCP server not reachable: " .. tostring(data), "ERROR") + log("Make sure the MCP server is running: node C:\\Users\\Admin\\roblox-mcp-server\\src\\index.js", "ERROR") + return false end - log("Connecting to MCP server at ws://" .. CONFIG.WS_HOST .. ":" .. CONFIG.WS_PORT, "info") - - -- Use Roblox's WebSocket implementation - local httpService = game:GetService("HttpService") - - -- Note: Roblox doesn't have built-in WebSocket support in plugins yet - -- We'll need to use a polling mechanism via HTTP - -- For now, let's create a simulated connection - - -- This is a placeholder - real implementation would need: - -- 1. Either Roblox to add WebSocket support to plugins - -- 2. Or use HTTP polling as a fallback - -- 3. Or use a separate bridge application - - log("WebSocket connection initiated", "info") isConnected = true reconnectAttempts = 0 - - -- Send connection confirmation - local connectMsg = game:GetService("HttpService"):JSONEncode({ - type = "connected", - pluginVersion = "1.0.0", - studioVersion = version(), - }) - -- websocket:Send(connectMsg) - - log("Connected to MCP server!", "success") + lastCommandId = 0 + log("Connected to MCP server!") + startPolling() + return true end --- Disconnect from server -local function disconnectFromServer() - if websocket then - websocket:Close() - websocket = nil - end - +-- Disconnect from MCP server +local function disconnect() isConnected = false - log("Disconnected from MCP server", "info") + isPolling = false + if pollThread then + pollThread = nil + end + log("Disconnected from MCP server") end --- Try to reconnect -local function scheduleReconnect() - if reconnectTimer then - return +-- Create toolbar button +local function createUI() + if not Plugin or not Plugin:FindFirstChildWhichIsA("Toolbar") then + toolbar = Plugin:CreateToolbar("RobloxMCP") end - if reconnectAttempts >= CONFIG.MAX_RECONNECT_ATTEMPTS then - log("Max reconnection attempts reached. Please restart the plugin.", "error") - return - end - - reconnectAttempts = reconnectAttempts + 1 - log(string.format("Scheduling reconnect in %d seconds (attempt %d/%d)", CONFIG.RECONNECT_DELAY, reconnectAttempts, CONFIG.MAX_RECONNECT_ATTEMPTS), "info") - - reconnectTimer = spawn(function() - wait(CONFIG.RECONNECT_DELAY) - reconnectTimer = nil - connectToServer() - end) -end - --- Create plugin GUI -local function createPluginGui() - if not Plugin then - return - end - - local toolbar = Plugin:CreateToolbar("RobloxMCP") - local button = toolbar:CreateButton( - "Connect", - "Connect/Disconnect from MCP server", - "rbxassetid://413369506" -- Default icon + connectButton = toolbar:CreateButton( + "MCP Connect", + "Connect/Disconnect from Claude Code MCP server", + "rbxassetid://16706090882" ) - button.Click:Connect(function() + connectButton.Click:Connect(function() if isConnected then - disconnectFromServer() + disconnect() + connectButton.Icon = "rbxassetid://16706090882" else - connectToServer() + local success = connect() + if success then + connectButton.Icon = "rbxassetid://16706100672" + end end end) - - -- Create info dialog - local function showInfo() - local infoGui = Instance.new("ScreenGui") - infoGui.Name = "RobloxMCPInfo" - - local frame = Instance.new("Frame") - frame.Size = UDim2.new(0, 400, 0, 300) - frame.Position = UDim2.new(0.5, -200, 0.5, -150) - frame.BackgroundColor3 = Color3.new(0.1, 0.1, 0.1) - frame.Parent = infoGui - - local title = Instance.new("TextLabel") - title.Size = UDim2.new(1, 0, 0, 50) - title.Position = UDim2.new(0, 0, 0, 0) - title.BackgroundTransparency = 1 - title.Text = "Roblox MCP Plugin" - title.TextColor3 = Color3.new(1, 1, 1) - title.TextSize = 24 - title.Font = Enum.Font.GothamBold - title.Parent = frame - - local status = Instance.new("TextLabel") - status.Size = UDim2.new(1, -20, 0, 100) - status.Position = UDim2.new(0, 10, 0, 60) - status.BackgroundTransparency = 1 - status.Text = "Status: " .. (isConnected and "Connected" or "Disconnected") .. "\n\n" .. "Server: ws://" .. CONFIG.WS_HOST .. ":" .. CONFIG.WS_PORT - status.TextColor3 = isConnected and Color3.new(0, 1, 0) or Color3.new(1, 0, 0) - status.TextSize = 16 - status.Font = Enum.Font.Gotham - status.TextXAlignment = Enum.TextXAlignment.Left - status.TextYAlignment = Enum.TextYAlignment.Top - status.Parent = frame - - local close = Instance.new("TextButton") - close.Size = UDim2.new(0, 100, 0, 40) - close.Position = UDim2.new(0.5, -50, 1, -50) - close.BackgroundColor3 = Color3.new(0.2, 0.2, 0.2) - close.Text = "Close" - close.TextColor3 = Color3.new(1, 1, 1) - close.TextSize = 18 - close.Parent = frame - - close.MouseButton1Click:Connect(function() - infoGui:Destroy() - end) - - infoGui.Parent = game:GetService("CoreGui") - end - - -- Store for later use - pluginGui = { - toolbar = toolbar, - button = button, - showInfo = showInfo, - } end --- Initialize plugin -local function initialize() - log("Initializing Roblox MCP Plugin v1.0.0", "info") +-- Initialize +local function init() + log("Roblox MCP Plugin v2.0 (HTTP Polling) loaded") + log("Make sure HttpService is enabled: Game Settings > Security > Allow HTTP Requests") + createUI() - createPluginGui() - - -- Auto-connect on startup - connectToServer() - - log("Plugin initialized. Click the toolbar button to connect/disconnect.", "info") + -- Auto-connect attempt + spawn(function() + wait(2) -- Wait for Studio to fully load + connect() + if isConnected and connectButton then + connectButton.Icon = "rbxassetid://16706100672" + end + end) end --- Cleanup -local function cleanup() - disconnectFromServer() - - if reconnectTimer then - reconnectTimer:Cancel() - reconnectTimer = nil - end -end - --- Start the plugin -initialize() - --- Handle plugin unload +-- Cleanup on unload if Plugin then - Plugin.Unloading:Connect(cleanup) + Plugin.Unloading:Connect(function() + disconnect() + end) end -return { - connect = connectToServer, - disconnect = disconnectFromServer, - isConnected = function() - return isConnected - end, -} +init() diff --git a/roblox-plugin/RobloxMCPServer_HTTP.lua b/roblox-plugin/RobloxMCPServer_HTTP.lua new file mode 100644 index 0000000..c032640 --- /dev/null +++ b/roblox-plugin/RobloxMCPServer_HTTP.lua @@ -0,0 +1,239 @@ +-- Roblox MCP Server - HTTP Polling Version +-- This version polls the MCP server for commands via HTTP + +local HttpService = game:GetService("HttpService") +local RunService = game:GetService("RunService") + +-- Configuration +local MCP_SERVER_URL = "http://127.0.0.1:37423" +local POLL_INTERVAL = 0.5 -- seconds +local DEBUG = true + +-- State +local isRunning = true +local lastCommandId = 0 + +-- Logging +local function log(msg) + if DEBUG then + print("[RobloxMCP] " .. msg) + end +end + +-- Get object by path +local function getObjectFromPath(path) + if not path or path == "" then return nil end + if path == "game" or path == "Game" then return game end + if path == "Workspace" or path == "workspace" then return workspace end + + local parts = {} + for part in string.gmatch(path, "[^%.]+") do + table.insert(parts, part) + end + + local obj = game + for _, part in ipairs(parts) do + if part == "Workspace" or part == "workspace" then + obj = workspace + elseif typeof(obj) == "Instance" and obj:FindFirstChild(part) then + obj = obj[part] + else + return nil + end + end + return obj +end + +-- Create object at path +local function createObjectAt(path, className, properties) + local lastDot = string.find(path, "%.[^%.]+$") + local parentPath = lastDot and string.sub(path, 1, lastDot - 1) or "game" + local objectName = lastDot and string.sub(path, lastDot + 1) or path + + local parent = getObjectFromPath(parentPath) + if not parent then return nil, "Parent not found" end + + local obj = Instance.new(className) + obj.Name = objectName + + if properties then + for prop, value in pairs(properties) do + pcall(function() obj[prop] = value end) + end + end + + obj.Parent = parent + return obj +end + +-- Command handlers +local handlers = {} + +handlers.createPart = function(params) + local props = { + Name = params.partName, + Anchored = params.anchored ~= false, + Shape = Enum.PartType.Block, + } + + if params.position then + props.Position = Vector3.new(params.position.x or 0, params.position.y or 0, params.position.z or 0) + end + + if params.size then + props.Size = Vector3.new(params.size.x or 4, params.size.y or 1, params.size.z or 2) + end + + if params.color then + pcall(function() props.BrickColor = BrickColor.new(params.color) end) + end + + local part = createObjectAt((params.parentPath or "Workspace") .. "." .. params.partName, "Part", props) + return {success = part ~= nil} +end + +handlers.createScript = function(params) + local obj = createObjectAt(params.path .. "." .. params.scriptName, params.scriptType or "Script", {Name = params.scriptName}) + if obj then + obj.Source = params.source + return {success = true} + end + return {success = false} +end + +handlers.setProperty = function(params) + local obj = getObjectFromPath(params.path) + if not obj then return {success = false, error = "Not found"} end + + local value = params.value + if params.property == "Position" or params.property == "Size" then + value = Vector3.new(value.x, value.y, value.z) + elseif params.property == "Color3" then + value = Color3.new(value.r, value.g, value.b) + end + + pcall(function() obj[params.property] = value end) + return {success = true} +end + +handlers.executeCode = function(params) + local fn, err = loadstring(params.code) + if not fn then return {success = false, error = err} end + + local ok = pcall(fn) + return {success = ok} +end + +handlers.getHierarchy = function(params) + local obj = getObjectFromPath(params.path or "Workspace") + if not obj then return {success = false, error = "Not found"} end + + local function build(obj, depth) + if depth <= 0 then return nil end + local children = {} + for _, child in ipairs(obj:GetChildren()) do + table.insert(children, { + name = child.Name, + className = child.ClassName, + }) + end + return children + end + + return {success = true, children = build(obj, params.depth or 2)} +end + +handlers.importGLB = function(params) + -- Import GLB model into Roblox Studio + -- GLB files need to be imported via the Editor API for assets + -- For now, we'll create a placeholder model with instructions + + local parent = getObjectFromPath(params.parentPath or "Workspace") + if not parent then + return {success = false, error = "Parent path not found"} + end + + -- Create a model to hold the imported GLB + local model = Instance.new("Model") + model.Name = params.modelName or "ImportedGLB" + model.Parent = parent + + -- Create a placeholder part with info + local placeholder = Instance.new("Part") + placeholder.Name = "GLB_Placeholder" + placeholder.Size = Vector3.new(4, 4, 4) + placeholder.Position = Vector3.new(0, 5, 0) + placeholder.Anchored = true + placeholder.BrickColor = BrickColor.new("Bright blue") + placeholder.Transparency = 0.5 + placeholder.Parent = model + + -- Add a note + local info = Instance.new("StringValue") + info.Name = "ImportInfo" + info.Value = "GLB Import: Use the 3D Importer (File > Import 3D) or Editor Service to import GLB files. This is a placeholder." + info.Parent = model + + return { + success = true, + modelPath = (params.parentPath or "Workspace") .. "." .. params.modelName, + note = "GLB files require manual import via Roblox Studio's 3D Importer or Editor Service API" + } +end + +-- Poll for commands +local function pollForCommands() + local success, response = pcall(function() + return HttpService:RequestAsync({ + Url = MCP_SERVER_URL .. "/poll?last=" .. lastCommandId, + Method = "GET", + }) + end) + + if success and response.Success then + local data = HttpService:JSONDecode(response.Body) + if data.commands then + for _, cmd in ipairs(data.commands) do + log("Got command: " .. cmd.command) + lastCommandId = cmd.id + + local handler = handlers[cmd.command] + local result = {success = false, error = "Unknown command"} + + if handler then + local ok, ret = pcall(handler, cmd.params) + if ok then + result = ret + else + result = {success = false, error = tostring(ret)} + end + end + + -- Send result back + pcall(function() + HttpService:RequestAsync({ + Url = MCP_SERVER_URL .. "/result", + Method = "POST", + Headers = {["Content-Type"] = "application/json"}, + Body = HttpService:JSONEncode({ + id = cmd.id, + result = result + }) + }) + end) + end + end + end +end + +-- Main loop +log("Starting Roblox MCP Server (HTTP Polling)") +log("MCP Server: " .. MCP_SERVER_URL) + +RunService.Heartbeat:Connect(function() + if isRunning then + pcall(pollForCommands) + end +end) + +log("Roblox MCP Server is running!") diff --git a/roblox-plugin/TestConnection.lua b/roblox-plugin/TestConnection.lua new file mode 100644 index 0000000..fcac9ea --- /dev/null +++ b/roblox-plugin/TestConnection.lua @@ -0,0 +1,89 @@ +-- Simple Roblox MCP Connection Test +-- Put this in ServerScriptService and Press Play + +local HttpService = game:GetService("HttpService") + +print("=" .. string.rep("=", 50)) +print("ROBLOX MCP CONNECTION TEST") +print("=" .. string.rep("=", 50)) + +-- Test 1: Check HTTP Service +print("\n[TEST 1] Checking HttpService...") +local success = pcall(function() + HttpService:GetAsync("http://127.0.0.1:37423/health") +end) + +if success then + print("✓ HTTP requests are WORKING!") +else + print("✗ HTTP requests are BLOCKED") + print("\nFIX: Go to File → Game Settings → Security") + print(" Enable BOTH HTTP options!") + warn("Cannot connect without HTTP enabled!") +end + +-- Test 2: Try to connect to MCP server +print("\n[TEST 2] Connecting to MCP Server...") +local response = pcall(function() + local result = HttpService:RequestAsync({ + Url = "http://127.0.0.1:37423/health", + Method = "GET", + }) + print("✓ MCP Server is RESPONDING!") + print(" Response: " .. result.Body) + return true +end) + +if not response then + print("✗ MCP Server is NOT responding") + print(" Make sure 'npm start' is running!") +end + +-- Test 3: Test polling +print("\n[TEST 3] Testing command polling...") +local pollResult = pcall(function() + local result = HttpService:RequestAsync({ + Url = "http://127.0.0.1:37423/poll?last=0", + Method = "GET", + }) + local data = HttpService:JSONDecode(result.Body) + print("✓ Polling is WORKING!") + print(" Commands waiting: " .. #data.commands) + return true +end) + +if not pollResult then + print("✗ Polling FAILED") +end + +print("\n" .. string.rep("=", 51)) +print("CONNECTION TEST COMPLETE") +print(string.rep("=", 51)) + +-- Status indicator +local status = Instance.new("ScreenGui") +status.Parent = game:GetService("CoreGui") + +local frame = Instance.new("Frame") +frame.Size = UDim2.new(0, 300, 0, 100) +frame.Position = UDim2.new(0.5, -150, 0, 10) + frame.BackgroundColor3 = Color3.new(0.1, 0.1, 0.1) +frame.Parent = status + +local text = Instance.new("TextLabel") +text.Size = UDim2.new(1, 0, 1, 0) +text.BackgroundTransparency = 1 +text.Text = "Roblox MCP Test\nRunning..." +text.TextColor3 = Color3.new(1, 1, 0) +text.TextScaled = true +text.Parent = frame + +if success and response then + text.Text = "MCP CONNECTED!\nReady for commands!" + text.TextColor3 = Color3.new(0, 1, 0) +else + text.Text = "MCP NOT CONNECTED\nCheck Output window" + text.TextColor3 = Color3.new(1, 0, 0) +end + +game:GetService("Debris"):AddItem(status, 10) diff --git a/src/index.js b/src/index.js index 8ef7209..8f953ee 100644 --- a/src/index.js +++ b/src/index.js @@ -23,6 +23,11 @@ let studioClients = new Set(); let pendingRequests = new Map(); let requestIdCounter = 0; +// HTTP polling support (alternative to WebSocket) +let pendingCommands = []; +let commandResults = new Map(); +let commandIdCounter = 0; + // Create MCP server const server = new Server( { @@ -40,33 +45,34 @@ const server = new Server( async function sendToStudio(command, params = {}) { return new Promise((resolve, reject) => { const requestId = ++requestIdCounter; + const commandId = ++commandIdCounter; - // Check if any Studio client is connected - if (studioClients.size === 0) { - reject(new Error( - 'No Roblox Studio instance connected. Please:\n' + - '1. Open Roblox Studio\n' + - '2. Install the RobloxMCP plugin (see RobloxMCFPlugin.lua)\n' + - '3. Make sure the plugin is running' - )); - return; - } - - // Set up response handler - pendingRequests.set(requestId, { resolve, reject, timeout: setTimeout(() => { + // Set up response handler (supports both WebSocket and HTTP polling) + const timeout = setTimeout(() => { pendingRequests.delete(requestId); + commandResults.delete(commandId); reject(new Error('Request timeout - Roblox Studio did not respond')); - }, 30000) }); + }, 30000); - // Send to all connected Studio clients - const message = JSON.stringify({ id: requestId, command, params }); - studioClients.forEach(ws => { - if (ws.readyState === ws.OPEN) { - ws.send(message); - } - }); + // Store in both maps for WebSocket and HTTP compatibility + pendingRequests.set(requestId, { resolve, reject, timeout }); + commandResults.set(commandId, { resolve, reject, timeout }); - console.error(`[MCP] Sent command ${command} (ID: ${requestId})`); + // Add command to HTTP polling queue + pendingCommands.push({ id: commandId, requestId, command, params, timestamp: Date.now() }); + + // Try to send via WebSocket if available + if (studioClients.size > 0) { + const message = JSON.stringify({ id: commandId, requestId, command, params }); + studioClients.forEach(ws => { + if (ws.readyState === ws.OPEN) { + ws.send(message); + } + }); + console.error(`[MCP] Sent command ${command} (WS ID: ${commandId})`); + } else { + console.error(`[MCP] Queued command ${command} (HTTP ID: ${commandId}) - waiting for Roblox Studio to poll`); + } }); } @@ -326,6 +332,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ['guiType', 'name'], }, }, + { + name: 'roblox_import_glb', + description: 'Import a GLB 3D model into Roblox Studio', + inputSchema: { + type: 'object', + properties: { + glbData: { + type: 'string', + description: 'Base64-encoded GLB model data', + }, + parentPath: { + type: 'string', + description: 'Parent path (e.g., "Workspace")', + default: 'Workspace', + }, + modelName: { + type: 'string', + description: 'Name for the imported model', + }, + }, + required: ['glbData', 'modelName'], + }, + }, ], }; }); @@ -426,6 +455,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }); break; + case 'roblox_import_glb': + result = await sendToStudio('importGLB', { + glbData: args.glbData, + parentPath: args.parentPath || 'Workspace', + modelName: args.modelName, + }); + break; + default: throw new Error(`Unknown tool: ${name}`); } @@ -498,11 +535,62 @@ wss.on('connection', (ws) => { app.get('/health', (req, res) => { res.json({ status: 'ok', - studioConnected: studioClients.size > 0, + studioConnected: studioClients.size > 0 || pendingCommands.length > 0, connections: studioClients.size, + pendingCommands: pendingCommands.length, }); }); +// HTTP polling endpoint for Roblox Studio +app.get('/poll', (req, res) => { + const lastId = parseInt(req.query.last || '0'); + + // Filter commands newer than lastId + const newCommands = pendingCommands.filter(cmd => cmd.id > lastId); + + // Clean up old commands (older than 5 minutes) + const now = Date.now(); + pendingCommands = pendingCommands.filter(cmd => now - cmd.timestamp < 300000); + + res.json({ + commands: newCommands, + lastId: commandIdCounter, + }); +}); + +// Result endpoint for Roblox Studio to send command results +app.post('/result', (req, res) => { + const { id, result } = req.body; + + // Store result for pending request + if (commandResults.has(id)) { + const { resolve, reject, timeout } = commandResults.get(id); + clearTimeout(timeout); + commandResults.delete(id); + + if (result && result.success === false) { + reject(new Error(result.error || 'Command failed')); + } else { + resolve(result); + } + } else { + // Also check pendingRequests for WebSocket compatibility + if (pendingRequests.has(id)) { + const { resolve, reject, timeout } = pendingRequests.get(id); + clearTimeout(timeout); + pendingRequests.delete(id); + + if (result && result.success === false) { + reject(new Error(result.error || 'Command failed')); + } else { + resolve(result); + } + } + } + + res.json({ success: true }); +}); + // Start Express server app.listen(HTTP_PORT, () => { console.error(`HTTP server listening on port ${HTTP_PORT}`);