Add FPS game example, auto-connect plugin, and Python injection tools
- Updated RobloxMCPPlugin with HTTP polling (auto-enables HttpService) - Added 20-weapon FPS game example (CoD-style) - Added Python studio-inject.py for command bar injection via Win32 API - Added auto-connect setup scripts (VBS + PowerShell) - Updated MCP server with all FPS game tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
17
AutoConnect/AutoSetup.vbs
Normal file
17
AutoConnect/AutoSetup.vbs
Normal file
@@ -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"
|
||||||
29
AutoConnect/START-HERE.bat
Normal file
29
AutoConnect/START-HERE.bat
Normal file
@@ -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
|
||||||
26
AutoConnect/Setup-MCP.bat
Normal file
26
AutoConnect/Setup-MCP.bat
Normal file
@@ -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
|
||||||
86
AutoConnect/Setup-MCP.ps1
Normal file
86
AutoConnect/Setup-MCP.ps1
Normal file
@@ -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
|
||||||
|
}
|
||||||
424
README.md
424
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
|
## Features
|
||||||
|
|
||||||
- Create and modify scripts in Roblox Studio
|
- Create 3D parts, models, folders, and entire scenes
|
||||||
- Create 3D parts, models, and folders
|
- Write and inject Lua scripts (Script, LocalScript, ModuleScript)
|
||||||
- Build GUI elements (ScreenGui, Frame, TextButton, etc.)
|
- Build GUI elements (ScreenGui, Frame, TextButton, etc.)
|
||||||
- Set properties on any object
|
- Manipulate workspace hierarchy
|
||||||
- Get hierarchy information
|
- Execute arbitrary Lua code
|
||||||
- Execute Lua code
|
- Playtest and save places automatically
|
||||||
- Play/Stop playtesting
|
- Python injection scripts for direct command bar automation
|
||||||
- Save places
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Claude Code <--(MCP)--> Node.js MCP Server <--(WebSocket)--> Roblox Studio Plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
## 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
|
```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
|
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
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"roblox-studio": {
|
"roblox-studio": {
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["/mnt/c/Users/Admin/roblox-mcp-server/src/index.js"],
|
"args": ["~/ClaudeCode-Roblox-Studio-MCP/src/index.js"],
|
||||||
"cwd": "/mnt/c/Users/Admin/roblox-mcp-server"
|
"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:
|
**Mac:**
|
||||||
- **Windows**: `C:\Users\YOUR_USERNAME\AppData\Local\Roblox\Plugins\RobloxMCPServer.lua`
|
```bash
|
||||||
- **Mac**: `~/Library/Application Support/Roblox/Plugins/RobloxMCPServer.lua`
|
cp roblox-plugin/RobloxMCPPlugin.lua ~/Library/Application\ Support/Roblox/Plugins/
|
||||||
|
```
|
||||||
|
|
||||||
2. Open Roblox Studio
|
### Step 4: Enable HTTP Requests (Critical!)
|
||||||
|
|
||||||
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!)
|
|
||||||
|
|
||||||
In Roblox Studio:
|
In Roblox Studio:
|
||||||
1. Go to **Game Settings → Security**
|
1. **File** → **Game Settings**
|
||||||
2. **Enable** "Allow HTTP Requests"
|
2. **Security** tab
|
||||||
3. Set **Enable Studio Access to API Services** to ON
|
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
|
## Available MCP Tools
|
||||||
npm start
|
|
||||||
|
| 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:
|
### Building a GUI
|
||||||
- HTTP: `http://localhost:37423` (for health checks)
|
|
||||||
- WebSocket: `ws://localhost:37424` (for Roblox Studio communication)
|
|
||||||
|
|
||||||
### Starting Roblox Studio Communication
|
```
|
||||||
|
Create a ScreenGui with:
|
||||||
1. Open Roblox Studio with the plugin installed
|
- A dark semi-transparent frame
|
||||||
2. Press **Play** to start the server script
|
- A title saying "MY GAME"
|
||||||
3. You should see a status indicator in the top-right corner
|
- A green start button
|
||||||
4. The MCP server will automatically connect
|
- Make the button print "Started!" when clicked
|
||||||
|
|
||||||
### 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')"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Tools
|
### Scripting
|
||||||
|
|
||||||
| Tool | Description |
|
```
|
||||||
|------|-------------|
|
Create a Script in Workspace.Part that makes it spin continuously
|
||||||
| `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 |
|
## Python Injection Scripts
|
||||||
| `roblox_create_folder` | Create folders for organization |
|
|
||||||
| `roblox_create_gui` | Create GUI elements |
|
The `examples/` folder includes Python scripts for direct injection into Roblox Studio:
|
||||||
| `roblox_set_property` | Set properties on existing objects |
|
|
||||||
| `roblox_get_hierarchy` | Get the object hierarchy |
|
### studio-inject.py
|
||||||
| `roblox_delete_object` | Delete an object by path |
|
Inject a single Lua script into the Roblox Studio command bar using Win32 API.
|
||||||
| `roblox_execute_code` | Execute arbitrary Lua code |
|
|
||||||
| `roblox_play` | Start playtest |
|
```bash
|
||||||
| `roblox_stop` | Stop playtest |
|
python examples/studio-inject.py
|
||||||
| `roblox_save_place` | Save the current place |
|
```
|
||||||
|
|
||||||
|
### 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
|
## Configuration
|
||||||
|
|
||||||
Edit `src/index.js` to change ports:
|
### MCP Server Ports
|
||||||
|
|
||||||
|
Edit `src/index.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const HTTP_PORT = 37423; // Health check endpoint
|
const HTTP_PORT = 37423; // HTTP polling endpoint
|
||||||
const WS_PORT = 37424; // WebSocket for Roblox Studio
|
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
|
```lua
|
||||||
local CONFIG = {
|
local CONFIG = {
|
||||||
PORT = 37425,
|
HOST = "localhost",
|
||||||
POLL_INTERVAL = 0.1,
|
PORT = 37423,
|
||||||
DEBUG = true,
|
POLL_INTERVAL = 0.5, -- seconds between polls
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### "No Roblox Studio instance connected"
|
### "MCP server not reachable"
|
||||||
|
|
||||||
- Make sure Roblox Studio is open
|
**Solution:**
|
||||||
- Make sure the server script is running (press Play)
|
- Make sure the MCP server is running: `npm start`
|
||||||
- Check that HTTP requests are enabled in Game Settings
|
- Check port 37423 is not in use
|
||||||
- Look for the status indicator in the top-right corner
|
- Verify Claude Code config is correct
|
||||||
|
|
||||||
### WebSocket Connection Failed
|
### "HTTP Requests Blocked"
|
||||||
|
|
||||||
- Check that the MCP server is running (`npm start`)
|
**Solution:**
|
||||||
- Verify the WS_PORT matches between server and plugin
|
- Game Settings → Security
|
||||||
- Check Windows Firewall if connection is refused
|
- 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)
|
**Solution:**
|
||||||
- Check the Output window in Roblox Studio for errors
|
- Check Output window in Roblox Studio
|
||||||
- Verify the parent path is correct
|
- 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
|
**Solution:**
|
||||||
- Enable "Allow HTTP Requests"
|
- Check for Lua errors in Output window
|
||||||
- Enable "Enable Studio Access to API Services"
|
- 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
|
## 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
|
### Adding New Tools
|
||||||
|
|
||||||
1. Add the tool definition to `src/index.js` in the `ListToolsRequestSchema` handler
|
1. Add tool definition in `src/index.js` (in `ListToolsRequestSchema` handler)
|
||||||
2. Add a case in the `CallToolRequestSchema` handler
|
2. Add handler in `CallToolRequestSchema` handler
|
||||||
3. Add a corresponding handler in `roblox-plugin/RobloxMCPServer.lua`
|
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
|
```lua
|
||||||
- Only use in trusted environments
|
-- roblox-plugin/RobloxMCPPlugin.lua
|
||||||
- HTTP requests must be enabled in Roblox Studio
|
elseif command == "myTool" then
|
||||||
- Consider using a reverse proxy for production deployments
|
-- Your implementation
|
||||||
|
return { success = true, result = "something" }
|
||||||
## License
|
```
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
||||||
## Contributing
|
## 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**
|
||||||
|
|||||||
242
examples/fps-game/part1_map.lua
Normal file
242
examples/fps-game/part1_map.lua
Normal file
@@ -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.")
|
||||||
383
examples/fps-game/part2_weapons.lua
Normal file
383
examples/fps-game/part2_weapons.lua
Normal file
@@ -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.")
|
||||||
542
examples/fps-game/part3_ai.lua
Normal file
542
examples/fps-game/part3_ai.lua
Normal file
@@ -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.")
|
||||||
421
examples/fps-game/part4_hud.lua
Normal file
421
examples/fps-game/part4_hud.lua
Normal file
@@ -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.")
|
||||||
371
examples/fps-game/part5_client.lua
Normal file
371
examples/fps-game/part5_client.lua
Normal file
@@ -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("═══════════════════════════════════════════")
|
||||||
176
examples/inject-all-parts.py
Normal file
176
examples/inject-all-parts.py
Normal file
@@ -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()
|
||||||
181
examples/studio-inject.py
Normal file
181
examples/studio-inject.py
Normal file
@@ -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()
|
||||||
@@ -1,89 +1,131 @@
|
|||||||
--[[
|
--[[
|
||||||
Roblox MCP Plugin
|
Roblox MCP Plugin v2.0 - HTTP Polling Edition
|
||||||
This plugin connects Roblox Studio to the MCP server, allowing Claude AI to control it.
|
Connects Roblox Studio to Claude Code via MCP server.
|
||||||
|
|
||||||
Installation:
|
Installation:
|
||||||
1. Copy this file to: Plugins/RobloxMCPPlugin.lua
|
1. Copy this file to: %LOCALAPPDATA%\Roblox\Plugins\RobloxMCPPlugin.lua
|
||||||
- Windows: C:\Users\YOUR_USERNAME\AppData\Local\Roblox\Plugins\
|
2. Open Roblox Studio
|
||||||
- Mac: ~/Library/Application Support/Roblox/Plugins/
|
3. Enable HttpService in: Game Settings > Security > Allow HTTP Requests = ON
|
||||||
2. Restart Roblox Studio
|
|
||||||
3. Enable the plugin via Plugin Management
|
|
||||||
4. The plugin will auto-connect to the MCP server
|
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
|
-- Configuration
|
||||||
local CONFIG = {
|
local CONFIG = {
|
||||||
WS_HOST = "localhost",
|
HOST = "localhost",
|
||||||
WS_PORT = 37423,
|
PORT = 37423,
|
||||||
RECONNECT_DELAY = 3,
|
POLL_INTERVAL = 0.5, -- seconds between polls
|
||||||
MAX_RECONNECT_ATTEMPTS = 10,
|
RECONNECT_DELAY = 5,
|
||||||
|
MAX_RECONNECT_ATTEMPTS = 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Build base URL
|
||||||
|
local BASE_URL = "http://" .. CONFIG.HOST .. ":" .. CONFIG.PORT
|
||||||
|
|
||||||
-- State
|
-- State
|
||||||
local websocket = nil
|
|
||||||
local isConnected = false
|
local isConnected = false
|
||||||
|
local isPolling = false
|
||||||
local reconnectAttempts = 0
|
local reconnectAttempts = 0
|
||||||
local reconnectTimer = nil
|
local lastCommandId = 0
|
||||||
local pluginGui = nil
|
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)
|
local function log(message, level)
|
||||||
level = level or "info"
|
level = level or "INFO"
|
||||||
local prefix = "[RobloxMCP]"
|
print("[RobloxMCP] [" .. level .. "] " .. message)
|
||||||
local fullMessage = string.format("%s %s: %s", prefix, level:upper(), message)
|
if level == "ERROR" then
|
||||||
|
warn("[RobloxMCP] " .. message)
|
||||||
print(fullMessage)
|
|
||||||
|
|
||||||
-- Also show in a dialog if it's an error
|
|
||||||
if level == "error" then
|
|
||||||
warn(fullMessage)
|
|
||||||
end
|
end
|
||||||
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)
|
local function getObjectFromPath(path)
|
||||||
if not path or path == "" then
|
if not path or path == "" then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Handle special paths
|
|
||||||
if path == "game" or path == "Game" then
|
if path == "game" or path == "Game" then
|
||||||
return game
|
return game
|
||||||
end
|
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 = {}
|
local parts = {}
|
||||||
for part in string.gmatch(path, "[^%.]+") do
|
for part in string.gmatch(path, "[^%.]+") do
|
||||||
table.insert(parts, part)
|
table.insert(parts, part)
|
||||||
end
|
end
|
||||||
|
if #parts == 0 then return nil end
|
||||||
|
|
||||||
if #parts == 0 then
|
-- Start from service or game
|
||||||
|
local obj = serviceMap[parts[1]] or game
|
||||||
|
|
||||||
|
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
|
return nil
|
||||||
end
|
end
|
||||||
|
obj = child
|
||||||
-- 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")
|
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Create an object at a path
|
-- Create object at path
|
||||||
local function createObjectAt(path, className, properties)
|
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 objectName = string.match(path, "%.([^%.]+)$") or path
|
||||||
|
|
||||||
local parent = getObjectFromPath(parentPath)
|
local parent = getObjectFromPath(parentPath)
|
||||||
@@ -91,11 +133,9 @@ local function createObjectAt(path, className, properties)
|
|||||||
return nil, "Parent not found: " .. parentPath
|
return nil, "Parent not found: " .. parentPath
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Create the object
|
|
||||||
local obj = Instance.new(className)
|
local obj = Instance.new(className)
|
||||||
obj.Name = objectName
|
obj.Name = objectName
|
||||||
|
|
||||||
-- Set properties
|
|
||||||
if properties then
|
if properties then
|
||||||
for propName, propValue in pairs(properties) do
|
for propName, propValue in pairs(properties) do
|
||||||
pcall(function()
|
pcall(function()
|
||||||
@@ -108,491 +148,282 @@ local function createObjectAt(path, className, properties)
|
|||||||
return obj
|
return obj
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Handle incoming commands from MCP server
|
-- Handle a single command from MCP server
|
||||||
local function handleCommand(data)
|
local function handleCommand(cmd)
|
||||||
local command = data.command
|
local command = cmd.command
|
||||||
local params = data.params or {}
|
local params = cmd.params or {}
|
||||||
local requestId = data.id
|
|
||||||
|
|
||||||
log("Received command: " .. command, "info")
|
log("Executing: " .. command)
|
||||||
|
|
||||||
local success, result = pcall(function()
|
local success, result = pcall(function()
|
||||||
if command == "createScript" then
|
if command == "createScript" then
|
||||||
-- Create a script object
|
|
||||||
local scriptObj = createObjectAt(params.path, params.scriptType or "Script", {
|
local scriptObj = createObjectAt(params.path, params.scriptType or "Script", {
|
||||||
Name = params.scriptName,
|
Name = params.scriptName,
|
||||||
})
|
})
|
||||||
|
|
||||||
if scriptObj then
|
if scriptObj then
|
||||||
-- Set the source code (in Roblox, this is the Source property)
|
pcall(function() scriptObj.Source = params.source end)
|
||||||
if scriptObj:IsA("ModuleScript") then
|
return { success = true, objectPath = params.path }
|
||||||
-- Wait for it to be parented first, then set source
|
|
||||||
scriptObj.Source = params.source
|
|
||||||
else
|
else
|
||||||
scriptObj.Source = params.source
|
return { success = false, error = "Failed to create script" }
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
success = true,
|
|
||||||
objectPath = params.path .. "." .. params.scriptName,
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return {
|
|
||||||
success = false,
|
|
||||||
error = "Failed to create script",
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif command == "createPart" then
|
elseif command == "createPart" then
|
||||||
local properties = {
|
local props = { Name = params.partName, Anchored = params.anchored ~= false }
|
||||||
Name = params.partName,
|
|
||||||
Anchored = params.anchored ~= false,
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Set shape based on partType
|
|
||||||
local shapeEnum = Enum.PartType.Block
|
local shapeEnum = Enum.PartType.Block
|
||||||
if params.partType == "Ball" then
|
if params.partType == "Ball" then shapeEnum = Enum.PartType.Ball
|
||||||
shapeEnum = Enum.PartType.Ball
|
elseif params.partType == "Cylinder" then shapeEnum = Enum.PartType.Cylinder
|
||||||
elseif params.partType == "Cylinder" then
|
elseif params.partType == "Wedge" then shapeEnum = Enum.PartType.Wedge
|
||||||
shapeEnum = Enum.PartType.Cylinder
|
elseif params.partType == "CornerWedge" then shapeEnum = Enum.PartType.CornerWedge
|
||||||
elseif params.partType == "Wedge" then
|
|
||||||
shapeEnum = Enum.PartType.Wedge
|
|
||||||
elseif params.partType == "CornerWedge" then
|
|
||||||
shapeEnum = Enum.PartType.CornerWedge
|
|
||||||
end
|
end
|
||||||
properties.Shape = shapeEnum
|
props.Shape = shapeEnum
|
||||||
|
|
||||||
-- Set position
|
|
||||||
if params.position then
|
if params.position then
|
||||||
properties.Position = Vector3.new(
|
props.Position = Vector3.new(params.position.x or 0, params.position.y or 0, params.position.z or 0)
|
||||||
params.position.x or 0,
|
|
||||||
params.position.y or 0,
|
|
||||||
params.position.z or 0
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Set size
|
|
||||||
if params.size then
|
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
|
end
|
||||||
|
|
||||||
-- Set color
|
|
||||||
if params.color then
|
if params.color then
|
||||||
local success = pcall(function()
|
pcall(function() props.Color = BrickColor.new(params.color).Color end)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local part = createObjectAt(params.parentPath or "Workspace", "Part", properties)
|
local part = createObjectAt(params.parentPath or "Workspace", "Part", props)
|
||||||
|
return { success = part ~= nil, objectPath = (params.parentPath or "Workspace") .. "." .. params.partName }
|
||||||
return {
|
|
||||||
success = part ~= nil,
|
|
||||||
objectPath = (params.parentPath or "Workspace") .. "." .. params.partName,
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "createModel" then
|
elseif command == "createModel" then
|
||||||
local model = createObjectAt(params.parentPath or "Workspace", "Model", {
|
local model = createObjectAt(params.parentPath or "Workspace", "Model", { Name = params.modelName })
|
||||||
Name = params.modelName,
|
return { success = model ~= nil, objectPath = (params.parentPath or "Workspace") .. "." .. params.modelName }
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
success = model ~= nil,
|
|
||||||
objectPath = (params.parentPath or "Workspace") .. "." .. params.modelName,
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "createFolder" then
|
elseif command == "createFolder" then
|
||||||
local folder = createObjectAt(params.parentPath or "Workspace", "Folder", {
|
local folder = createObjectAt(params.parentPath or "Workspace", "Folder", { Name = params.folderName })
|
||||||
Name = params.folderName,
|
return { success = folder ~= nil, objectPath = (params.parentPath or "Workspace") .. "." .. params.folderName }
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
success = folder ~= nil,
|
|
||||||
objectPath = (params.parentPath or "Workspace") .. "." .. params.folderName,
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "createGUI" then
|
elseif command == "createGUI" then
|
||||||
local properties = params.properties or {}
|
local properties = params.properties or {}
|
||||||
properties.Name = params.name
|
properties.Name = params.name
|
||||||
|
|
||||||
-- Set default GUI properties
|
|
||||||
if params.guiType == "ScreenGui" then
|
if params.guiType == "ScreenGui" then
|
||||||
properties.ResetOnSpawn = false
|
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
|
end
|
||||||
if not properties.Position then
|
if params.guiType == "Frame" or params.guiType == "TextLabel" or params.guiType == "TextButton" then
|
||||||
properties.Position = UDim2.new(0, 0, 0, 0)
|
if not properties.Size then properties.Size = UDim2.new(0, 200, 0, 50) end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
-- Set text properties for text-based GUI
|
|
||||||
if params.guiType == "TextLabel" or params.guiType == "TextButton" then
|
if params.guiType == "TextLabel" or params.guiType == "TextButton" then
|
||||||
if not properties.Text then
|
if not properties.Text then properties.Text = params.name end
|
||||||
properties.Text = params.name
|
|
||||||
end
|
end
|
||||||
if not properties.TextScaled then
|
|
||||||
properties.TextScaled = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local gui = createObjectAt(params.parentPath or "StarterGui", params.guiType, properties)
|
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
|
elseif command == "setProperty" then
|
||||||
local obj = getObjectFromPath(params.path)
|
local obj = getObjectFromPath(params.path)
|
||||||
if not obj then
|
if not obj then return { success = false, error = "Object not found: " .. params.path } end
|
||||||
return {
|
|
||||||
success = false,
|
|
||||||
error = "Object not found: " .. params.path,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Handle special property types
|
|
||||||
local value = params.value
|
local value = params.value
|
||||||
|
pcall(function()
|
||||||
if params.property == "Position" or params.property == "Size" then
|
if params.property == "Position" or params.property == "Size" then
|
||||||
value = Vector3.new(value.x, value.y, value.z)
|
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
|
elseif params.property == "CFrame" then
|
||||||
if value.components then
|
if value.components then value = CFrame.new(unpack(value.components)) end
|
||||||
value = CFrame.new(unpack(value.components))
|
elseif params.property == "Color" then
|
||||||
|
value = BrickColor.new(value).Color
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
pcall(function()
|
|
||||||
obj[params.property] = value
|
obj[params.property] = value
|
||||||
end)
|
end)
|
||||||
|
return { success = true, property = params.property, value = tostring(value) }
|
||||||
return {
|
|
||||||
success = true,
|
|
||||||
property = params.property,
|
|
||||||
value = tostring(value),
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "getHierarchy" then
|
elseif command == "getHierarchy" then
|
||||||
local obj = getObjectFromPath(params.path or "Workspace")
|
local obj = getObjectFromPath(params.path or "Workspace")
|
||||||
if not obj then
|
if not obj then return { success = false, error = "Object not found" } end
|
||||||
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
|
|
||||||
|
|
||||||
|
local function buildTree(object, depth, currentDepth)
|
||||||
|
if currentDepth > depth then return nil end
|
||||||
local children = {}
|
local children = {}
|
||||||
for _, child in ipairs(object:GetChildren()) do
|
for _, child in ipairs(object:GetChildren()) do
|
||||||
local childData = {
|
local childData = { name = child.Name, className = child.ClassName }
|
||||||
name = child.Name,
|
|
||||||
className = child.ClassName,
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentDepth < depth then
|
if currentDepth < depth then
|
||||||
childData.children = buildHierarchy(child, depth, currentDepth + 1)
|
childData.children = buildTree(child, depth, currentDepth + 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
table.insert(children, childData)
|
table.insert(children, childData)
|
||||||
end
|
end
|
||||||
|
|
||||||
return children
|
return children
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return { success = true, path = params.path or "Workspace", children = buildTree(obj, params.depth or 2, 0) }
|
||||||
success = true,
|
|
||||||
path = params.path or "Workspace",
|
|
||||||
children = buildHierarchy(obj, params.depth or 2, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "deleteObject" then
|
elseif command == "deleteObject" then
|
||||||
local obj = getObjectFromPath(params.path)
|
local obj = getObjectFromPath(params.path)
|
||||||
if not obj then
|
if not obj then return { success = false, error = "Object not found: " .. params.path } end
|
||||||
return {
|
|
||||||
success = false,
|
|
||||||
error = "Object not found: " .. params.path,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
obj:Destroy()
|
obj:Destroy()
|
||||||
|
return { success = true, deletedPath = params.path }
|
||||||
return {
|
|
||||||
success = true,
|
|
||||||
deletedPath = params.path,
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "play" then
|
elseif command == "play" then
|
||||||
local mode = params.mode or "Both"
|
pcall(function() game:Load("PlaySolo") end)
|
||||||
|
return { success = true, 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "stop" then
|
elseif command == "stop" then
|
||||||
game:Load("Stop")
|
pcall(function() game:Load("Stop") end)
|
||||||
|
return { success = true }
|
||||||
return {
|
|
||||||
success = true,
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "savePlace" then
|
elseif command == "savePlace" then
|
||||||
local success = pcall(function()
|
local ok = pcall(function() game:SavePlace() end)
|
||||||
game:SavePlace()
|
return { success = ok }
|
||||||
end)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success = success,
|
|
||||||
}
|
|
||||||
|
|
||||||
elseif command == "executeCode" then
|
elseif command == "executeCode" then
|
||||||
-- Execute code in the appropriate context
|
local fn, err = loadstring(params.code)
|
||||||
local context = params.context or "Plugin"
|
if not fn then return { success = false, error = err } end
|
||||||
|
local ok, execErr = pcall(fn)
|
||||||
-- For security, we'll use the plugin's ExecuteInShell method if available
|
return { success = ok, error = not ok and tostring(execErr) or nil }
|
||||||
-- Or use the command bar
|
|
||||||
local success, err = pcall(function()
|
|
||||||
loadstring(params.code)()
|
|
||||||
end)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success = success,
|
|
||||||
error = err,
|
|
||||||
context = context,
|
|
||||||
}
|
|
||||||
|
|
||||||
else
|
else
|
||||||
return {
|
return { success = false, error = "Unknown command: " .. tostring(command) }
|
||||||
success = false,
|
|
||||||
error = "Unknown command: " .. tostring(command),
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Send response back
|
-- Build response
|
||||||
local response = {
|
local response = {
|
||||||
id = requestId,
|
id = cmd.id,
|
||||||
data = result,
|
result = success and result or { success = false, error = tostring(result) },
|
||||||
}
|
}
|
||||||
if not success then
|
if not success then
|
||||||
response.error = tostring(result)
|
response.error = tostring(result)
|
||||||
end
|
end
|
||||||
|
return response
|
||||||
|
end
|
||||||
|
|
||||||
if isConnected and websocket then
|
-- Poll the MCP server for new commands
|
||||||
websocket:Send(game:GetService("HttpService"):JSONEncode(response))
|
local function pollOnce()
|
||||||
|
local ok, data = httpRequest("GET", "/poll?last=" .. tostring(lastCommandId))
|
||||||
|
if not ok then
|
||||||
|
return false, data
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
-- WebSocket message handler
|
return true
|
||||||
local function onMessage(message)
|
end
|
||||||
log("Received message from MCP server", "info")
|
|
||||||
|
|
||||||
local data = game:GetService("HttpService"):JSONDecode(message)
|
-- Main polling loop
|
||||||
handleCommand(data)
|
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
|
end
|
||||||
|
|
||||||
-- Connect to MCP server
|
-- Connect to MCP server
|
||||||
local function connectToServer()
|
local function connect()
|
||||||
if isConnected then
|
if isConnected then return end
|
||||||
return
|
|
||||||
|
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
|
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
|
isConnected = true
|
||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
|
lastCommandId = 0
|
||||||
-- Send connection confirmation
|
log("Connected to MCP server!")
|
||||||
local connectMsg = game:GetService("HttpService"):JSONEncode({
|
startPolling()
|
||||||
type = "connected",
|
return true
|
||||||
pluginVersion = "1.0.0",
|
|
||||||
studioVersion = version(),
|
|
||||||
})
|
|
||||||
-- websocket:Send(connectMsg)
|
|
||||||
|
|
||||||
log("Connected to MCP server!", "success")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Disconnect from server
|
|
||||||
local function disconnectFromServer()
|
|
||||||
if websocket then
|
|
||||||
websocket:Close()
|
|
||||||
websocket = nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Disconnect from MCP server
|
||||||
|
local function disconnect()
|
||||||
isConnected = false
|
isConnected = false
|
||||||
log("Disconnected from MCP server", "info")
|
isPolling = false
|
||||||
|
if pollThread then
|
||||||
|
pollThread = nil
|
||||||
|
end
|
||||||
|
log("Disconnected from MCP server")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Try to reconnect
|
-- Create toolbar button
|
||||||
local function scheduleReconnect()
|
local function createUI()
|
||||||
if reconnectTimer then
|
if not Plugin or not Plugin:FindFirstChildWhichIsA("Toolbar") then
|
||||||
return
|
toolbar = Plugin:CreateToolbar("RobloxMCP")
|
||||||
end
|
end
|
||||||
|
|
||||||
if reconnectAttempts >= CONFIG.MAX_RECONNECT_ATTEMPTS then
|
connectButton = toolbar:CreateButton(
|
||||||
log("Max reconnection attempts reached. Please restart the plugin.", "error")
|
"MCP Connect",
|
||||||
return
|
"Connect/Disconnect from Claude Code MCP server",
|
||||||
end
|
"rbxassetid://16706090882"
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
button.Click:Connect(function()
|
connectButton.Click:Connect(function()
|
||||||
if isConnected then
|
if isConnected then
|
||||||
disconnectFromServer()
|
disconnect()
|
||||||
|
connectButton.Icon = "rbxassetid://16706090882"
|
||||||
else
|
else
|
||||||
connectToServer()
|
local success = connect()
|
||||||
|
if success then
|
||||||
|
connectButton.Icon = "rbxassetid://16706100672"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
-- Create info dialog
|
-- Initialize
|
||||||
local function showInfo()
|
local function init()
|
||||||
local infoGui = Instance.new("ScreenGui")
|
log("Roblox MCP Plugin v2.0 (HTTP Polling) loaded")
|
||||||
infoGui.Name = "RobloxMCPInfo"
|
log("Make sure HttpService is enabled: Game Settings > Security > Allow HTTP Requests")
|
||||||
|
createUI()
|
||||||
|
|
||||||
local frame = Instance.new("Frame")
|
-- Auto-connect attempt
|
||||||
frame.Size = UDim2.new(0, 400, 0, 300)
|
spawn(function()
|
||||||
frame.Position = UDim2.new(0.5, -200, 0.5, -150)
|
wait(2) -- Wait for Studio to fully load
|
||||||
frame.BackgroundColor3 = Color3.new(0.1, 0.1, 0.1)
|
connect()
|
||||||
frame.Parent = infoGui
|
if isConnected and connectButton then
|
||||||
|
connectButton.Icon = "rbxassetid://16706100672"
|
||||||
local title = Instance.new("TextLabel")
|
end
|
||||||
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)
|
end)
|
||||||
|
|
||||||
infoGui.Parent = game:GetService("CoreGui")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Store for later use
|
-- Cleanup on unload
|
||||||
pluginGui = {
|
|
||||||
toolbar = toolbar,
|
|
||||||
button = button,
|
|
||||||
showInfo = showInfo,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Initialize plugin
|
|
||||||
local function initialize()
|
|
||||||
log("Initializing Roblox MCP Plugin v1.0.0", "info")
|
|
||||||
|
|
||||||
createPluginGui()
|
|
||||||
|
|
||||||
-- Auto-connect on startup
|
|
||||||
connectToServer()
|
|
||||||
|
|
||||||
log("Plugin initialized. Click the toolbar button to connect/disconnect.", "info")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Cleanup
|
|
||||||
local function cleanup()
|
|
||||||
disconnectFromServer()
|
|
||||||
|
|
||||||
if reconnectTimer then
|
|
||||||
reconnectTimer:Cancel()
|
|
||||||
reconnectTimer = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Start the plugin
|
|
||||||
initialize()
|
|
||||||
|
|
||||||
-- Handle plugin unload
|
|
||||||
if Plugin then
|
if Plugin then
|
||||||
Plugin.Unloading:Connect(cleanup)
|
Plugin.Unloading:Connect(function()
|
||||||
|
disconnect()
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
init()
|
||||||
connect = connectToServer,
|
|
||||||
disconnect = disconnectFromServer,
|
|
||||||
isConnected = function()
|
|
||||||
return isConnected
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
|
|||||||
239
roblox-plugin/RobloxMCPServer_HTTP.lua
Normal file
239
roblox-plugin/RobloxMCPServer_HTTP.lua
Normal file
@@ -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!")
|
||||||
89
roblox-plugin/TestConnection.lua
Normal file
89
roblox-plugin/TestConnection.lua
Normal file
@@ -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)
|
||||||
126
src/index.js
126
src/index.js
@@ -23,6 +23,11 @@ let studioClients = new Set();
|
|||||||
let pendingRequests = new Map();
|
let pendingRequests = new Map();
|
||||||
let requestIdCounter = 0;
|
let requestIdCounter = 0;
|
||||||
|
|
||||||
|
// HTTP polling support (alternative to WebSocket)
|
||||||
|
let pendingCommands = [];
|
||||||
|
let commandResults = new Map();
|
||||||
|
let commandIdCounter = 0;
|
||||||
|
|
||||||
// Create MCP server
|
// Create MCP server
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{
|
{
|
||||||
@@ -40,33 +45,34 @@ const server = new Server(
|
|||||||
async function sendToStudio(command, params = {}) {
|
async function sendToStudio(command, params = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const requestId = ++requestIdCounter;
|
const requestId = ++requestIdCounter;
|
||||||
|
const commandId = ++commandIdCounter;
|
||||||
|
|
||||||
// Check if any Studio client is connected
|
// Set up response handler (supports both WebSocket and HTTP polling)
|
||||||
if (studioClients.size === 0) {
|
const timeout = setTimeout(() => {
|
||||||
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(() => {
|
|
||||||
pendingRequests.delete(requestId);
|
pendingRequests.delete(requestId);
|
||||||
|
commandResults.delete(commandId);
|
||||||
reject(new Error('Request timeout - Roblox Studio did not respond'));
|
reject(new Error('Request timeout - Roblox Studio did not respond'));
|
||||||
}, 30000) });
|
}, 30000);
|
||||||
|
|
||||||
// Send to all connected Studio clients
|
// Store in both maps for WebSocket and HTTP compatibility
|
||||||
const message = JSON.stringify({ id: requestId, command, params });
|
pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||||
|
commandResults.set(commandId, { resolve, reject, timeout });
|
||||||
|
|
||||||
|
// 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 => {
|
studioClients.forEach(ws => {
|
||||||
if (ws.readyState === ws.OPEN) {
|
if (ws.readyState === ws.OPEN) {
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
console.error(`[MCP] Sent command ${command} (WS ID: ${commandId})`);
|
||||||
console.error(`[MCP] Sent command ${command} (ID: ${requestId})`);
|
} 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'],
|
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;
|
break;
|
||||||
|
|
||||||
|
case 'roblox_import_glb':
|
||||||
|
result = await sendToStudio('importGLB', {
|
||||||
|
glbData: args.glbData,
|
||||||
|
parentPath: args.parentPath || 'Workspace',
|
||||||
|
modelName: args.modelName,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown tool: ${name}`);
|
throw new Error(`Unknown tool: ${name}`);
|
||||||
}
|
}
|
||||||
@@ -498,11 +535,62 @@ wss.on('connection', (ws) => {
|
|||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
studioConnected: studioClients.size > 0,
|
studioConnected: studioClients.size > 0 || pendingCommands.length > 0,
|
||||||
connections: studioClients.size,
|
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
|
// Start Express server
|
||||||
app.listen(HTTP_PORT, () => {
|
app.listen(HTTP_PORT, () => {
|
||||||
console.error(`HTTP server listening on port ${HTTP_PORT}`);
|
console.error(`HTTP server listening on port ${HTTP_PORT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user