Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6b37b5aac |
@@ -14,7 +14,7 @@ The plugin auto-starts the MCP server and connects over WebSocket. No manual con
|
||||
|
||||
## Requirements
|
||||
|
||||
- Godot 4.3+ (4.4+ recommended)
|
||||
- Godot 4.3+ (4.7+ recommended)
|
||||
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
|
||||
<details>
|
||||
<summary>Install uv</summary>
|
||||
|
||||
@@ -24,6 +24,7 @@ const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
|
||||
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
|
||||
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
|
||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
||||
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
||||
|
||||
const SERVER_NAME := "godot-ai"
|
||||
|
||||
@@ -37,8 +38,14 @@ const DEFAULT_WS_PORT := 9500
|
||||
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
|
||||
const MIN_PORT := 1024
|
||||
const MAX_PORT := 65535
|
||||
## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
|
||||
## pathological run of occupied ports can't stall the (cold-path) caller.
|
||||
## 64 localhost binds are sub-millisecond; finding a free port realistically
|
||||
## takes one or two probes, so this only bounds the worst case.
|
||||
const SUGGEST_PORT_MAX_PROBES := 64
|
||||
const SETTING_WS_PORT := "godot_ai/ws_port"
|
||||
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
|
||||
const _DISCOVERY_TIMEOUT_MS := 3000
|
||||
|
||||
|
||||
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
|
||||
@@ -131,15 +138,39 @@ static func excluded_domains() -> String:
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
## Clamp `start` into the legal port range, then walk
|
||||
## `candidate`..`candidate+span-1` and return the first port that is NOT
|
||||
## currently excluded by Windows' winnat reservation table. Falls back to the
|
||||
## clamped candidate if nothing clears (caller can apply anyway — user may
|
||||
## just retry). On non-Windows this is a no-op: all ports pass, returns the
|
||||
## clamped candidate.
|
||||
## Suggest a port the caller can actually switch to. Walks
|
||||
## `candidate`..`candidate+span-1` and returns the first port that is both
|
||||
## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
|
||||
## grab these; bind fails with WinError 10013 and netstat shows nothing) and
|
||||
## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
|
||||
## "free" honest on macOS/Linux, where the reservation table is empty but the
|
||||
## next port up may still be occupied — the same suggestion feeds the dock
|
||||
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
|
||||
## log line. Falls back to the clamped candidate if nothing in the window
|
||||
## clears both checks (caller surfaces it as a best-effort hint; the user can
|
||||
## retry or pick another). Best-effort by nature: a TOCTOU window remains
|
||||
## between the probe and the caller actually binding the port. The bind probe
|
||||
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
|
||||
## stall on a pathological run of occupied ports.
|
||||
static func suggest_free_port(start: int, span: int = 2048) -> int:
|
||||
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
|
||||
return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT)
|
||||
var limit := mini(candidate + span - 1, MAX_PORT)
|
||||
var p := candidate
|
||||
var probes := 0
|
||||
while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
|
||||
## Jump past a whole Windows-reserved range in one step (no-op on
|
||||
## POSIX: returns `p` unchanged), so we don't probe port-by-port
|
||||
## through the large adjacent ranges those services reserve. The
|
||||
## jump itself runs no bind probes, so it doesn't count against the cap.
|
||||
var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
|
||||
if not_reserved < p or not_reserved > limit:
|
||||
break
|
||||
p = not_reserved
|
||||
probes += 1
|
||||
if PortResolver.can_bind_local_port(p):
|
||||
return p
|
||||
p += 1
|
||||
return candidate
|
||||
|
||||
|
||||
# --- Client operations (string id) ---------------------------------------
|
||||
@@ -509,8 +540,8 @@ static func invalidate_uvx_cli_cache() -> void:
|
||||
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
|
||||
## with a mutex so it can race safely against worker threads calling
|
||||
## `find()` from `_run_client_action_worker`. The mutex is held only
|
||||
## across the dictionary clear, never across `OS.execute`, so this call
|
||||
## can never block the main thread on a subprocess.
|
||||
## across the dictionary clear, never across the bounded subprocess lookup,
|
||||
## so this call can never block the main thread on a subprocess.
|
||||
static func invalidate_cli_cache() -> void:
|
||||
CliFinder.invalidate()
|
||||
|
||||
@@ -521,10 +552,9 @@ static var _uv_version_searched: bool = false
|
||||
|
||||
## Cached for the editor session. The dock's `_refresh_setup_status`
|
||||
## (called via `call_deferred` from `_build_ui`) calls this on the
|
||||
## main thread in user mode, so a single cold `OS.execute(uvx,
|
||||
## ["--version"])` adds ~80 ms to the dock's first paint on Linux and
|
||||
## more on Windows. Subsequent calls (focus-in refresh, manual Refresh
|
||||
## clicks) reuse the cached string.
|
||||
## main thread in user mode, so the cold `uvx --version` probe is
|
||||
## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
|
||||
## manual Refresh clicks) reuse the cached string.
|
||||
##
|
||||
## Invalidate via `invalidate_uv_version_cache()` when the user
|
||||
## installs / reinstalls uv via the dock so the next refresh reflects
|
||||
@@ -539,9 +569,10 @@ static func check_uv_version() -> String:
|
||||
_uv_version_searched = true
|
||||
_uv_version_cache = ""
|
||||
return ""
|
||||
var output: Array = []
|
||||
if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0:
|
||||
_uv_version_cache = output[0].strip_edges()
|
||||
var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||
_uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
|
||||
else:
|
||||
_uv_version_cache = ""
|
||||
_uv_version_searched = true
|
||||
@@ -612,9 +643,12 @@ static func find_worktree_src_dir(start_dir: String) -> String:
|
||||
|
||||
static func _find_system_install() -> String:
|
||||
var cmd := "which" if OS.get_name() != "Windows" else "where"
|
||||
var output: Array = []
|
||||
if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0:
|
||||
var found: String = output[0].strip_edges()
|
||||
var result := McpCliExec.run(cmd, ["godot-ai"], _DISCOVERY_TIMEOUT_MS, false)
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||
if lines.is_empty():
|
||||
return ""
|
||||
var found := CliFinder._pick_best_path(lines) if OS.get_name() == "Windows" else lines[0].strip_edges()
|
||||
if not found.is_empty():
|
||||
return found
|
||||
return ""
|
||||
|
||||
@@ -34,6 +34,7 @@ extends RefCounted
|
||||
|
||||
const DEFAULT_TIMEOUT_MS := 8000
|
||||
const _POLL_INTERVAL_MS := 50
|
||||
const _KILL_GRACE_MS := 500
|
||||
|
||||
|
||||
static func run(
|
||||
@@ -44,6 +45,21 @@ static func run(
|
||||
) -> Dictionary:
|
||||
if exe.is_empty():
|
||||
return _spawn_failed_result()
|
||||
if _uses_blocking_legacy_path():
|
||||
## Godot 4.3 keeps the old blocking path because execute_with_pipe
|
||||
## capture/exit semantics differ there. The bounded timeout/kill
|
||||
## behavior is available on Godot 4.4+ only.
|
||||
return _run_blocking_legacy(exe, args)
|
||||
|
||||
return _run_piped(exe, args, timeout_ms, capture_stderr)
|
||||
|
||||
|
||||
static func _run_piped(
|
||||
exe: String,
|
||||
args: Array,
|
||||
timeout_ms: int,
|
||||
capture_stderr: bool,
|
||||
) -> Dictionary:
|
||||
|
||||
var spawn_exe := exe
|
||||
var spawn_args := args
|
||||
@@ -76,12 +92,18 @@ static func run(
|
||||
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
|
||||
while OS.is_process_running(pid):
|
||||
if Time.get_ticks_msec() >= deadline:
|
||||
## Read whatever made it to the pipes before we kill the
|
||||
## process — partial output beats blank "timed out" when the
|
||||
## CLI was emitting useful diagnostics on its way to hanging.
|
||||
var partial_stdout := _drain_pipe(stdio)
|
||||
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
|
||||
## Kill before draining: a pipe read can block while the child is
|
||||
## still alive. Once it exits, drain any buffered partial output.
|
||||
OS.kill(pid)
|
||||
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
|
||||
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
|
||||
OS.delay_msec(_POLL_INTERVAL_MS)
|
||||
|
||||
var partial_stdout := ""
|
||||
var partial_stderr := ""
|
||||
if not OS.is_process_running(pid):
|
||||
partial_stdout = _drain_pipe(stdio)
|
||||
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
|
||||
_close_pipes(stdio, stderr_pipe)
|
||||
return {
|
||||
"exit_code": -1,
|
||||
@@ -107,6 +129,27 @@ static func run(
|
||||
}
|
||||
|
||||
|
||||
static func _run_blocking_legacy(exe: String, args: Array) -> Dictionary:
|
||||
## Godot 4.3's OS.execute_with_pipe has capture/exit-code differences
|
||||
## locked by the 4.3 canary skips in test_cli_exec.gd. Preserve the old
|
||||
## blocking discovery behavior there so startup-critical probes keep the
|
||||
## same semantics that worked before the bounded-pipe path landed.
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(exe, args, output, true)
|
||||
var lines := PackedStringArray()
|
||||
for line in output:
|
||||
lines.append(str(line))
|
||||
var stdout := "\n".join(lines)
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": "",
|
||||
"output": stdout,
|
||||
"timed_out": false,
|
||||
"spawn_failed": exit_code == -1,
|
||||
}
|
||||
|
||||
|
||||
static func _spawn_failed_result() -> Dictionary:
|
||||
return {
|
||||
"exit_code": -1,
|
||||
@@ -119,9 +162,19 @@ static func _spawn_failed_result() -> Dictionary:
|
||||
|
||||
|
||||
static func _drain_pipe(pipe: Variant) -> String:
|
||||
if pipe is FileAccess:
|
||||
return (pipe as FileAccess).get_as_text()
|
||||
if not (pipe is FileAccess):
|
||||
return ""
|
||||
var f := pipe as FileAccess
|
||||
var bytes := PackedByteArray()
|
||||
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
|
||||
while bytes.size() < max_bytes:
|
||||
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
|
||||
if chunk.is_empty():
|
||||
break
|
||||
bytes.append_array(chunk)
|
||||
if f.eof_reached():
|
||||
break
|
||||
return bytes.get_string_from_utf8()
|
||||
|
||||
|
||||
static func _join_streams(stdout: String, stderr_text: String) -> String:
|
||||
@@ -141,3 +194,10 @@ static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
|
||||
(stdio as FileAccess).close()
|
||||
if stderr_pipe is FileAccess:
|
||||
(stderr_pipe as FileAccess).close()
|
||||
|
||||
|
||||
static func _uses_blocking_legacy_path() -> bool:
|
||||
var version := Engine.get_version_info()
|
||||
var major := int(version.get("major", 4))
|
||||
var minor := int(version.get("minor", 0))
|
||||
return major < 4 or (major == 4 and minor < 4)
|
||||
|
||||
@@ -14,7 +14,7 @@ extends RefCounted
|
||||
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
|
||||
## concurrent mutation, so `_cache` / `_searched` access is guarded by
|
||||
## `_mutex`. The mutex is held only across dictionary read/write — the slow
|
||||
## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a
|
||||
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
|
||||
## main-thread `invalidate()` can never block on a worker's subprocess.
|
||||
## Two workers racing the same exe both call `_resolve()` and both write
|
||||
## back the same answer; that's wasted work, not corruption.
|
||||
@@ -24,6 +24,8 @@ static var _mutex: Mutex = Mutex.new()
|
||||
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
|
||||
static var _searched: Dictionary = {}
|
||||
|
||||
const _LOOKUP_TIMEOUT_MS := 3000
|
||||
|
||||
|
||||
## Find any of the supplied exe names; returns the first hit.
|
||||
## On Windows pass the .exe variant in `exe_names` if relevant.
|
||||
@@ -54,8 +56,8 @@ static func _find_one(exe_name: String) -> String:
|
||||
_mutex.unlock()
|
||||
if already_searched:
|
||||
return cached
|
||||
# `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` /
|
||||
# `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
||||
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
# would let a concurrent `invalidate()` on the main thread freeze the
|
||||
# editor for the duration of the subprocess — which defeats the whole
|
||||
# point of running CLI lookup off the main thread.
|
||||
@@ -81,20 +83,19 @@ static func _resolve(exe_name: String) -> String:
|
||||
var shell := OS.get_environment("SHELL")
|
||||
if shell.is_empty():
|
||||
shell = "/bin/bash"
|
||||
var login_output: Array = []
|
||||
var stripped := exe_name.trim_suffix(".exe")
|
||||
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
|
||||
if login_exit == 0 and login_output.size() > 0:
|
||||
var login_found: String = login_output[0].strip_edges()
|
||||
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
|
||||
if int(login_result.get("exit_code", -1)) == 0:
|
||||
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
||||
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
||||
return login_found
|
||||
|
||||
# 3. which / where with inherited PATH
|
||||
var lookup := "where" if is_windows else "which"
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(lookup, [exe_name], output, true)
|
||||
if exit_code == 0 and output.size() > 0:
|
||||
var lines := PackedStringArray(output[0].split("\n"))
|
||||
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var output := str(result.get("stdout", ""))
|
||||
var lines := PackedStringArray(output.split("\n"))
|
||||
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
||||
if not found.is_empty():
|
||||
return found
|
||||
|
||||
@@ -64,6 +64,7 @@ const EVAL_PROBE_INTERVAL_SEC := 0.35
|
||||
|
||||
var _log_buffer: McpLogBuffer
|
||||
var _game_log_buffer: McpGameLogBuffer
|
||||
var _editor_log_buffer: McpEditorLogBuffer
|
||||
|
||||
## Pending request_id -> {connection, timer, timeout_callable}.
|
||||
## We retain the bound timeout lambda so `_clear_pending` can disconnect
|
||||
@@ -80,12 +81,16 @@ var _game_run_token := 0
|
||||
var _ready_run_token := -1
|
||||
var _game_session_id := -1
|
||||
var _game_run_active := false
|
||||
var _game_run_started_msec := 0
|
||||
var _game_run_started_editor_cursor := 0
|
||||
var _game_helper_expected := true
|
||||
signal game_ready
|
||||
|
||||
|
||||
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void:
|
||||
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null) -> void:
|
||||
_log_buffer = log_buffer
|
||||
_game_log_buffer = game_log_buffer
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
|
||||
|
||||
func _has_capture(prefix: String) -> bool:
|
||||
@@ -107,14 +112,23 @@ func _setup_session(session_id: int) -> void:
|
||||
_game_session_id = session_id
|
||||
|
||||
|
||||
func begin_game_run() -> void:
|
||||
func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void:
|
||||
_game_run_token += 1
|
||||
_game_run_active = true
|
||||
_game_ready = false
|
||||
_ready_run_token = -1
|
||||
_game_session_id = -1
|
||||
_game_run_started_msec = Time.get_ticks_msec()
|
||||
_game_run_started_editor_cursor = maxi(0, editor_log_cursor)
|
||||
_game_helper_expected = helper_expected
|
||||
var run_id := ""
|
||||
if _game_log_buffer:
|
||||
run_id = _game_log_buffer.clear_for_new_run()
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] game capture pending run token %d" % _game_run_token)
|
||||
var log_text := "[debug] game capture pending run token %d" % _game_run_token
|
||||
if not run_id.is_empty():
|
||||
log_text += " (run %s)" % run_id
|
||||
_log_buffer.log(log_text)
|
||||
|
||||
|
||||
func end_game_run() -> void:
|
||||
@@ -128,6 +142,158 @@ func is_game_capture_ready() -> bool:
|
||||
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
|
||||
|
||||
|
||||
static func with_liveness_flags(status: Dictionary) -> Dictionary:
|
||||
var enriched := status.duplicate(true)
|
||||
var state := str(enriched.get("status", "stopped"))
|
||||
enriched["helper_live"] = state == "live"
|
||||
enriched["session_active"] = not state in ["not_live", "stopped"]
|
||||
return enriched
|
||||
|
||||
|
||||
func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary:
|
||||
var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec
|
||||
var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0))
|
||||
var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0
|
||||
## "stopped" also covers idle/never-ran; no game run is currently active.
|
||||
var status := "stopped"
|
||||
if _game_run_active:
|
||||
if is_game_capture_ready():
|
||||
status = "live"
|
||||
elif not _game_helper_expected:
|
||||
status = "no_helper"
|
||||
elif elapsed_msec >= ready_wait_msec:
|
||||
status = "not_live"
|
||||
else:
|
||||
status = "launching"
|
||||
return with_liveness_flags({
|
||||
"status": status,
|
||||
"run_token": _game_run_token,
|
||||
"active": _game_run_active,
|
||||
"ready": is_game_capture_ready(),
|
||||
"helper_expected": _game_helper_expected,
|
||||
"run_started_msec": _game_run_started_msec,
|
||||
"elapsed_msec": elapsed_msec,
|
||||
"ready_wait_msec": ready_wait_msec,
|
||||
"editor_log_cursor": _game_run_started_editor_cursor,
|
||||
})
|
||||
|
||||
|
||||
func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary:
|
||||
var state := str(status.get("status", "stopped"))
|
||||
var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||
var recent_errors: Array = errors_info.get("errors", [])
|
||||
var recent_errors_scope := str(errors_info.get("scope", "none"))
|
||||
var truncated := bool(errors_info.get("truncated", false))
|
||||
var data := {
|
||||
"game_status": status.duplicate(true),
|
||||
"recent_errors": recent_errors,
|
||||
"recent_errors_scope": recent_errors_scope,
|
||||
"recent_errors_may_predate_run": recent_errors_scope == "retained_recent",
|
||||
"recent_errors_truncated": truncated,
|
||||
}
|
||||
var message := ""
|
||||
match state:
|
||||
"not_live":
|
||||
if not recent_errors.is_empty() and recent_errors_scope == "run":
|
||||
message = "The game failed to load or crashed before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
if truncated:
|
||||
message += " Editor logs since this run may be truncated; showing retained errors."
|
||||
elif not recent_errors.is_empty():
|
||||
message = "The game is not responding and reported no load errors during this run. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
else:
|
||||
message = "The game is not responding and reported no load errors before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and retry."
|
||||
"no_helper":
|
||||
message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game."
|
||||
"launching":
|
||||
message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0)
|
||||
"stopped":
|
||||
message = "The game is not running. Start the project and retry the game-side tool."
|
||||
_:
|
||||
message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state
|
||||
var err := ErrorCodes.make(code, message)
|
||||
var inner: Dictionary = err.get("error", {})
|
||||
inner["data"] = data
|
||||
err["error"] = inner
|
||||
return err
|
||||
|
||||
|
||||
func recent_editor_errors_since(cursor: int) -> Dictionary:
|
||||
return _recent_editor_errors_since(cursor)
|
||||
|
||||
|
||||
func _recent_editor_errors_since(cursor: int) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var truncated := false
|
||||
if _editor_log_buffer == null:
|
||||
return {"errors": out, "truncated": false, "scope": "none"}
|
||||
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1)
|
||||
truncated = bool(captured.get("truncated", false))
|
||||
for raw_entry in captured.get("entries", []):
|
||||
var compact := _compact_editor_error(raw_entry)
|
||||
if compact.is_empty():
|
||||
continue
|
||||
out.append(compact)
|
||||
if out.size() >= 5:
|
||||
break
|
||||
if not out.is_empty():
|
||||
return {"errors": out, "truncated": truncated, "scope": "run"}
|
||||
|
||||
for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)):
|
||||
var compact := _compact_editor_error(raw_entry, true)
|
||||
if compact.is_empty():
|
||||
continue
|
||||
out.append(compact)
|
||||
if out.size() >= 5:
|
||||
break
|
||||
if not out.is_empty():
|
||||
return {"errors": out, "truncated": false, "scope": "retained_recent"}
|
||||
return {"errors": out, "truncated": false, "scope": "none"}
|
||||
|
||||
|
||||
func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary:
|
||||
if not raw_entry is Dictionary:
|
||||
return {}
|
||||
var entry := raw_entry as Dictionary
|
||||
if str(entry.get("level", "info")) != "error":
|
||||
return {}
|
||||
var path := str(entry.get("path", ""))
|
||||
if fallback_recent and _is_diagnostic_noise_path(path):
|
||||
return {}
|
||||
var compact := {
|
||||
"source": "editor",
|
||||
"level": "error",
|
||||
"text": str(entry.get("text", "")),
|
||||
"path": path,
|
||||
"line": int(entry.get("line", 0)),
|
||||
"function": str(entry.get("function", "")),
|
||||
}
|
||||
if entry.has("details"):
|
||||
compact["details"] = entry["details"].duplicate(true)
|
||||
return compact
|
||||
|
||||
|
||||
func _is_diagnostic_noise_path(path: String) -> bool:
|
||||
return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/")
|
||||
|
||||
|
||||
func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(entries.size() - 1, -1, -1):
|
||||
out.append(entries[i])
|
||||
return out
|
||||
|
||||
|
||||
func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||
var text := str(entry.get("text", "editor error"))
|
||||
var path := str(entry.get("path", ""))
|
||||
var line := int(entry.get("line", 0))
|
||||
if not path.is_empty() and line > 0:
|
||||
return "%s (%s:%d)" % [text, path, line]
|
||||
if not path.is_empty():
|
||||
return "%s (%s)" % [text, path]
|
||||
return text
|
||||
|
||||
|
||||
func _capture(message: String, data: Array, session_id: int) -> bool:
|
||||
## Godot passes the full "prefix:tail" string as `message`.
|
||||
match message:
|
||||
@@ -152,17 +318,14 @@ func _capture(message: String, data: Array, session_id: int) -> bool:
|
||||
## Boot beacon from the game-side autoload. Tells us the
|
||||
## game has registered its "mcp" capture and is safe to send
|
||||
## take_screenshot to — before this, Godot's debugger would
|
||||
## drop our message silently. Also marks a fresh play
|
||||
## cycle: rotate the game-log buffer so each run starts
|
||||
## clean and gets a new run_id.
|
||||
## drop our message silently.
|
||||
_game_ready = true
|
||||
_ready_run_token = _game_run_token
|
||||
game_ready.emit()
|
||||
if _game_log_buffer:
|
||||
var run_id := _game_log_buffer.clear_for_new_run()
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id)
|
||||
elif _log_buffer:
|
||||
if _game_log_buffer:
|
||||
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id())
|
||||
else:
|
||||
_log_buffer.log("[debug] <- mcp:hello from game_helper")
|
||||
return true
|
||||
"mcp:eval_response":
|
||||
@@ -262,8 +425,8 @@ func _wait_then_send(
|
||||
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
||||
await tree.process_frame
|
||||
if not is_game_capture_ready():
|
||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
||||
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC))
|
||||
_send_error_response(connection, request_id,
|
||||
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
|
||||
return
|
||||
_send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
|
||||
|
||||
@@ -357,16 +520,23 @@ func _on_timeout(request_id: String) -> void:
|
||||
var connection: McpConnection = pending.connection
|
||||
if connection == null or not is_instance_valid(connection):
|
||||
return
|
||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
||||
"Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.")
|
||||
var status := get_game_status(-1, GAME_READY_WAIT_SEC)
|
||||
var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||
"Game screenshot timed out after reaching the game helper. The game may be busy or unable to render a frame. Check logs_read(source='game') and retry.")
|
||||
if status.get("status", "") != "live":
|
||||
err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR)
|
||||
_send_error_response(connection, request_id, err)
|
||||
if _log_buffer:
|
||||
_log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id)
|
||||
|
||||
|
||||
func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void:
|
||||
_send_error_response(connection, request_id, ErrorCodes.make(code, message))
|
||||
|
||||
|
||||
func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void:
|
||||
if connection == null or not is_instance_valid(connection):
|
||||
return
|
||||
var err := ErrorCodes.make(code, message)
|
||||
connection.send_deferred_response(request_id, err)
|
||||
|
||||
|
||||
@@ -447,8 +617,8 @@ func _wait_then_eval(
|
||||
## but the game-side capture didn't register within the short wait. Fast
|
||||
## and caller-actionable; classifying it apart from the opaque 10s hang
|
||||
## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
|
||||
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
|
||||
"Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC))
|
||||
_send_error_response(connection, request_id,
|
||||
_explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY))
|
||||
return
|
||||
_send_eval(tree, code, request_id, connection, timeout_sec)
|
||||
|
||||
@@ -704,8 +874,8 @@ func _wait_then_game_command(
|
||||
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
||||
await tree.process_frame
|
||||
if not is_game_capture_ready():
|
||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
||||
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC))
|
||||
_send_error_response(connection, request_id,
|
||||
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
|
||||
return
|
||||
_send_game_command(tree, op, params, request_id, connection, timeout_sec)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
|
||||
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
|
||||
"create_script": 4500,
|
||||
"stop_project": 4500,
|
||||
"run_project": 6000,
|
||||
"take_screenshot": 30000,
|
||||
"game_eval": 15000,
|
||||
"game_command": 15000,
|
||||
|
||||
@@ -28,6 +28,7 @@ func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_
|
||||
|
||||
func get_editor_state(_params: Dictionary) -> Dictionary:
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var game_status := _current_game_status()
|
||||
var data := {
|
||||
"godot_version": Engine.get_version_info().get("string", "unknown"),
|
||||
"project_name": ProjectSettings.get_setting("application/config/name", ""),
|
||||
@@ -38,6 +39,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
|
||||
## false between Play→Stop cycles. Lets capture-source=game callers
|
||||
## poll for a real ready signal instead of guessing with sleep().
|
||||
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
|
||||
"game_status": game_status,
|
||||
"helper_live": bool(game_status.get("helper_live", false)),
|
||||
"session_active": bool(game_status.get("session_active", false)),
|
||||
}
|
||||
## Half-installed addon tree from a failed self-update rollback. When
|
||||
## non-empty, the agent / dock paint the operator-facing recovery copy
|
||||
@@ -72,6 +76,7 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
var include_details: bool = bool(params.get("include_details", false))
|
||||
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
|
||||
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
|
||||
var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
|
||||
if not source in VALID_LOG_SOURCES:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||
@@ -82,7 +87,7 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
"plugin":
|
||||
return _get_plugin_logs(count, offset)
|
||||
"game":
|
||||
return _get_game_logs(count, offset, include_details)
|
||||
return _get_game_logs(count, offset, include_details, since_run_id)
|
||||
"editor":
|
||||
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
|
||||
"all":
|
||||
@@ -90,6 +95,17 @@ func get_logs(params: Dictionary) -> Dictionary:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
|
||||
|
||||
|
||||
func _current_game_status() -> Dictionary:
|
||||
if _debugger_plugin == null:
|
||||
return McpDebuggerPlugin.with_liveness_flags({
|
||||
"status": "stopped",
|
||||
"active": false,
|
||||
"ready": false,
|
||||
"helper_expected": true,
|
||||
})
|
||||
return _debugger_plugin.get_game_status()
|
||||
|
||||
|
||||
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
|
||||
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
|
||||
var page: Array[Dictionary] = []
|
||||
@@ -107,7 +123,10 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
|
||||
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
|
||||
var game_status := _current_game_status()
|
||||
var helper_live := bool(game_status.get("helper_live", false))
|
||||
var session_active := bool(game_status.get("session_active", false))
|
||||
if _game_log_buffer == null:
|
||||
return {
|
||||
"data": {
|
||||
@@ -117,21 +136,35 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
|
||||
"returned_count": 0,
|
||||
"offset": offset,
|
||||
"run_id": "",
|
||||
"is_running": false,
|
||||
"current_run_id": "",
|
||||
"is_running": session_active,
|
||||
"helper_live": helper_live,
|
||||
"session_active": session_active,
|
||||
"game_status": game_status,
|
||||
"dropped_count": 0,
|
||||
"stale_run_id": false,
|
||||
}
|
||||
}
|
||||
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
|
||||
var current_run_id := _game_log_buffer.run_id()
|
||||
var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
|
||||
var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
|
||||
var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
|
||||
var page := _entries_for_response(run_page.get("entries", []), include_details)
|
||||
return {
|
||||
"data": {
|
||||
"source": "game",
|
||||
"lines": page,
|
||||
"total_count": _game_log_buffer.total_count(),
|
||||
"total_count": int(run_page.get("total_count", 0)),
|
||||
"returned_count": page.size(),
|
||||
"offset": offset,
|
||||
"run_id": _game_log_buffer.run_id(),
|
||||
"is_running": EditorInterface.is_playing_scene(),
|
||||
"run_id": target_run_id,
|
||||
"current_run_id": current_run_id,
|
||||
"is_running": session_active,
|
||||
"helper_live": helper_live,
|
||||
"session_active": session_active,
|
||||
"game_status": game_status,
|
||||
"dropped_count": _game_log_buffer.dropped_count(),
|
||||
"stale_run_id": stale_run_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,21 +244,24 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
|
||||
combined.append({"source": "plugin", "level": "info", "text": line})
|
||||
for entry in _collect_editor_log_entries():
|
||||
combined.append(entry)
|
||||
var run_id := ""
|
||||
var current_run_id := ""
|
||||
var dropped := 0
|
||||
if _game_log_buffer != null:
|
||||
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
|
||||
run_id = _game_log_buffer.run_id()
|
||||
current_run_id = run_id
|
||||
dropped = _game_log_buffer.dropped_count()
|
||||
var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
|
||||
for entry in run_page.get("entries", []):
|
||||
combined.append(entry)
|
||||
var stop := mini(combined.size(), offset + count)
|
||||
var page: Array[Dictionary] = []
|
||||
for i in range(mini(offset, combined.size()), stop):
|
||||
page.append(combined[i])
|
||||
page = _entries_for_response(page, include_details)
|
||||
var run_id := ""
|
||||
var dropped := 0
|
||||
if _game_log_buffer != null:
|
||||
run_id = _game_log_buffer.run_id()
|
||||
dropped = _game_log_buffer.dropped_count()
|
||||
if _editor_log_buffer != null:
|
||||
dropped += _editor_log_buffer.dropped_count()
|
||||
var game_status := _current_game_status()
|
||||
return {
|
||||
"data": {
|
||||
"source": "all",
|
||||
@@ -234,7 +270,11 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
|
||||
"returned_count": page.size(),
|
||||
"offset": offset,
|
||||
"run_id": run_id,
|
||||
"is_running": EditorInterface.is_playing_scene(),
|
||||
"current_run_id": current_run_id,
|
||||
"is_running": bool(game_status.get("session_active", false)),
|
||||
"helper_live": bool(game_status.get("helper_live", false)),
|
||||
"session_active": bool(game_status.get("session_active", false)),
|
||||
"game_status": game_status,
|
||||
"dropped_count": dropped,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,16 +239,10 @@ func set_property(params: Dictionary) -> Dictionary:
|
||||
# properties. Mirrors resource_create's inline-assign path but
|
||||
# avoids a separate tool call for the common case.
|
||||
var type_str: String = value.get("__class__", "")
|
||||
var class_err := ResourceHandler._validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var instance := ClassDB.instantiate(type_str)
|
||||
if instance == null or not (instance is Resource):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to instantiate %s as a Resource" % type_str
|
||||
)
|
||||
var res: Resource = instance
|
||||
var made := ResourceHandler._instantiate_resource(type_str)
|
||||
if made is Dictionary:
|
||||
return made
|
||||
var res: Resource = made
|
||||
var remaining: Dictionary = (value as Dictionary).duplicate()
|
||||
remaining.erase("__class__")
|
||||
if not remaining.is_empty():
|
||||
@@ -528,6 +522,7 @@ func _set_owner_recursive(node: Node, owner: Node) -> void:
|
||||
## is optional — the coercer defaults it to 1.0 when absent.
|
||||
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
||||
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
||||
const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
|
||||
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
||||
|
||||
|
||||
@@ -556,6 +551,8 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
|
||||
ok = value is PackedVector2Array
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
ok = value is PackedVector3Array
|
||||
TYPE_PACKED_VECTOR4_ARRAY:
|
||||
ok = value is PackedVector4Array
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
ok = value is PackedColorArray
|
||||
TYPE_PACKED_INT32_ARRAY:
|
||||
@@ -568,8 +565,31 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
|
||||
ok = value is PackedFloat64Array
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
ok = value is PackedStringArray
|
||||
TYPE_VECTOR2I: ok = value is Vector2i
|
||||
TYPE_VECTOR3I: ok = value is Vector3i
|
||||
TYPE_VECTOR4: ok = value is Vector4
|
||||
TYPE_VECTOR4I: ok = value is Vector4i
|
||||
TYPE_QUATERNION: ok = value is Quaternion
|
||||
TYPE_RECT2: ok = value is Rect2
|
||||
TYPE_RECT2I: ok = value is Rect2i
|
||||
TYPE_AABB: ok = value is AABB
|
||||
TYPE_PLANE: ok = value is Plane
|
||||
TYPE_BASIS: ok = value is Basis
|
||||
TYPE_TRANSFORM2D: ok = value is Transform2D
|
||||
TYPE_TRANSFORM3D: ok = value is Transform3D
|
||||
TYPE_PROJECTION: ok = value is Projection
|
||||
_:
|
||||
# null / untyped-TYPE_NIL / already-correct-type are handled by
|
||||
# Godot's setter; anything else would silently no-op, so error.
|
||||
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
|
||||
return null
|
||||
var unsupported := ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
|
||||
type_string(typeof(value)), type_string(target_type),
|
||||
],
|
||||
)
|
||||
return ErrorCodes.prefix_message(unsupported, prefix)
|
||||
if ok:
|
||||
return null
|
||||
var dict_err := _check_dict_coerce_failed(value, target_type)
|
||||
@@ -597,6 +617,8 @@ static func _shape_hint(target_type: int) -> String:
|
||||
return "[{\"x\":0,\"y\":0}, ...]"
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
|
||||
TYPE_PACKED_VECTOR4_ARRAY:
|
||||
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
|
||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
||||
@@ -605,6 +627,24 @@ static func _shape_hint(target_type: int) -> String:
|
||||
return "[float, ...]"
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
return "[\"...\", ...]"
|
||||
TYPE_VECTOR2I:
|
||||
return "{\"x\":0,\"y\":0}"
|
||||
TYPE_VECTOR3I:
|
||||
return "{\"x\":0,\"y\":0,\"z\":0}"
|
||||
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
|
||||
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
|
||||
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
|
||||
return "{\"position\":{...},\"size\":{...}}"
|
||||
TYPE_PLANE:
|
||||
return "{\"normal\":{...},\"d\":0}"
|
||||
TYPE_BASIS:
|
||||
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
|
||||
TYPE_TRANSFORM2D:
|
||||
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
|
||||
TYPE_TRANSFORM3D:
|
||||
return "{\"basis\":{...},\"origin\":{...}}"
|
||||
TYPE_PROJECTION:
|
||||
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
|
||||
var keys: Array[String] = []
|
||||
match target_type:
|
||||
TYPE_VECTOR2: keys = VECTOR2_KEYS
|
||||
@@ -717,6 +757,17 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_VECTOR4_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector4Array()
|
||||
for item in value:
|
||||
if item is Vector4:
|
||||
out.append(item)
|
||||
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
|
||||
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedColorArray()
|
||||
@@ -757,6 +808,72 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
||||
else:
|
||||
return value
|
||||
return out
|
||||
TYPE_VECTOR2I:
|
||||
if value is Dictionary and value.has_all(VECTOR2_KEYS):
|
||||
return Vector2i(int(value["x"]), int(value["y"]))
|
||||
TYPE_VECTOR3I:
|
||||
if value is Dictionary and value.has_all(VECTOR3_KEYS):
|
||||
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
|
||||
TYPE_VECTOR4:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
return Vector4(value["x"], value["y"], value["z"], value["w"])
|
||||
TYPE_VECTOR4I:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
|
||||
TYPE_QUATERNION:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
return Quaternion(value["x"], value["y"], value["z"], value["w"])
|
||||
TYPE_RECT2:
|
||||
if value is Dictionary and value.has("position") and value.has("size"):
|
||||
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
|
||||
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
|
||||
if p is Vector2 and s is Vector2:
|
||||
return Rect2(p, s)
|
||||
TYPE_RECT2I:
|
||||
if value is Dictionary and value.has("position") and value.has("size"):
|
||||
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
|
||||
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
|
||||
if p is Vector2i and s is Vector2i:
|
||||
return Rect2i(p, s)
|
||||
TYPE_AABB:
|
||||
if value is Dictionary and value.has("position") and value.has("size"):
|
||||
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
|
||||
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
|
||||
if p is Vector3 and s is Vector3:
|
||||
return AABB(p, s)
|
||||
TYPE_PLANE:
|
||||
if value is Dictionary and value.has("normal") and value.has("d"):
|
||||
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
|
||||
if n is Vector3:
|
||||
return Plane(n, float(value["d"]))
|
||||
TYPE_BASIS:
|
||||
if value is Dictionary and value.has_all(["x", "y", "z"]):
|
||||
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
|
||||
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
|
||||
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
|
||||
if bx is Vector3 and by is Vector3 and bz is Vector3:
|
||||
return Basis(bx, by, bz)
|
||||
TYPE_TRANSFORM2D:
|
||||
if value is Dictionary and value.has_all(["x", "y", "origin"]):
|
||||
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
|
||||
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
|
||||
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
|
||||
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
|
||||
return Transform2D(tx, ty, to_)
|
||||
TYPE_TRANSFORM3D:
|
||||
if value is Dictionary and value.has("basis") and value.has("origin"):
|
||||
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
|
||||
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
|
||||
if b is Basis and o is Vector3:
|
||||
return Transform3D(b, o)
|
||||
TYPE_PROJECTION:
|
||||
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
|
||||
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
|
||||
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
|
||||
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
|
||||
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
|
||||
return Projection(px, py, pz, pw)
|
||||
# PackedByteArray intentionally unhandled — needs design decision
|
||||
# (base64 string vs. raw int list); JSON has no native byte type.
|
||||
return value
|
||||
|
||||
@@ -6,14 +6,17 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
||||
## Handles project settings and filesystem search commands.
|
||||
|
||||
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
|
||||
const RUN_READY_WAIT_SEC := 3.0
|
||||
|
||||
var _connection: McpConnection
|
||||
var _debugger_plugin
|
||||
var _editor_log_buffer
|
||||
|
||||
|
||||
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
|
||||
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
|
||||
_connection = connection
|
||||
_debugger_plugin = debugger_plugin
|
||||
_editor_log_buffer = editor_log_buffer
|
||||
|
||||
|
||||
func get_project_setting(params: Dictionary) -> Dictionary:
|
||||
@@ -81,16 +84,15 @@ func run_project(params: Dictionary) -> Dictionary:
|
||||
# stop-not-running case in telemetry). Surface state via was_already_running
|
||||
# so a caller wanting a *different* scene can detect and stop+restart.
|
||||
if EditorInterface.is_playing_scene():
|
||||
return {
|
||||
"data": {
|
||||
"mode": mode,
|
||||
"scene": params.get("scene", ""),
|
||||
"autosave": autosave,
|
||||
"was_already_running": true,
|
||||
"undoable": false,
|
||||
"reason": "Project was already running; no action taken",
|
||||
}
|
||||
}
|
||||
return _run_project_current_liveness_response(
|
||||
_run_project_base_data(
|
||||
mode,
|
||||
str(params.get("scene", "")),
|
||||
autosave,
|
||||
true,
|
||||
"Project was already running; no action taken"
|
||||
)
|
||||
)
|
||||
|
||||
var validation_error: Variant = null
|
||||
if mode == "custom":
|
||||
@@ -125,7 +127,7 @@ func run_project(params: Dictionary) -> Dictionary:
|
||||
restore_setting = true
|
||||
|
||||
if _debugger_plugin != null:
|
||||
_debugger_plugin.begin_game_run()
|
||||
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
|
||||
|
||||
match mode:
|
||||
"main":
|
||||
@@ -142,16 +144,171 @@ func run_project(params: Dictionary) -> Dictionary:
|
||||
if _connection:
|
||||
_connection.pause_processing = false
|
||||
|
||||
var base_data := _run_project_base_data(
|
||||
mode,
|
||||
str(params.get("scene", "")),
|
||||
autosave,
|
||||
false,
|
||||
"Play/stop is a runtime action"
|
||||
)
|
||||
var request_id: String = params.get("_request_id", "")
|
||||
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
|
||||
_finish_run_project_deferred(request_id, base_data)
|
||||
return McpDispatcher.DEFERRED_RESPONSE
|
||||
|
||||
return _run_project_current_liveness_response(base_data)
|
||||
|
||||
|
||||
func _editor_log_cursor() -> int:
|
||||
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
|
||||
|
||||
|
||||
func _game_helper_autoload_expected() -> bool:
|
||||
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
|
||||
|
||||
|
||||
func _run_project_base_data(
|
||||
mode: String,
|
||||
scene: String,
|
||||
autosave: bool,
|
||||
was_already_running: bool,
|
||||
reason: String
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"data": {
|
||||
"mode": mode,
|
||||
"scene": params.get("scene", ""),
|
||||
"scene": scene,
|
||||
"autosave": autosave,
|
||||
"was_already_running": false,
|
||||
"was_already_running": was_already_running,
|
||||
"undoable": false,
|
||||
"reason": "Play/stop is a runtime action",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
|
||||
if _debugger_plugin == null:
|
||||
return {"data": base_data}
|
||||
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
|
||||
|
||||
|
||||
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
|
||||
var tree := _connection.get_tree()
|
||||
while true:
|
||||
await tree.process_frame
|
||||
if not is_instance_valid(_connection):
|
||||
return
|
||||
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
if (
|
||||
not EditorInterface.is_playing_scene()
|
||||
and int(pre_status.get("elapsed_msec", 0)) > 100
|
||||
and str(pre_status.get("status", "stopped")) == "launching"
|
||||
):
|
||||
_debugger_plugin.end_game_run()
|
||||
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||
var decision := _run_project_liveness_decision(status, errors_info)
|
||||
if not bool(decision.get("resolve", false)):
|
||||
continue
|
||||
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
|
||||
return
|
||||
|
||||
|
||||
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
|
||||
var data := base_data.duplicate(true)
|
||||
var game_status: Dictionary = decision.get("game_status", {})
|
||||
data["game_status"] = game_status
|
||||
data["helper_live"] = bool(game_status.get("helper_live", false))
|
||||
data["session_active"] = bool(game_status.get("session_active", false))
|
||||
if bool(data.get("was_already_running", false)):
|
||||
data["reason"] = _run_project_already_running_message(decision)
|
||||
else:
|
||||
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
|
||||
data["recent_errors"] = decision.get("recent_errors", [])
|
||||
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
|
||||
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
|
||||
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
func _run_project_already_running_message(decision: Dictionary) -> String:
|
||||
var state := str(decision.get("liveness_status", "unknown"))
|
||||
match state:
|
||||
"live":
|
||||
return "Project was already running; the Godot AI game helper is live."
|
||||
"not_live":
|
||||
var errors: Array = decision.get("recent_errors", [])
|
||||
var scope := str(decision.get("recent_errors_scope", "none"))
|
||||
if not errors.is_empty() and scope == "run":
|
||||
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||
if not errors.is_empty():
|
||||
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||
"no_helper":
|
||||
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
|
||||
"launching":
|
||||
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
|
||||
"stopped":
|
||||
return "Project was already marked playing by the editor, but no active game liveness run exists."
|
||||
_:
|
||||
return "Project was already running; current liveness status is %s." % state
|
||||
|
||||
|
||||
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
|
||||
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
|
||||
var state := str(status.get("status", "stopped"))
|
||||
var recent_errors: Array = errors_info.get("errors", [])
|
||||
var errors_scope := str(errors_info.get("scope", "none"))
|
||||
var truncated := bool(errors_info.get("truncated", false))
|
||||
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
|
||||
var elapsed_msec := int(status.get("elapsed_msec", 0))
|
||||
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
|
||||
var decision := {
|
||||
"resolve": false,
|
||||
"game_status": enriched_status,
|
||||
"liveness_status": state,
|
||||
"recent_errors": recent_errors,
|
||||
"recent_errors_scope": errors_scope,
|
||||
"recent_errors_may_predate_run": errors_scope == "retained_recent",
|
||||
"recent_errors_truncated": truncated,
|
||||
"message": "",
|
||||
}
|
||||
if state == "live":
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game launched and the Godot AI game helper is live."
|
||||
elif correlated_error:
|
||||
decision["resolve"] = true
|
||||
decision["liveness_status"] = "not_live"
|
||||
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
if truncated:
|
||||
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
||||
elif state == "not_live":
|
||||
decision["resolve"] = true
|
||||
if not recent_errors.is_empty():
|
||||
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||
else:
|
||||
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||
elif state == "no_helper":
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
|
||||
elif state == "stopped":
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
|
||||
elif state == "launching" and elapsed_msec >= ready_wait_msec:
|
||||
decision["resolve"] = true
|
||||
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
|
||||
return decision
|
||||
|
||||
|
||||
func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||
var text := str(entry.get("text", "editor error"))
|
||||
var path := str(entry.get("path", ""))
|
||||
var line := int(entry.get("line", 0))
|
||||
if not path.is_empty() and line > 0:
|
||||
return "%s (%s:%d)" % [text, path, line]
|
||||
if not path.is_empty():
|
||||
return "%s (%s)" % [text, path]
|
||||
return text
|
||||
|
||||
|
||||
func stop_project(params: Dictionary) -> Dictionary:
|
||||
|
||||
@@ -178,19 +178,10 @@ func create_resource(params: Dictionary) -> Dictionary:
|
||||
return home_err
|
||||
var has_file_target := not resource_path.is_empty()
|
||||
|
||||
var class_err := _validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
|
||||
var instance := ClassDB.instantiate(type_str)
|
||||
if instance == null:
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
|
||||
if not (instance is Resource):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
|
||||
)
|
||||
var res: Resource = instance
|
||||
var made := _instantiate_resource(type_str)
|
||||
if made is Dictionary:
|
||||
return made
|
||||
var res: Resource = made
|
||||
|
||||
if not properties.is_empty():
|
||||
var apply_err := _apply_resource_properties(res, properties)
|
||||
@@ -226,6 +217,56 @@ static func _validate_resource_class(type_str: String) -> Variant:
|
||||
return null
|
||||
|
||||
|
||||
## Resolve a resource type name to a fresh instance. Handles engine built-ins
|
||||
## (ClassDB) and project `class_name` Resources (the global script-class
|
||||
## registry). Returns a Resource on success, or an error dict on failure.
|
||||
static func _instantiate_resource(type_str: String) -> Variant:
|
||||
if ClassDB.class_exists(type_str):
|
||||
var class_err: Variant = _validate_resource_class(type_str)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var built_in := ClassDB.instantiate(type_str)
|
||||
if built_in == null or not (built_in is Resource):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||
return built_in
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") == type_str:
|
||||
var script_path: String = entry.get("path", "")
|
||||
var scr: Variant = load(script_path)
|
||||
# Reject non-Resource script classes BEFORE constructing them:
|
||||
# scr.new() runs _init(), and an @tool class_name extending a
|
||||
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
|
||||
# an orphan instance this path never frees. get_instance_base_type()
|
||||
# resolves to the native base, so multi-level custom Resource
|
||||
# hierarchies (B extends A extends Resource) still pass.
|
||||
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||
if base_or_err is Dictionary:
|
||||
return base_or_err
|
||||
var base_type: StringName = base_or_err
|
||||
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||
if not scr.can_instantiate():
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
|
||||
# Reject scripts whose _init() requires arguments BEFORE scr.new():
|
||||
# scr.new() passes no args, so a required-arg _init raises and aborts
|
||||
# this handler mid-call, null-cascading into a generic "malformed
|
||||
# result" error instead of a clean rejection. get_script_method_list()
|
||||
# reports the effective (incl. inherited) _init; required args =
|
||||
# args - default_args. Statically detectable only — a _init that runs
|
||||
# but throws still falls through to scr.new() and the dispatcher catch.
|
||||
for method in scr.get_script_method_list():
|
||||
if method.get("name", "") == "_init":
|
||||
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
|
||||
if required_args > 0:
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
|
||||
break
|
||||
var made: Variant = scr.new()
|
||||
if made == null or not (made is Resource):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||
return made
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
|
||||
|
||||
|
||||
## Apply a dict of property values to a freshly-instantiated Resource,
|
||||
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
|
||||
## Returns null on success or an error dict on failure.
|
||||
@@ -240,9 +281,17 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
|
||||
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
|
||||
valid.append(prop.name)
|
||||
valid.sort()
|
||||
# Name the script's class_name (e.g. MyTestResource) rather than the
|
||||
# native base (Resource) so the hint names the type the agent created,
|
||||
# and point at the real MCP verb — resource_manage(op="get_info") now
|
||||
# answers for project class_name Resources too.
|
||||
var type_label := res.get_class()
|
||||
var res_script: Variant = res.get_script()
|
||||
if res_script is Script and not String(res_script.get_global_name()).is_empty():
|
||||
type_label = String(res_script.get_global_name())
|
||||
var err := ErrorCodes.make(
|
||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
|
||||
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
|
||||
)
|
||||
err["error"]["data"] = {"valid_properties": valid}
|
||||
return err
|
||||
@@ -270,16 +319,15 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
|
||||
# resource_create/environment_create callers can populate
|
||||
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
|
||||
var sub_type: String = v.get("__class__", "")
|
||||
var class_err := _validate_resource_class(sub_type)
|
||||
if class_err != null:
|
||||
return class_err
|
||||
var sub_instance := ClassDB.instantiate(sub_type)
|
||||
if sub_instance == null or not (sub_instance is Resource):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
|
||||
)
|
||||
var sub_res: Resource = sub_instance
|
||||
# Resolve via the shared helper so the nested shortcut accepts both
|
||||
# engine built-ins (ClassDB) and project `class_name` Resources,
|
||||
# exactly like the top-level resource_create path.
|
||||
var sub_made := _instantiate_resource(sub_type)
|
||||
if sub_made is Dictionary:
|
||||
# Preserve the property-slot context the inline path used to add.
|
||||
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
|
||||
return sub_made
|
||||
var sub_res: Resource = sub_made
|
||||
var remaining: Dictionary = (v as Dictionary).duplicate()
|
||||
remaining.erase("__class__")
|
||||
if not remaining.is_empty():
|
||||
@@ -361,6 +409,12 @@ func get_resource_info(params: Dictionary) -> Dictionary:
|
||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
|
||||
|
||||
if not ClassDB.class_exists(type_str):
|
||||
# Project class_name Resources aren't in ClassDB; resolve them through the
|
||||
# global script-class registry so get_info answers for the same custom
|
||||
# types resource_create can make. Read-only — never instantiates.
|
||||
var custom_info: Variant = _custom_resource_info(type_str)
|
||||
if custom_info != null:
|
||||
return custom_info
|
||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
|
||||
if ClassDB.is_parent_class(type_str, "Node"):
|
||||
return ErrorCodes.make(
|
||||
@@ -396,3 +450,82 @@ func get_resource_info(params: Dictionary) -> Dictionary:
|
||||
data["concrete_subclasses"] = class_info.concrete_inheritors
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
## Resolve a loaded global-class script to its native base type, or an error if
|
||||
## the script failed to load (not a Script) or to compile (empty base type).
|
||||
## Shared by the create and get_info custom-Resource paths so both report a
|
||||
## compile failure rather than a misleading "is not a Resource type (extends )".
|
||||
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
|
||||
if not (scr is Script):
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
|
||||
var base_type: StringName = scr.get_instance_base_type()
|
||||
if String(base_type).is_empty():
|
||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
|
||||
return base_type
|
||||
|
||||
|
||||
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
|
||||
## dict, an error dict (for a class_name whose native base is not a Resource), or
|
||||
## null if `type_str` is not a registered global class. Read-only: resolves
|
||||
## properties from the script + its native base WITHOUT instantiating (no _init()).
|
||||
static func _custom_resource_info(type_str: String) -> Variant:
|
||||
for entry in ProjectSettings.get_global_class_list():
|
||||
if entry.get("class", "") != type_str:
|
||||
continue
|
||||
var script_path: String = entry.get("path", "")
|
||||
var scr: Variant = load(script_path)
|
||||
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||
if base_or_err is Dictionary:
|
||||
return base_or_err
|
||||
var base_type: StringName = base_or_err
|
||||
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||
var can_instantiate: bool = scr.can_instantiate()
|
||||
# Inherited (native) properties come from the engine base via ClassDB...
|
||||
var class_info := ClassIntrospection.build(String(base_type), {
|
||||
"sections": ["properties"],
|
||||
"include_inherited": true,
|
||||
"limit": 0,
|
||||
})
|
||||
var props: Array = []
|
||||
for native_prop in class_info.properties:
|
||||
props.append(native_prop)
|
||||
# ...and the script's own (and inherited script) exported properties come
|
||||
# from the Script itself, so we never construct the resource. A real
|
||||
# default isn't available without instantiating, so script props carry an
|
||||
# explicit null — keeping one uniform key set across the array (native
|
||||
# props carry their real default).
|
||||
for raw_prop in scr.get_script_property_list():
|
||||
var prop: Dictionary = raw_prop
|
||||
var usage := int(prop.get("usage", 0))
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
props.append({
|
||||
"name": str(prop.get("name", "")),
|
||||
"type": type_string(int(prop.get("type", TYPE_NIL))),
|
||||
"class_name": str(prop.get("class_name", "")),
|
||||
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
|
||||
"hint_string": str(prop.get("hint_string", "")),
|
||||
"usage": usage,
|
||||
"default": null,
|
||||
})
|
||||
props.sort_custom(func(a, b): return a.name < b.name)
|
||||
# parent_class is the immediate script parent when there is one (so a
|
||||
# multi-level chain B -> A -> Resource reports A), else the native base.
|
||||
var parent_name := String(base_type)
|
||||
var base_script: Variant = scr.get_base_script()
|
||||
if base_script is Script and not String(base_script.get_global_name()).is_empty():
|
||||
parent_name = String(base_script.get_global_name())
|
||||
return {"data": {
|
||||
"type": type_str,
|
||||
"parent_class": parent_name,
|
||||
"can_instantiate": can_instantiate,
|
||||
# is_abstract reflects real abstractness (the @abstract annotation),
|
||||
# NOT editor-instantiability — a non-@tool concrete Resource has
|
||||
# can_instantiate()==false in-editor but is not abstract.
|
||||
"is_abstract": scr.is_abstract(),
|
||||
"properties": props,
|
||||
"property_count": props.size(),
|
||||
}}
|
||||
return null
|
||||
|
||||
@@ -152,7 +152,7 @@ func connect_signal(params: Dictionary) -> Dictionary:
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
|
||||
|
||||
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
|
||||
_undo_redo.add_do_method(source, "connect", signal_name, callable)
|
||||
_undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
|
||||
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
@@ -174,9 +174,19 @@ func disconnect_signal(params: Dictionary) -> Dictionary:
|
||||
if not source.is_connected(signal_name, callable):
|
||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
|
||||
|
||||
# Capture the connection's current flags so undo restores it exactly as it
|
||||
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
|
||||
# silently promote a runtime-only connection into one that serializes on the
|
||||
# next save. (The connection still exists at this point — checked above.)
|
||||
var reconnect_flags := 0
|
||||
for conn in source.get_signal_connection_list(signal_name):
|
||||
if conn.get("callable", Callable()) == callable:
|
||||
reconnect_flags = int(conn.get("flags", 0))
|
||||
break
|
||||
|
||||
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
|
||||
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
|
||||
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
|
||||
_undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
|
||||
_undo_redo.commit_action()
|
||||
|
||||
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
|
||||
|
||||
+280
-45
@@ -40,8 +40,17 @@ const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.g
|
||||
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
|
||||
|
||||
const DEV_MODE_SETTING := "godot_ai/dev_mode"
|
||||
## "Change the port + reconfigure your clients" guide. Surfaced from the crash
|
||||
## panel when a foreign process holds the HTTP port — the one piece of recovery
|
||||
## (per-client config rewrite) that doesn't fit in the inline crash body.
|
||||
## Resolved against the installed plugin version at click time (see
|
||||
## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
|
||||
## not tip-of-main, which may have drifted from that build's UI.
|
||||
const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
|
||||
const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
|
||||
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
|
||||
const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000
|
||||
const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000
|
||||
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
|
||||
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
|
||||
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
|
||||
@@ -60,12 +69,13 @@ var _status_icon: ColorRect
|
||||
var _status_label: Label
|
||||
var _client_grid: VBoxContainer
|
||||
var _client_configure_all_btn: Button
|
||||
var _client_empty_cta_btn: Button
|
||||
var _clients_summary_label: Label
|
||||
var _clients_window: Window
|
||||
var _dev_mode_toggle: CheckButton
|
||||
var _install_label: Label
|
||||
|
||||
# Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients
|
||||
# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients
|
||||
# that cap total tool count (Antigravity: 100). Pending set is mutated by
|
||||
# checkbox clicks; saved set reflects what the spawned server actually
|
||||
# sees. `Apply & Restart Server` writes pending → setting and triggers a
|
||||
@@ -147,23 +157,24 @@ static var _orphaned_client_status_refresh_threads: Array[Thread] = []
|
||||
|
||||
## Per-row worker state for Configure / Remove. Issue #239: shelling out
|
||||
## to a hung CLI on main hangs the editor. We dispatch each click to its
|
||||
## own thread (one slot per client) and apply the result via call_deferred
|
||||
## once the subprocess returns or the wall-clock budget in McpCliExec
|
||||
## kicks in. The buttons stay disabled while the slot is busy so the user
|
||||
## can't queue a re-click on the same row.
|
||||
## own thread (one slot per client), then `_process` reaps completed workers
|
||||
## and applies returned payloads on main. The buttons stay disabled while
|
||||
## the slot is busy so the user can't queue a re-click on the same row.
|
||||
##
|
||||
## Per-client (not single-slot) so Configure-all can fan out — the
|
||||
## workers are independent, only the row UI is shared, and McpCliExec
|
||||
## bounds the wall-clock for each.
|
||||
##
|
||||
## No orphan-thread list (unlike the refresh worker): action threads
|
||||
## never get abandoned mid-flight. McpCliExec's wall-clock budget caps
|
||||
## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager`
|
||||
## install-time drain blocks briefly and finishes — there's no path that
|
||||
## "gives up" on an action thread the way `_abandon_client_status_refresh_thread`
|
||||
## does for the refresh worker.
|
||||
## A watchdog can abandon a slot when a worker fails to report completion.
|
||||
## The thread object is retained in `_orphaned_client_action_threads` until
|
||||
## it finishes so GDScript does not destroy a live Thread object.
|
||||
var _client_action_threads: Dictionary = {}
|
||||
var _client_action_generations: Dictionary = {}
|
||||
var _client_action_started_msec: Dictionary = {}
|
||||
var _client_action_names: Dictionary = {}
|
||||
## Timed-out Configure/Remove workers are abandoned but retained here until
|
||||
## they finish, so GDScript does not destroy a live Thread object.
|
||||
static var _orphaned_client_action_threads: Array[Thread] = []
|
||||
|
||||
# Dev-mode only
|
||||
var _dev_section: VBoxContainer
|
||||
@@ -193,6 +204,11 @@ var _crash_panel: VBoxContainer
|
||||
var _crash_output: RichTextLabel
|
||||
var _crash_restart_btn: Button
|
||||
var _crash_reload_btn: Button
|
||||
## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
|
||||
## case (no `can_recover_incompatible` proof). The inline body names a free
|
||||
## port; this button carries the per-client reconfigure steps that don't fit
|
||||
## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
|
||||
var _crash_docs_btn: Button
|
||||
## Port-picker escape hatch — visible inside the crash panel when the root
|
||||
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
|
||||
## the EditorSetting and reloads the plugin in response to the panel's
|
||||
@@ -238,10 +254,14 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_prune_orphaned_client_status_refresh_threads()
|
||||
_prune_orphaned_client_action_threads()
|
||||
_poll_completed_client_status_refresh_thread()
|
||||
_poll_completed_client_action_threads()
|
||||
_check_client_status_refresh_timeout()
|
||||
_check_client_action_timeouts()
|
||||
if _connection == null:
|
||||
return
|
||||
_prune_orphaned_client_status_refresh_threads()
|
||||
_check_client_status_refresh_timeout()
|
||||
_retry_deferred_client_status_refresh()
|
||||
_update_status()
|
||||
if _log_viewer != null and _log_viewer.visible:
|
||||
@@ -275,6 +295,8 @@ func _exit_tree() -> void:
|
||||
## drains directly because it has additional state-machine work
|
||||
## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit.
|
||||
func prepare_for_self_update_drain() -> void:
|
||||
_poll_completed_client_status_refresh_thread()
|
||||
_poll_completed_client_action_threads()
|
||||
_drain_client_status_refresh_workers()
|
||||
_drain_client_action_workers()
|
||||
|
||||
@@ -309,13 +331,13 @@ func _drain_client_action_workers() -> void:
|
||||
## plugin disable / install-update path reloads our script class, so any
|
||||
## live Thread must finish before its slot is GC'd or we hit
|
||||
## `~Thread … destroyed without its completion having been realized` →
|
||||
## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the
|
||||
## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV.
|
||||
## VM corruption. Normal UI recovery is handled by the per-row watchdog;
|
||||
## teardown still blocks because GDScript's Thread API has no kill/timeout
|
||||
## primitive and destroying a live Thread corrupts the VM.
|
||||
##
|
||||
## Generation-bumped per-row so any pending `call_deferred(
|
||||
## "_apply_client_action_result")` from a worker that finished after we
|
||||
## started draining detects the generation mismatch and short-circuits
|
||||
## without touching freed UI state.
|
||||
## Generation-bumped per-row so any result from a worker that finished
|
||||
## after we started draining detects the generation mismatch and
|
||||
## short-circuits without touching freed UI state.
|
||||
##
|
||||
## After draining, restore the row UI for any in-flight rows: bare
|
||||
## `_client_action_threads.clear()` would leave the dock stuck showing
|
||||
@@ -328,6 +350,8 @@ func _drain_client_action_workers() -> void:
|
||||
if t != null:
|
||||
t.wait_to_finish()
|
||||
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
_finalize_action_buttons(String(client_id))
|
||||
var row: Dictionary = _client_rows.get(String(client_id), {})
|
||||
if not row.is_empty():
|
||||
@@ -337,6 +361,71 @@ func _drain_client_action_workers() -> void:
|
||||
""
|
||||
)
|
||||
_client_action_threads.clear()
|
||||
for thread in _orphaned_client_action_threads:
|
||||
if thread != null:
|
||||
thread.wait_to_finish()
|
||||
_orphaned_client_action_threads.clear()
|
||||
_client_action_started_msec.clear()
|
||||
_client_action_names.clear()
|
||||
|
||||
|
||||
func _check_client_action_timeouts() -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
for client_id in _client_action_threads.keys():
|
||||
if not _client_action_started_msec.has(client_id):
|
||||
continue
|
||||
var started := int(_client_action_started_msec.get(client_id, 0))
|
||||
if now - started >= CLIENT_ACTION_TIMEOUT_MSEC:
|
||||
_abandon_client_action_thread(String(client_id))
|
||||
|
||||
|
||||
func _abandon_client_action_thread(client_id: String) -> void:
|
||||
if not _client_action_threads.has(client_id):
|
||||
return
|
||||
var thread: Thread = _client_action_threads[client_id]
|
||||
var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec()))
|
||||
var worker_alive := thread != null and thread.is_alive()
|
||||
if thread != null:
|
||||
_orphaned_client_action_threads.append(thread)
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
var action := str(_client_action_names.get(client_id, "configure"))
|
||||
_client_action_names.erase(client_id)
|
||||
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
||||
_finalize_action_buttons(client_id)
|
||||
print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [
|
||||
client_id,
|
||||
action,
|
||||
elapsed,
|
||||
str(worker_alive),
|
||||
])
|
||||
var label := "Remove" if action == "remove" else "Configure"
|
||||
_apply_row_status(
|
||||
client_id,
|
||||
Client.Status.ERROR,
|
||||
"%s did not report completion in time; refreshing current status." % label
|
||||
)
|
||||
_refresh_clients_summary()
|
||||
if is_inside_tree():
|
||||
_request_client_status_refresh(true)
|
||||
|
||||
|
||||
func _prune_orphaned_client_action_threads() -> void:
|
||||
var completed_orphan := false
|
||||
for i in range(_orphaned_client_action_threads.size() - 1, -1, -1):
|
||||
var thread := _orphaned_client_action_threads[i]
|
||||
if thread == null:
|
||||
_orphaned_client_action_threads.remove_at(i)
|
||||
elif not thread.is_alive():
|
||||
thread.wait_to_finish()
|
||||
_orphaned_client_action_threads.remove_at(i)
|
||||
completed_orphan = true
|
||||
if completed_orphan and is_inside_tree():
|
||||
_request_client_action_completion_refresh()
|
||||
|
||||
|
||||
func _request_client_action_completion_refresh() -> void:
|
||||
_request_client_status_refresh(true)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
@@ -473,6 +562,13 @@ func _build_ui() -> void:
|
||||
_crash_reload_btn.pressed.connect(_on_reload_plugin)
|
||||
_crash_panel.add_child(_crash_reload_btn)
|
||||
|
||||
_crash_docs_btn = Button.new()
|
||||
_crash_docs_btn.text = "How to change the port"
|
||||
_crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients"
|
||||
_crash_docs_btn.visible = false
|
||||
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
|
||||
_crash_panel.add_child(_crash_docs_btn)
|
||||
|
||||
_crash_panel.add_child(HSeparator.new())
|
||||
add_child(_crash_panel)
|
||||
|
||||
@@ -558,30 +654,45 @@ func _build_ui() -> void:
|
||||
add_child(HSeparator.new())
|
||||
|
||||
# --- Clients ---
|
||||
var clients_row := HBoxContainer.new()
|
||||
clients_row.add_theme_constant_override("separation", 8)
|
||||
var clients_header_row := HBoxContainer.new()
|
||||
clients_header_row.add_theme_constant_override("separation", 8)
|
||||
|
||||
var clients_header := _make_header("Clients")
|
||||
clients_row.add_child(clients_header)
|
||||
clients_header_row.add_child(clients_header)
|
||||
|
||||
_clients_summary_label = Label.new()
|
||||
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
|
||||
_clients_summary_label.clip_text = true
|
||||
_clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
clients_row.add_child(_clients_summary_label)
|
||||
clients_header_row.add_child(_clients_summary_label)
|
||||
|
||||
var clients_actions := HFlowContainer.new()
|
||||
clients_actions.add_theme_constant_override("h_separation", 8)
|
||||
clients_actions.add_theme_constant_override("v_separation", 4)
|
||||
|
||||
var clients_refresh_btn := Button.new()
|
||||
clients_refresh_btn.text = "Refresh"
|
||||
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
|
||||
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
|
||||
clients_row.add_child(clients_refresh_btn)
|
||||
clients_actions.add_child(clients_refresh_btn)
|
||||
|
||||
var clients_open_btn := Button.new()
|
||||
clients_open_btn.text = "Clients & Settings"
|
||||
clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
|
||||
clients_open_btn.text = "Clients & Tools"
|
||||
clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
|
||||
clients_open_btn.pressed.connect(_on_open_clients_window)
|
||||
clients_row.add_child(clients_open_btn)
|
||||
clients_actions.add_child(clients_open_btn)
|
||||
|
||||
add_child(clients_row)
|
||||
add_child(clients_header_row)
|
||||
add_child(clients_actions)
|
||||
|
||||
_client_empty_cta_btn = Button.new()
|
||||
_client_empty_cta_btn.text = "Configure an AI client ->"
|
||||
_client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
|
||||
_client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_client_empty_cta_btn.visible = false
|
||||
_client_empty_cta_btn.pressed.connect(_on_open_clients_window)
|
||||
add_child(_client_empty_cta_btn)
|
||||
|
||||
# Drift banner — hidden until a sweep finds at least one mismatched client.
|
||||
_drift_banner = VBoxContainer.new()
|
||||
@@ -600,7 +711,7 @@ func _build_ui() -> void:
|
||||
add_child(_drift_banner)
|
||||
|
||||
_clients_window = Window.new()
|
||||
_clients_window.title = "MCP Clients & Settings"
|
||||
_clients_window.title = "Godot AI"
|
||||
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
|
||||
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
|
||||
_clients_window.visible = false
|
||||
@@ -758,7 +869,7 @@ func _build_client_row(client_id: String) -> void:
|
||||
# --- Status updates ---
|
||||
|
||||
func _update_status() -> void:
|
||||
var connected: bool = _connection.is_connected
|
||||
var connected: bool = _connection != null and _connection.is_connected
|
||||
## During plugin self-update there's a brief window where this dock
|
||||
## script is already the new version (Godot hot-reloads scripts on
|
||||
## file change) but `_plugin` is still the old `EditorPlugin` instance
|
||||
@@ -785,7 +896,7 @@ func _update_status() -> void:
|
||||
status_text = "Restarting server..."
|
||||
status_color = COLOR_AMBER
|
||||
elif connected:
|
||||
status_text = "Connected"
|
||||
status_text = _connected_status_text()
|
||||
status_color = Color.GREEN
|
||||
elif state == ServerStateScript.CRASHED:
|
||||
var exit_ms: int = server_status.get("exit_ms", 0)
|
||||
@@ -858,6 +969,15 @@ func _update_crash_panel(server_status: Dictionary) -> void:
|
||||
not show_recovery_restart
|
||||
and state != ServerStateScript.INCOMPATIBLE
|
||||
)
|
||||
## Docs link only for the genuinely-foreign occupant: a recoverable
|
||||
## (older godot-ai) server gets Restart Server instead, and the inline
|
||||
## body already names a free port — the link carries the per-client
|
||||
## reconfigure steps that don't fit inline.
|
||||
if _crash_docs_btn != null:
|
||||
_crash_docs_btn.visible = (
|
||||
state == ServerStateScript.INCOMPATIBLE
|
||||
and not bool(server_status.get("can_recover_incompatible", false))
|
||||
)
|
||||
|
||||
var port_picker_visible := (
|
||||
state == ServerStateScript.PORT_EXCLUDED
|
||||
@@ -887,9 +1007,16 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
||||
if not message.is_empty():
|
||||
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
|
||||
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
|
||||
## Genuinely foreign occupant (no recovery proof). Name a concrete
|
||||
## free port so the user doesn't have to hunt for one, and let the
|
||||
## crash panel's "How to change the port" link carry the per-client
|
||||
## reconfigure steps. `suggest_free_port` already routes through the
|
||||
## Windows reservation table, so the named port won't itself fail
|
||||
## with WinError 10013.
|
||||
var hint := _free_port_hint(port)
|
||||
if not message.is_empty():
|
||||
return message
|
||||
return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port
|
||||
return "%s %s" % [message, hint]
|
||||
return "Port %d is occupied by an incompatible server. %s" % [port, hint]
|
||||
ServerStateScript.FOREIGN_PORT:
|
||||
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
|
||||
ServerStateScript.CRASHED:
|
||||
@@ -907,6 +1034,31 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
||||
return ""
|
||||
|
||||
|
||||
## One sentence naming concrete free ports for the user to switch to. Names
|
||||
## BOTH http and ws: this branch also fires for an incompatible godot-ai
|
||||
## server we can't prove we own, which commonly holds both ports — moving only
|
||||
## http would then leave the new server unable to bind ws. Both suggestions are
|
||||
## routed through `suggest_free_port` so they clear Windows' winnat reservation
|
||||
## table (no point suggesting a port that 10013s on bind). Only the http port
|
||||
## reaches client configs; the ws port is server↔plugin, hence the wording.
|
||||
## The per-client reconfigure steps live behind the crash panel's docs link.
|
||||
static func _free_port_hint(port: int) -> String:
|
||||
var free_http := ClientConfigurator.suggest_free_port(port + 1)
|
||||
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1)
|
||||
return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws]
|
||||
|
||||
|
||||
## URL for the port-conflict guide, pinned to the release tag that matches the
|
||||
## installed plugin version (releases are tagged `v<version>`). The crash-panel
|
||||
## button only exists in builds that ship `docs/port-conflicts.md`, so the
|
||||
## versioned ref always resolves — and a shipped build never points users at a
|
||||
## tip-of-main guide that has drifted from its own UI.
|
||||
static func _port_conflict_docs_url() -> String:
|
||||
var version := ClientConfigurator.get_plugin_version()
|
||||
var git_ref := ("v%s" % version) if not version.is_empty() else "main"
|
||||
return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
|
||||
|
||||
|
||||
## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner`
|
||||
## confirms `*.update_backup` files exist in the addons tree. Mirrors the
|
||||
## issue #354 fix shape: structured, agent-readable diagnostic that survives
|
||||
@@ -1480,6 +1632,30 @@ func _update_dev_section_buttons() -> void:
|
||||
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
|
||||
|
||||
|
||||
func _configured_client_count() -> int:
|
||||
var configured := 0
|
||||
for client_id in _client_rows:
|
||||
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
||||
if status == Client.Status.CONFIGURED:
|
||||
configured += 1
|
||||
return configured
|
||||
|
||||
|
||||
func _client_status_refresh_has_completed() -> bool:
|
||||
return _last_client_status_refresh_completed_msec > 0
|
||||
|
||||
|
||||
func _connected_status_text() -> String:
|
||||
var configured := _configured_client_count()
|
||||
if configured == 0:
|
||||
if not _client_status_refresh_has_completed():
|
||||
return "Server connected · checking AI client configuration"
|
||||
return "Server connected · no AI client configured"
|
||||
if configured == 1:
|
||||
return "Server connected · 1 AI client configured"
|
||||
return "Server connected · %d AI clients configured" % configured
|
||||
|
||||
|
||||
func _on_install_uv() -> void:
|
||||
match OS.get_name():
|
||||
"Windows":
|
||||
@@ -1548,28 +1724,65 @@ func _dispatch_client_action(client_id: String, action: String) -> void:
|
||||
_client_action_generations[client_id] = generation
|
||||
var thread := Thread.new()
|
||||
_client_action_threads[client_id] = thread
|
||||
_client_action_started_msec[client_id] = Time.get_ticks_msec()
|
||||
_client_action_names[client_id] = action
|
||||
var err := thread.start(
|
||||
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
|
||||
)
|
||||
if err != OK:
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
_finalize_action_buttons(client_id)
|
||||
_apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread")
|
||||
_refresh_clients_summary()
|
||||
|
||||
|
||||
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void:
|
||||
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> Dictionary:
|
||||
var result: Dictionary
|
||||
if action == "remove":
|
||||
result = ClientConfigurator.remove(client_id, server_url)
|
||||
else:
|
||||
result = ClientConfigurator.configure(client_id, server_url)
|
||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
call_deferred("_apply_client_action_result", client_id, action, result, generation)
|
||||
return {
|
||||
"client_id": client_id,
|
||||
"action": action,
|
||||
"result": result,
|
||||
"generation": generation,
|
||||
}
|
||||
|
||||
|
||||
func _poll_completed_client_action_threads() -> void:
|
||||
for client_id in _client_action_threads.keys():
|
||||
var thread: Thread = _client_action_threads[client_id]
|
||||
if thread == null or thread.is_alive():
|
||||
continue
|
||||
var payload: Variant = thread.wait_to_finish()
|
||||
_client_action_threads[client_id] = null
|
||||
if payload is Dictionary:
|
||||
var data := payload as Dictionary
|
||||
var result: Dictionary = data.get("result", {})
|
||||
_apply_client_action_result(
|
||||
String(data.get("client_id", client_id)),
|
||||
String(data.get("action", _client_action_names.get(client_id, "configure"))),
|
||||
result,
|
||||
int(data.get("generation", _client_action_generations.get(client_id, 0)))
|
||||
)
|
||||
else:
|
||||
_apply_client_action_result(
|
||||
String(client_id),
|
||||
String(_client_action_names.get(client_id, "configure")),
|
||||
{"status": "error", "message": "worker returned no result"},
|
||||
int(_client_action_generations.get(client_id, 0))
|
||||
)
|
||||
|
||||
|
||||
func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void:
|
||||
if int(_client_action_generations.get(client_id, 0)) != generation:
|
||||
if _client_action_threads.get(client_id, null) == null:
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
return
|
||||
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
return
|
||||
@@ -1578,6 +1791,8 @@ func _apply_client_action_result(client_id: String, action: String, result: Dict
|
||||
if t != null:
|
||||
t.wait_to_finish()
|
||||
_client_action_threads.erase(client_id)
|
||||
_client_action_started_msec.erase(client_id)
|
||||
_client_action_names.erase(client_id)
|
||||
_finalize_action_buttons(client_id)
|
||||
if _server_blocks_client_health():
|
||||
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
|
||||
@@ -1645,7 +1860,7 @@ func _on_configure_all_clients() -> void:
|
||||
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
||||
_refresh_clients_summary()
|
||||
return
|
||||
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
|
||||
if ClientRefreshStateScript.should_disable_client_actions(_refresh_state):
|
||||
return
|
||||
for client_id in _client_rows:
|
||||
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
||||
@@ -1700,7 +1915,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
|
||||
var tools_tab := VBoxContainer.new()
|
||||
tools_tab.add_theme_constant_override("separation", 8)
|
||||
var tools_margin := _build_margin_container()
|
||||
tools_margin.name = "Settings"
|
||||
tools_margin.name = "Tools"
|
||||
tools_margin.add_child(tools_tab)
|
||||
tabs.add_child(tools_margin)
|
||||
|
||||
@@ -1976,8 +2191,11 @@ func _refresh_clients_summary() -> void:
|
||||
)
|
||||
_clients_summary_label.text = text
|
||||
if _client_configure_all_btn != null:
|
||||
_client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state)
|
||||
_client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state)
|
||||
if _client_empty_cta_btn != null:
|
||||
_client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
|
||||
_refresh_drift_banner(mismatched_ids)
|
||||
_update_status()
|
||||
|
||||
|
||||
func _show_manual_command_for(client_id: String) -> void:
|
||||
@@ -2167,8 +2385,8 @@ func _warm_strategy_bytecode() -> void:
|
||||
|
||||
func _begin_client_status_refresh_run() -> int:
|
||||
## Marks a refresh as starting and returns the new generation token.
|
||||
## Generation is bumped here (not at completion) so that a worker callback
|
||||
## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree`
|
||||
## Generation is bumped here (not at completion) so that a worker result
|
||||
## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree`
|
||||
## fires can be detected as stale via generation mismatch.
|
||||
_refresh_state = ClientRefreshStateScript.RUNNING
|
||||
_client_status_refresh_pending = false
|
||||
@@ -2181,7 +2399,7 @@ func _begin_client_status_refresh_run() -> int:
|
||||
|
||||
func _finalize_completed_refresh() -> void:
|
||||
## Stamps cooldown and clears in-flight state. Called at the end of every
|
||||
## refresh that successfully applied results — the worker callback path
|
||||
## refresh that successfully applied results — the worker reaping path
|
||||
## and the no-CLI fast path in `_perform_initial_client_status_refresh`.
|
||||
_last_client_status_refresh_completed_msec = Time.get_ticks_msec()
|
||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
@@ -2308,7 +2526,7 @@ func _retry_deferred_client_status_refresh() -> void:
|
||||
_request_client_status_refresh(force)
|
||||
|
||||
|
||||
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void:
|
||||
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> Dictionary:
|
||||
var results: Dictionary = {}
|
||||
for probe in client_probes:
|
||||
var client_id := String(probe.get("id", ""))
|
||||
@@ -2325,8 +2543,25 @@ func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_
|
||||
"installed": installed,
|
||||
"error_msg": details.get("error_msg", ""),
|
||||
}
|
||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
||||
call_deferred("_apply_client_status_refresh_results", results, generation)
|
||||
return {"results": results, "generation": generation}
|
||||
|
||||
|
||||
func _poll_completed_client_status_refresh_thread() -> void:
|
||||
if _client_status_refresh_thread == null:
|
||||
return
|
||||
if _client_status_refresh_thread.is_alive():
|
||||
return
|
||||
var payload: Variant = _client_status_refresh_thread.wait_to_finish()
|
||||
_client_status_refresh_thread = null
|
||||
if payload is Dictionary:
|
||||
var data := payload as Dictionary
|
||||
var results: Dictionary = data.get("results", {})
|
||||
_apply_client_status_refresh_results(
|
||||
results,
|
||||
int(data.get("generation", _client_status_refresh_generation))
|
||||
)
|
||||
else:
|
||||
_apply_client_status_refresh_results({}, _client_status_refresh_generation)
|
||||
|
||||
|
||||
func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void:
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="Godot AI"
|
||||
description="MCP server and AI tools for Godot"
|
||||
author="Godot AI"
|
||||
version="2.7.6"
|
||||
version="2.8.1"
|
||||
script="plugin.gd"
|
||||
|
||||
@@ -233,14 +233,14 @@ func _enter_tree() -> void:
|
||||
|
||||
_telemetry = Telemetry.new(_connection)
|
||||
|
||||
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer)
|
||||
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer)
|
||||
add_debugger_plugin(_debugger_plugin)
|
||||
_ensure_game_helper_autoload()
|
||||
|
||||
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer)
|
||||
var scene_handler := SceneHandler.new(_connection)
|
||||
var node_handler := NodeHandler.new(get_undo_redo())
|
||||
var project_handler := ProjectHandler.new(_connection, _debugger_plugin)
|
||||
var project_handler := ProjectHandler.new(_connection, _debugger_plugin, _editor_log_buffer)
|
||||
var client_handler := ClientHandler.new()
|
||||
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
|
||||
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
|
||||
|
||||
@@ -81,9 +81,9 @@ func _log_error(
|
||||
## Collect every function name in the first non-empty backtrace so
|
||||
## game_helper can match its eval's uniquely named wrapper function.
|
||||
var funcs := PackedStringArray()
|
||||
for bt in script_backtraces:
|
||||
for bt: RefCounted in script_backtraces:
|
||||
if bt != null and bt.get_frame_count() > 0:
|
||||
for i in bt.get_frame_count():
|
||||
for i: int in bt.get_frame_count():
|
||||
funcs.append(bt.get_frame_function(i))
|
||||
break
|
||||
_mutex.lock()
|
||||
|
||||
@@ -6,9 +6,8 @@ extends McpStructuredLogRing
|
||||
## ferried back from the playing game over the EngineDebugger channel.
|
||||
##
|
||||
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
|
||||
## rotates each time clear_for_new_run() fires (called on the game's
|
||||
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
|
||||
## this play started".
|
||||
## rotates at play-start, giving agents a stable cursor for "lines from
|
||||
## this run" even when the game never reaches the mcp:hello boot beacon.
|
||||
##
|
||||
## Single-threaded — game_helper.gd drains its logger from `_process` and
|
||||
## calls `append` from the main thread, so this subclass can use the base
|
||||
@@ -17,6 +16,7 @@ extends McpStructuredLogRing
|
||||
const MAX_LINES := 2000
|
||||
|
||||
var _run_id := ""
|
||||
var _run_seq := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -24,17 +24,22 @@ func _init() -> void:
|
||||
|
||||
|
||||
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
|
||||
var entry := {
|
||||
"source": "game",
|
||||
"level": _coerce_level(level),
|
||||
"text": text,
|
||||
"run_id": _run_id,
|
||||
}
|
||||
if not details.is_empty():
|
||||
entry["details"] = details.duplicate(true)
|
||||
_append_entry(entry)
|
||||
|
||||
|
||||
## Rotate the run identifier and drop all buffered entries. Called when the
|
||||
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
|
||||
## Returns the new run_id.
|
||||
## Rotate the run identifier without dropping buffered entries. Called at
|
||||
## play-start so even no-hello parse failures get a fresh current-run identity.
|
||||
## Historical lines stay tagged with their original run_id and can still be
|
||||
## queried explicitly.
|
||||
func clear_for_new_run() -> String:
|
||||
_clear_storage()
|
||||
_run_id = _generate_run_id()
|
||||
return _run_id
|
||||
|
||||
@@ -43,8 +48,38 @@ func run_id() -> String:
|
||||
return _run_id
|
||||
|
||||
|
||||
static func _generate_run_id() -> String:
|
||||
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
|
||||
return get_run_page(run_id, offset, count).entries
|
||||
|
||||
|
||||
func run_total_count(run_id: String) -> int:
|
||||
return int(get_run_page(run_id, 0, 0).total_count)
|
||||
|
||||
|
||||
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
|
||||
var entries := _entries_for_run(run_id)
|
||||
var start := mini(maxi(0, offset), entries.size())
|
||||
var stop := mini(entries.size(), start + maxi(0, count))
|
||||
var out: Array[Dictionary] = []
|
||||
for i in range(start, stop):
|
||||
out.append(entries[i])
|
||||
return {
|
||||
"entries": out,
|
||||
"total_count": entries.size(),
|
||||
}
|
||||
|
||||
|
||||
func _entries_for_run(run_id: String) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for entry in get_range(0, total_count()):
|
||||
if str(entry.get("run_id", "")) == run_id:
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
func _generate_run_id() -> String:
|
||||
## Opaque to agents — they only check equality. Time-based is plenty
|
||||
## unique within a single editor session and avoids the RNG-seed
|
||||
## reproducibility footgun.
|
||||
return "r%d" % Time.get_ticks_msec()
|
||||
## unique within a single editor session; the local sequence protects
|
||||
## fast back-to-back test runs within the same millisecond.
|
||||
_run_seq += 1
|
||||
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
|
||||
|
||||
@@ -57,6 +57,14 @@ static func has_worker_alive(state: int) -> bool:
|
||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||
|
||||
|
||||
## True while the status worker is still within its healthy budget. Once a
|
||||
## refresh has timed out, the dock keeps the warning badge but must let users
|
||||
## retry Configure / Configure all instead of stranding the controls behind an
|
||||
## orphaned, uninterruptible worker.
|
||||
static func should_disable_client_actions(state: int) -> bool:
|
||||
return state == RUNNING
|
||||
|
||||
|
||||
## True when the dock should reject new refresh spawns. Used by the
|
||||
## focus-in / manual button / cooldown-timer entrypoints.
|
||||
static func is_blocked_for_spawn(state: int) -> bool:
|
||||
|
||||
@@ -300,6 +300,15 @@ func _set_incompatible_server(live: Dictionary, expected_version: String, port:
|
||||
var proof_name := str(proof.get("proof", ""))
|
||||
_can_recover_incompatible = not proof_name.is_empty()
|
||||
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
|
||||
if not _can_recover_incompatible:
|
||||
## Non-recoverable: a foreign / unprovable occupant holds the port and
|
||||
## we have no ownership proof, so we must NOT kill it — surface a
|
||||
## concrete free port the user can switch to instead (the same hint
|
||||
## the dock crash body renders). Logging it to the editor output also
|
||||
## lets `ci-stale-server-smoke --mode foreign` assert this upstream
|
||||
## classification from CI. Reservation-aware on Windows.
|
||||
var suggested := ClientConfigurator.suggest_free_port(port + 1)
|
||||
print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, suggested])
|
||||
_host._refresh_dock_client_statuses()
|
||||
|
||||
|
||||
|
||||
@@ -314,6 +314,8 @@ static func _is_trusted_download_url(url: String) -> bool:
|
||||
const SCHEME := "https://"
|
||||
if not url.begins_with(SCHEME):
|
||||
return false
|
||||
if url.find("\\") >= 0:
|
||||
return false
|
||||
var rest := url.substr(SCHEME.length())
|
||||
var authority := rest
|
||||
var slash := rest.find("/")
|
||||
@@ -457,7 +459,7 @@ func _on_checksum_completed(
|
||||
|
||||
print("MCP | self-update checksum verified (sha256 %s)" % actual)
|
||||
install_state_changed.emit({"button_text": "Installing..."})
|
||||
_install_zip()
|
||||
_install_zip.call_deferred()
|
||||
|
||||
|
||||
## Surface an integrity-check failure and drop the staged zip so the bad
|
||||
@@ -483,8 +485,9 @@ static func _parse_sha256_digest(text: String) -> String:
|
||||
if trimmed.is_empty():
|
||||
return ""
|
||||
## First whitespace-delimited token; `sha256sum` separates digest and
|
||||
## filename with two spaces, so allow_empty=false collapses the run.
|
||||
var tokens := trimmed.split(" ", false)
|
||||
## filename with two spaces, but some tools use tabs.
|
||||
var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ")
|
||||
var tokens := normalized.split(" ", false)
|
||||
if tokens.is_empty():
|
||||
return ""
|
||||
var digest := String(tokens[0]).strip_edges().to_lower()
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dsi2vtxfvqdm7"
|
||||
path="res://.godot/imported/mekton_bull.glb-55daae491fc640a3c58139492aec6bb6.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/mektons/mekton_bull.glb"
|
||||
dest_files=["res://.godot/imported/mekton_bull.glb-55daae491fc640a3c58139492aec6bb6.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c5nsxhrn7kgln"
|
||||
path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "74daccd2f257ba6ddc877d0e3112b374"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901.png"
|
||||
dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.9 MiB |
+45
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ctf5kpns0edj8"
|
||||
path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "30794e134a9d90aee14b4cd127e9d1cb"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png"
|
||||
dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://2f4nyuypxmfl"
|
||||
path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "a8a4c3a73bd7e809157f8fc65b8872c9"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png"
|
||||
dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=1
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=1
|
||||
roughness/src_normal="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png"
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Godot AI contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +0,0 @@
|
||||
@rpc("any_peer", "call_local")
|
||||
func remove_slow_effect():
|
||||
slow_timer = 0.0
|
||||
self.is_slowed = false
|
||||
if movement_manager:
|
||||
movement_manager.set_speed_multiplier(1.0)
|
||||
@@ -1,6 +0,0 @@
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_clear_sticky_cell(pos: Vector2i) -> void:
|
||||
sticky_cells.erase(pos)
|
||||
mark_cleansed(pos)
|
||||
if gridmap:
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
|
||||
@@ -1,68 +0,0 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="1_font"]
|
||||
|
||||
[node name="GauntletHUD" type="CanvasLayer"]
|
||||
layer = 5
|
||||
visible = false
|
||||
|
||||
[node name="TopContainer" type="CenterContainer" parent="."]
|
||||
anchors_preset = 5
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_top = 70.0
|
||||
grow_horizontal = 2
|
||||
|
||||
[node name="SlowMoLabel" type="Label" parent="TopContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_colors/font_color = Color(0.3, 0.5, 1.0, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
text = "SLOW-MO"
|
||||
horizontal_alignment = 1
|
||||
visible = false
|
||||
|
||||
[node name="BottomContainer" type="CenterContainer" parent="."]
|
||||
anchors_preset = 7
|
||||
anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -120.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="BottomContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="PhaseLabel" type="Label" parent="BottomContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 24
|
||||
theme_override_colors/font_color = Color(1, 0.6, 0.8, 1)
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
text = "🍬 OPEN ARENA"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="CleanserHBox" type="HBoxContainer" parent="BottomContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
alignment = 1
|
||||
|
||||
[node name="CleanserIcon" type="TextureRect" parent="BottomContainer/VBoxContainer/CleanserHBox"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(20, 20)
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="CleanserLabel" type="Label" parent="BottomContainer/VBoxContainer/CleanserHBox"]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 20
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 6
|
||||
theme_override_fonts/font = ExtResource("1_font")
|
||||
text = "[E] Cleanser (0)"
|
||||
horizontal_alignment = 1
|
||||
@@ -1,48 +0,0 @@
|
||||
func _spawn_cleanser_particles(pos: Vector2i) -> void:
|
||||
"""Spawn bright cleansing particles when sticky is cleared."""
|
||||
if not main_scene or not gridmap:
|
||||
return
|
||||
|
||||
var world_pos = Vector3(
|
||||
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||||
0.5,
|
||||
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||
)
|
||||
|
||||
var particles = GPUParticles3D.new()
|
||||
particles.emitting = true
|
||||
particles.one_shot = true
|
||||
particles.amount = 12
|
||||
particles.lifetime = 0.6
|
||||
particles.explosiveness = 0.9
|
||||
|
||||
var material = ParticleProcessMaterial.new()
|
||||
material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||
material.emission_sphere_radius = 0.3
|
||||
material.direction = Vector3(0, 1, 0)
|
||||
material.spread = 180.0
|
||||
material.initial_velocity_min = 3.0
|
||||
material.initial_velocity_max = 5.0
|
||||
material.gravity = Vector3(0, -5.0, 0)
|
||||
material.scale_min = 0.05
|
||||
material.scale_max = 0.15
|
||||
|
||||
var mesh = SphereMesh.new()
|
||||
mesh.radius = 0.2
|
||||
mesh.height = 0.4
|
||||
var spatial_mat = StandardMaterial3D.new()
|
||||
spatial_mat.albedo_color = Color(0.2, 1.0, 1.0) # Cyan/Blue for cleanser
|
||||
spatial_mat.emission_enabled = true
|
||||
spatial_mat.emission = Color(0.2, 1.0, 1.0)
|
||||
spatial_mat.emission_energy_multiplier = 3.0
|
||||
mesh.material = spatial_mat
|
||||
particles.draw_pass_1 = mesh
|
||||
|
||||
particles.process_material = material
|
||||
particles.position = world_pos
|
||||
|
||||
main_scene.add_child(particles)
|
||||
|
||||
await get_tree().create_timer(1.2).timeout
|
||||
if particles and is_instance_valid(particles):
|
||||
particles.queue_free()
|
||||
@@ -1,24 +0,0 @@
|
||||
func _find_valid_drop_position() -> Vector2i:
|
||||
# Try random adjacent cells
|
||||
var neighbors = enhanced_gridmap.get_neighbors(current_position, 0)
|
||||
neighbors.shuffle()
|
||||
|
||||
for neighbor in neighbors:
|
||||
var pos = neighbor.position
|
||||
# Check item layer
|
||||
var item_cell = Vector3i(pos.x, 1, pos.y)
|
||||
if enhanced_gridmap.get_cell_item(item_cell) == -1:
|
||||
if not is_position_occupied(pos):
|
||||
# Gauntlet Mode explicit overrides
|
||||
var gm = null
|
||||
var main_gauntlet = get_tree().root.get_node_or_null("Main")
|
||||
if main_gauntlet and main_gauntlet.get("gauntlet_manager"):
|
||||
gm = main_gauntlet.gauntlet_manager
|
||||
if gm and gm.is_active:
|
||||
if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1:
|
||||
continue
|
||||
if gm._is_npc_zone(pos):
|
||||
continue
|
||||
return pos
|
||||
|
||||
return Vector2i(-1, -1)
|
||||
@@ -1,8 +0,0 @@
|
||||
@rpc("any_peer", "call_local")
|
||||
func remove_slow_effect():
|
||||
slow_timer = 0.0
|
||||
self.is_slowed = false
|
||||
if movement_manager:
|
||||
# INSTANT response: restore speed multiplier to 1.0 immediately
|
||||
movement_manager.set_speed_multiplier(1.0)
|
||||
print("Player %s slow effect removed early" % name)
|
||||
@@ -1,25 +0,0 @@
|
||||
/func _find_valid_drop_position/,/return Vector2i(-1, -1)/c\
|
||||
func _find_valid_drop_position() -> Vector2i:\
|
||||
# Try random adjacent cells\
|
||||
var neighbors = enhanced_gridmap.get_neighbors(current_position, 0)\
|
||||
neighbors.shuffle()\
|
||||
\
|
||||
for neighbor in neighbors:\
|
||||
var pos = neighbor.position\
|
||||
# Check item layer\
|
||||
var item_cell = Vector3i(pos.x, 1, pos.y)\
|
||||
if enhanced_gridmap.get_cell_item(item_cell) == -1:\
|
||||
if not is_position_occupied(pos):\
|
||||
# Gauntlet Mode explicit overrides\
|
||||
var gm = null\
|
||||
var main_gauntlet = get_tree().root.get_node_or_null("Main")\
|
||||
if main_gauntlet and main_gauntlet.get("gauntlet_manager"):\
|
||||
gm = main_gauntlet.gauntlet_manager\
|
||||
if gm and gm.is_active:\
|
||||
if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1:\
|
||||
continue\
|
||||
if gm._is_npc_zone(pos):\
|
||||
continue\
|
||||
return pos\
|
||||
\
|
||||
return Vector2i(-1, -1)
|
||||
@@ -0,0 +1,7 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://c5xk3jdg2s11m"]
|
||||
|
||||
[node name="MektonBullsArena" type="Node3D"]
|
||||
|
||||
[node name="GridMap" type="GridMap" parent="."]
|
||||
|
||||
[node name="Area3D" type="Area3D" parent="."]
|
||||
+27
-2
@@ -17,6 +17,7 @@ var is_match_ended: bool = false
|
||||
var obstacle_manager
|
||||
var portal_mode_manager
|
||||
var gauntlet_manager
|
||||
var mekton_bulls_manager
|
||||
var vfx_manager
|
||||
|
||||
# Minimal local state
|
||||
@@ -259,6 +260,13 @@ func _init_managers():
|
||||
add_child(gauntlet_manager)
|
||||
gauntlet_manager.initialize(self, $EnhancedGridMap)
|
||||
|
||||
# Mekton Bulls manager
|
||||
if LobbyManager.game_mode == "Mekton Bulls":
|
||||
mekton_bulls_manager = load("res://scripts/managers/mekton_bulls_manager.gd").new()
|
||||
mekton_bulls_manager.name = "MektonBullsManager"
|
||||
add_child(mekton_bulls_manager)
|
||||
mekton_bulls_manager.initialize(self, $EnhancedGridMap)
|
||||
|
||||
# Screen shake manager for impact feedback
|
||||
screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new()
|
||||
screen_shake_manager.name = "ScreenShakeManager"
|
||||
@@ -623,6 +631,8 @@ func _setup_host_game():
|
||||
portal_mode_manager.setup_arena_locally()
|
||||
elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_manager._setup_arena()
|
||||
elif LobbyManager.game_mode == "Mekton Bulls" and mekton_bulls_manager:
|
||||
mekton_bulls_manager._setup_arena()
|
||||
else:
|
||||
# Randomize grid first to ensure Floor 0 is walkable for pre-calculation
|
||||
randomize_game_grid()
|
||||
@@ -729,10 +739,20 @@ func _setup_client_game():
|
||||
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||
portal_mode_manager.setup_arena_locally()
|
||||
|
||||
# Initialize arena locally for Candy Pump Survival
|
||||
# Special initialization for Gauntlet mode
|
||||
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||
gauntlet_manager._apply_arena_setup()
|
||||
|
||||
if LobbyManager.game_mode == "Mekton Bulls" and mekton_bulls_manager:
|
||||
mekton_bulls_manager.hud_node = load("res://scenes/ui/mekton_bulls_hud.tscn").instantiate()
|
||||
mekton_bulls_manager.hud_node.name = "MektonBullsHUD"
|
||||
add_child(mekton_bulls_manager.hud_node)
|
||||
if mekton_bulls_manager.hud_node.has_method("initialize"):
|
||||
mekton_bulls_manager.hud_node.initialize(mekton_bulls_manager)
|
||||
if mekton_bulls_manager.hud_node.has_method("set_local_player"):
|
||||
mekton_bulls_manager.hud_node.set_local_player(multiplayer.get_unique_id())
|
||||
mekton_bulls_manager._apply_arena_setup()
|
||||
|
||||
# Ensure local player setup (UI, controls) is verified
|
||||
var player_character = get_node_or_null(str(my_id))
|
||||
if player_character:
|
||||
@@ -876,10 +896,15 @@ func _start_game():
|
||||
elif LobbyManager.game_mode == "Candy Pump Survival":
|
||||
if gauntlet_manager:
|
||||
gauntlet_manager.start_game_mode()
|
||||
|
||||
if goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
goals_cycle_manager.start_match(float(match_duration), true) # Enable cycles for 3x3 pattern missions
|
||||
elif LobbyManager.game_mode == "Mekton Bulls":
|
||||
if mekton_bulls_manager:
|
||||
mekton_bulls_manager.start_game_mode()
|
||||
if goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
goals_cycle_manager.start_match(float(match_duration))
|
||||
elif goals_cycle_manager:
|
||||
var match_duration = LobbyManager.get_match_duration()
|
||||
goals_cycle_manager.start_match(float(match_duration))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[gd_scene load_steps=5 format=3 uid="uid://bull1234abcd"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://bullscript1" path="res://scripts/npcs/mekton_bull.gd" id="1_bull"]
|
||||
[ext_resource type="PackedScene" uid="uid://df7h7y7y7y7y7" path="res://scenes/static_tekton_mesh.tscn" id="2_mesh"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_bull"]
|
||||
size = Vector3(1.5, 2.0, 1.5)
|
||||
|
||||
[node name="MektonBull" type="Area3D" groups=["MektonBulls"]]
|
||||
collision_layer = 4
|
||||
collision_mask = 2
|
||||
script = ExtResource("1_bull")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0)
|
||||
shape = SubResource("BoxShape3D_bull")
|
||||
|
||||
[node name="Visuals" type="Node3D" parent="."]
|
||||
|
||||
[node name="Mesh" parent="Visuals" instance=ExtResource("2_mesh")]
|
||||
transform = Transform3D(0.4, 0, 0, 0, 0.4, 0, 0, 0, 0.4, 0, 0, 0)
|
||||
@@ -0,0 +1,91 @@
|
||||
[gd_scene format=3 uid="uid://bullhud"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://bullhudscript" path="res://scripts/ui/mekton_bulls_hud.gd" id="1_hud"]
|
||||
|
||||
[node name="MektonBullsHUD" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_hud")
|
||||
|
||||
[node name="BullTracker" type="Control" parent="."]
|
||||
anchors_preset = 0
|
||||
|
||||
[node name="PowerCounters" type="Control" parent="."]
|
||||
anchors_preset = 0
|
||||
layout_mode = 1
|
||||
anchors_preset = 2
|
||||
anchor_top = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -60.0
|
||||
offset_right = 300.0
|
||||
grow_vertical = 0
|
||||
|
||||
[node name="Label" type="Label" parent="PowerCounters"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
text = "Freeze: 0 | Knock: 0"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="PowerPicker" type="PanelContainer" parent="."]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -100.0
|
||||
offset_top = -50.0
|
||||
offset_right = 100.0
|
||||
offset_bottom = 50.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="HBoxContainer" type="HBoxContainer" parent="PowerPicker"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="FreezeBtn" type="Button" parent="PowerPicker/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Freeze"
|
||||
|
||||
[node name="KnockBtn" type="Button" parent="PowerPicker/HBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Knock"
|
||||
|
||||
[node name="PlacementPanel" type="PanelContainer" parent="."]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -200.0
|
||||
offset_top = -150.0
|
||||
offset_right = 200.0
|
||||
offset_bottom = 150.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="PlacementPanel"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Label" type="Label" parent="PlacementPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
text = "Match Placements"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="List" type="VBoxContainer" parent="PlacementPanel/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[connection signal="pressed" from="PowerPicker/HBoxContainer/FreezeBtn" to="." method="_on_freeze_btn_pressed"]
|
||||
[connection signal="pressed" from="PowerPicker/HBoxContainer/KnockBtn" to="." method="_on_knock_btn_pressed"]
|
||||
@@ -134,6 +134,56 @@ func _normalize_tile(tile: int) -> int:
|
||||
return tile - 4 # 11->7, 12->8, etc.
|
||||
return tile
|
||||
|
||||
# =============================================================================
|
||||
# Mekton Bulls mode helpers
|
||||
# =============================================================================
|
||||
|
||||
func is_mekton_bulls_mode() -> bool:
|
||||
return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.MEKTON_BULLS)
|
||||
|
||||
func _get_mekton_bulls_manager() -> Node:
|
||||
if gauntlet_manager_override and is_instance_valid(gauntlet_manager_override):
|
||||
return gauntlet_manager_override
|
||||
|
||||
var current = actor
|
||||
while current != null:
|
||||
var bm = current.get_node_or_null("MektonBullsManager")
|
||||
if bm: return bm
|
||||
current = current.get_parent()
|
||||
|
||||
var root = actor.get_tree().root
|
||||
var main = root.get_node_or_null("Main")
|
||||
if main:
|
||||
return main.get_node_or_null("MektonBullsManager")
|
||||
return null
|
||||
|
||||
func _get_active_bulls() -> Array:
|
||||
return actor.get_tree().get_nodes_in_group("MektonBulls")
|
||||
|
||||
func _is_cell_unsafe_in_mekton_bulls(pos: Vector2i) -> bool:
|
||||
"""Cell is unsafe if it's WATER, or if it's on the boundary (soon to be flooded)."""
|
||||
if not is_mekton_bulls_mode(): return false
|
||||
var bm = _get_mekton_bulls_manager()
|
||||
if not bm: return false
|
||||
|
||||
# Check if water
|
||||
var tile = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
||||
if tile == 24: # TILE_WATER
|
||||
return true
|
||||
|
||||
if bm.has_method("_is_boundary") and bm._is_boundary(pos):
|
||||
return true
|
||||
|
||||
# Bull proximity
|
||||
var bulls = _get_active_bulls()
|
||||
for b in bulls:
|
||||
var b_pos = enhanced_gridmap.local_to_map(b.position)
|
||||
# If cell is adjacent to the bull, it's unsafe.
|
||||
if abs(b_pos.x - pos.x) <= 1 and abs(b_pos.z - pos.y) <= 1:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
# =============================================================================
|
||||
# Goal Analysis
|
||||
# =============================================================================
|
||||
@@ -345,14 +395,28 @@ func find_best_tile_to_grab() -> Dictionary:
|
||||
|
||||
func find_nearest_tile_of_type(tile_types: Array) -> Vector2i:
|
||||
"""Find nearest tile matching any type in array using optimized spiral search."""
|
||||
var current_pos = actor.current_position
|
||||
|
||||
if not enhanced_gridmap:
|
||||
return Vector2i(-1, -1)
|
||||
|
||||
# Optimization: Start check at simple radius
|
||||
# If we find something in the spiral, it is guaranteed to be one of the nearest (by Chebyshev distance logic broadly, or just good enough)
|
||||
if is_mekton_bulls_mode():
|
||||
# Return the nearest uncollected tile from our blueprint
|
||||
var bm = _get_mekton_bulls_manager()
|
||||
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
if bm and pid != null and bm.player_blueprints.has(pid):
|
||||
var bp = bm.player_blueprints[pid]
|
||||
var best_tile = Vector2i(-1, -1)
|
||||
var best_dist = INF
|
||||
for c in bp.cells:
|
||||
if enhanced_gridmap.get_cell_item(Vector3i(c.x, 0, c.y)) == bp.color:
|
||||
var dist = actor.current_position.distance_to(c)
|
||||
if dist < best_dist and _is_valid_move_target(c, true):
|
||||
best_dist = dist
|
||||
best_tile = c
|
||||
if best_tile != Vector2i(-1, -1):
|
||||
return best_tile
|
||||
|
||||
var current_pos = actor.current_position
|
||||
# Optimization: Start check at simple radius
|
||||
var max_radius = 25 # Limit search range to prevent full map scans on huge maps
|
||||
if OS.has_feature("mobile"):
|
||||
max_radius = 15 # Stricter limit on mobile
|
||||
@@ -438,10 +502,43 @@ func find_nearest_roaming_tekton() -> Node3D:
|
||||
# Movement Strategy
|
||||
# =============================================================================
|
||||
|
||||
func _should_use_freeze() -> bool:
|
||||
if not is_mekton_bulls_mode(): return false
|
||||
var bm = _get_mekton_bulls_manager()
|
||||
if not bm: return false
|
||||
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
if not bm.player_powers.has(pid) or bm.player_powers[pid]["FREEZE"] <= 0: return false
|
||||
|
||||
var bulls = _get_active_bulls()
|
||||
var bot_pos = enhanced_gridmap.local_to_map(actor.position)
|
||||
for b in bulls:
|
||||
var b_pos = enhanced_gridmap.local_to_map(b.position)
|
||||
if abs(bot_pos.x - b_pos.x) <= 3 and abs(bot_pos.z - b_pos.y) <= 3:
|
||||
return true
|
||||
return false
|
||||
|
||||
func find_optimal_move_target() -> Vector2i:
|
||||
"""Calculate the best position to move towards."""
|
||||
"""Core decision logic. Evaluates sabotaging vs making progress."""
|
||||
var main = actor.get_tree().get_root().get_node_or_null("Main")
|
||||
var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
||||
if is_mekton_bulls_mode():
|
||||
# In Mekton Bulls, use powers if viable.
|
||||
if _should_use_freeze():
|
||||
var bm = _get_mekton_bulls_manager()
|
||||
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
bm.try_use_freeze.rpc_id(1) # Try emitting to server
|
||||
|
||||
# Knock another nearby player
|
||||
var mb_pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||
var mb_bm = _get_mekton_bulls_manager()
|
||||
if mb_bm and mb_bm.player_powers.has(mb_pid) and mb_bm.player_powers[mb_pid]["KNOCK"] > 0:
|
||||
var opps = _get_opponents()
|
||||
for op in opps:
|
||||
var dist = actor.position.distance_to(op.position)
|
||||
if dist < enhanced_gridmap.cell_size.x * 2.0:
|
||||
mb_bm.try_use_knock.rpc_id(1, op.name.to_int(), actor.position.direction_to(op.position).normalized())
|
||||
break
|
||||
|
||||
var is_sng = LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
||||
var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null
|
||||
var time_left = gc_manager.get_global_time_remaining() if gc_manager else 999.0
|
||||
var is_match_running = gc_manager.is_match_running() if gc_manager else false
|
||||
@@ -602,6 +699,11 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool:
|
||||
if not enhanced_gridmap or not enhanced_gridmap.is_position_valid(pos):
|
||||
return false
|
||||
|
||||
if is_mekton_bulls_mode():
|
||||
# Do not move into WATER or the boundary
|
||||
if _is_cell_unsafe_in_mekton_bulls(pos):
|
||||
return false
|
||||
|
||||
# Check Floor 0 (Ground/Walls)
|
||||
var floor_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
||||
if floor_item == -1 or floor_item in enhanced_gridmap.non_walkable_items:
|
||||
|
||||
@@ -5,7 +5,8 @@ enum Mode {
|
||||
FREEMODE = 0,
|
||||
STOP_N_GO = 1,
|
||||
TEKTON_DOORS = 2,
|
||||
GAUNTLET = 3
|
||||
GAUNTLET = 3,
|
||||
MEKTON_BULLS = 4
|
||||
}
|
||||
|
||||
static func from_string(mode: String) -> Mode:
|
||||
@@ -18,6 +19,8 @@ static func from_string(mode: String) -> Mode:
|
||||
return Mode.TEKTON_DOORS
|
||||
"Candy Pump Survival":
|
||||
return Mode.GAUNTLET
|
||||
"Mekton Bulls":
|
||||
return Mode.MEKTON_BULLS
|
||||
_:
|
||||
return Mode.FREEMODE
|
||||
|
||||
@@ -31,11 +34,13 @@ static func mode_to_string(mode: Mode) -> String:
|
||||
return "Tekton Doors"
|
||||
Mode.GAUNTLET:
|
||||
return "Candy Pump Survival"
|
||||
Mode.MEKTON_BULLS:
|
||||
return "Mekton Bulls"
|
||||
_:
|
||||
return "Freemode"
|
||||
|
||||
static func is_restricted(mode: Mode) -> bool:
|
||||
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET
|
||||
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET or mode == Mode.MEKTON_BULLS
|
||||
|
||||
static func get_all_modes() -> Array[String]:
|
||||
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"]
|
||||
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival", "Mekton Bulls"]
|
||||
|
||||
@@ -36,6 +36,11 @@ signal gauntlet_round_duration_changed(duration: int)
|
||||
signal gauntlet_growth_interval_changed(interval: float)
|
||||
signal gauntlet_cells_per_tick_changed(cells: Dictionary)
|
||||
|
||||
# Mekton Bulls settings signals
|
||||
signal mekton_bulls_round_duration_changed(duration: int)
|
||||
signal mekton_bulls_phase_interval_changed(interval: int)
|
||||
signal mekton_bulls_points_changed(min_pts: int, max_pts: int)
|
||||
|
||||
# Room data structure
|
||||
var current_room: Dictionary = {}
|
||||
var players_in_room: Array = [] # [{id, name, is_ready}]
|
||||
@@ -88,13 +93,19 @@ var gauntlet_cells_per_tick: Dictionary = {
|
||||
"phase3": [8, 10],
|
||||
}
|
||||
|
||||
# Mekton Bulls settings
|
||||
var mekton_bulls_round_duration: int = 120
|
||||
var mekton_bulls_phase_interval: int = 30
|
||||
var mekton_bulls_min_points: int = 100
|
||||
var mekton_bulls_max_points: int = 1000
|
||||
|
||||
# Rematch tracking
|
||||
var rematch_votes: Array = [] # [player_id, ...]
|
||||
|
||||
# Character and area selection
|
||||
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
|
||||
var available_areas: Array[String] = []
|
||||
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump Survival"]
|
||||
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival", "Mekton Bulls"]
|
||||
var selected_area: String = "Freemode Arena" # Host-controlled
|
||||
var game_mode: String = "Freemode" # Host-controlled
|
||||
var local_character_index: int = 0 # Local player's character index
|
||||
@@ -149,8 +160,12 @@ func _update_available_areas(mode: String) -> void:
|
||||
available_areas = ["Freemode Arena", "Classic", "Colloseum"]
|
||||
"Stop n Go":
|
||||
available_areas = ["Stop N Go Arena"]
|
||||
"Tekton Doors":
|
||||
available_areas = ["Tekton Doors Area"]
|
||||
"Candy Pump Survival":
|
||||
available_areas = ["Gauntlet Arena"]
|
||||
"Mekton Bulls":
|
||||
available_areas = ["Mekton Bulls Arena"]
|
||||
_:
|
||||
available_areas = ["Classic"]
|
||||
|
||||
@@ -584,6 +599,39 @@ func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void:
|
||||
gauntlet_cells_per_tick = cells
|
||||
emit_signal("gauntlet_cells_per_tick_changed", cells)
|
||||
|
||||
# =============================================================================
|
||||
# Mekton Bulls Settings
|
||||
# =============================================================================
|
||||
|
||||
func set_mekton_bulls_round_duration(duration: int) -> void:
|
||||
mekton_bulls_round_duration = duration
|
||||
if is_host: rpc("sync_mekton_bulls_round_duration", duration)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_mekton_bulls_round_duration(duration: int) -> void:
|
||||
mekton_bulls_round_duration = duration
|
||||
emit_signal("mekton_bulls_round_duration_changed", duration)
|
||||
|
||||
func set_mekton_bulls_phase_interval(interval: int) -> void:
|
||||
mekton_bulls_phase_interval = interval
|
||||
if is_host: rpc("sync_mekton_bulls_phase_interval", interval)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_mekton_bulls_phase_interval(interval: int) -> void:
|
||||
mekton_bulls_phase_interval = interval
|
||||
emit_signal("mekton_bulls_phase_interval_changed", interval)
|
||||
|
||||
func set_mekton_bulls_points(min_pts: int, max_pts: int) -> void:
|
||||
mekton_bulls_min_points = min_pts
|
||||
mekton_bulls_max_points = max_pts
|
||||
if is_host: rpc("sync_mekton_bulls_points", min_pts, max_pts)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_mekton_bulls_points(min_pts: int, max_pts: int) -> void:
|
||||
mekton_bulls_min_points = min_pts
|
||||
mekton_bulls_max_points = max_pts
|
||||
emit_signal("mekton_bulls_points_changed", min_pts, max_pts)
|
||||
|
||||
# =============================================================================
|
||||
# Character Selection
|
||||
# =============================================================================
|
||||
@@ -792,6 +840,10 @@ func start_game(force: bool = false) -> void:
|
||||
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
|
||||
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
||||
rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
||||
# Sync mekton bulls
|
||||
rpc("sync_mekton_bulls_round_duration", mekton_bulls_round_duration)
|
||||
rpc("sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval)
|
||||
rpc("sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points)
|
||||
# Sync game mode
|
||||
rpc("sync_game_mode", game_mode)
|
||||
|
||||
@@ -870,6 +922,9 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
|
||||
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration)
|
||||
rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
||||
rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
||||
rpc_id(requester_id, "sync_mekton_bulls_round_duration", mekton_bulls_round_duration)
|
||||
rpc_id(requester_id, "sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval)
|
||||
rpc_id(requester_id, "sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points)
|
||||
rpc_id(requester_id, "sync_game_mode", game_mode)
|
||||
rpc_id(requester_id, "sync_area", selected_area)
|
||||
|
||||
@@ -1021,3 +1076,7 @@ func reset() -> void:
|
||||
doors_swap_time = 15
|
||||
doors_refresh_time = 25
|
||||
doors_required_goals = 8
|
||||
mekton_bulls_round_duration = 120
|
||||
mekton_bulls_phase_interval = 30
|
||||
mekton_bulls_min_points = 100
|
||||
mekton_bulls_max_points = 1000
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
extends Node
|
||||
class_name MektonBullsManager
|
||||
|
||||
class Blueprint3x3 extends RefCounted:
|
||||
var anchor: Vector2i
|
||||
var color: int
|
||||
var cells: Array[Vector2i] = []
|
||||
var progress: int = 0
|
||||
|
||||
signal phase_changed(phase_index: int)
|
||||
signal player_eliminated(player_id: int)
|
||||
|
||||
# Nodes
|
||||
var main_scene: Node
|
||||
var gridmap: Node
|
||||
|
||||
# Phase State
|
||||
var current_phase: int = 1
|
||||
var arena_size: Vector2i = Vector2i(20, 20)
|
||||
|
||||
var round_duration: float = 120.0
|
||||
var phase_interval: float = 30.0
|
||||
var round_timer: float = 120.0
|
||||
var phase_timer: float = 30.0
|
||||
|
||||
signal time_remaining_changed(remaining: float)
|
||||
|
||||
var bull_node: Node3D = null
|
||||
const BULL_SCENE = preload("res://scenes/npcs/mekton_bull.tscn")
|
||||
|
||||
enum CellState {
|
||||
SAFE,
|
||||
WATER,
|
||||
BLOCKED
|
||||
}
|
||||
var arena_cells: Dictionary = {}
|
||||
|
||||
var is_active: bool = false
|
||||
var flood_cooldown: float = 0.0
|
||||
|
||||
|
||||
var player_blueprints: Dictionary = {} # { player_id: Blueprint3x3 }
|
||||
var player_powers: Dictionary = {} # { player_id: { "FREEZE": 0, "KNOCK": 0 } }
|
||||
|
||||
var player_cooldowns: Dictionary = {} # { player_id: float }
|
||||
enum PowerType { FREEZE, KNOCK }
|
||||
|
||||
# Placement Tracking
|
||||
var player_placement: Dictionary = {} # { pid: placement_rank } 1=first out
|
||||
var elimination_order: Array = [] # List of pids
|
||||
|
||||
var candy_tick_timer: float = 0.0
|
||||
const GOAL_COLORS = [7, 8, 9, 10]
|
||||
|
||||
|
||||
const TILE_WALKABLE: int = 0
|
||||
const TILE_WATER: int = 24 # Water tile
|
||||
const TILE_OBSTACLE: int = 4 # Wall/obstacle
|
||||
|
||||
func initialize(main: Node, grid: Node) -> void:
|
||||
main_scene = main
|
||||
gridmap = grid
|
||||
|
||||
|
||||
func start_game_mode() -> void:
|
||||
print("[MektonBulls] Starting Mekton Bulls game mode...")
|
||||
|
||||
round_duration = float(LobbyManager.mekton_bulls_round_duration)
|
||||
phase_interval = float(LobbyManager.mekton_bulls_phase_interval)
|
||||
round_timer = round_duration
|
||||
phase_timer = phase_interval
|
||||
|
||||
is_active = true
|
||||
_setup_arena()
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||
_spawn_bull()
|
||||
_assign_initial_blueprints()
|
||||
|
||||
|
||||
func _ready():
|
||||
player_eliminated.connect(_on_player_eliminated)
|
||||
|
||||
func _setup_arena() -> void:
|
||||
if not gridmap:
|
||||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||
if not gridmap:
|
||||
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||
if not gridmap:
|
||||
push_error("[MektonBulls] No EnhancedGridMap found!")
|
||||
return
|
||||
|
||||
print("[MektonBulls] Setting up Phase 1 Arena...")
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||
rpc("sync_arena_setup")
|
||||
_apply_arena_setup()
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func sync_arena_setup() -> void:
|
||||
_apply_arena_setup()
|
||||
|
||||
func _apply_arena_setup() -> void:
|
||||
if not gridmap:
|
||||
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||
if not gridmap: return
|
||||
|
||||
current_phase = 1
|
||||
arena_size = arena_size_for_phase(current_phase)
|
||||
|
||||
gridmap.set("columns", 20)
|
||||
gridmap.set("rows", 20)
|
||||
gridmap.clear()
|
||||
|
||||
# Initial build 20x20
|
||||
for x in range(20):
|
||||
for z in range(20):
|
||||
var pos = Vector2i(x, z)
|
||||
|
||||
if _is_boundary(pos):
|
||||
# Perimeter
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
arena_cells[pos] = CellState.SAFE
|
||||
else:
|
||||
# Walkable floor
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
arena_cells[pos] = CellState.SAFE
|
||||
|
||||
gridmap.set_cell_item(Vector3i(x, 2, z), -1)
|
||||
|
||||
gridmap.diagonal_movement = true
|
||||
gridmap.update_grid_data()
|
||||
gridmap.initialize_astar()
|
||||
|
||||
_reposition_npc()
|
||||
_validate_blueprint_after_shrink()
|
||||
|
||||
|
||||
func arena_size_for_phase(phase: int) -> Vector2i:
|
||||
match phase:
|
||||
1: return Vector2i(20, 20)
|
||||
2: return Vector2i(19, 19)
|
||||
3: return Vector2i(18, 18)
|
||||
4: return Vector2i(17, 17)
|
||||
_: return Vector2i(17, 17) # Final phase
|
||||
|
||||
func _is_boundary(pos: Vector2i) -> bool:
|
||||
var bounds = arena_size_for_phase(current_phase)
|
||||
# Grid shrinks symmetrically towards (0,0) by bounds.
|
||||
return pos.x == 0 or pos.x == bounds.x - 1 or pos.y == 0 or pos.y == bounds.y - 1
|
||||
|
||||
func _shrink_arena() -> void:
|
||||
if current_phase >= 4:
|
||||
return
|
||||
|
||||
current_phase += 1
|
||||
var new_bounds = arena_size_for_phase(current_phase)
|
||||
var old_bounds = arena_size_for_phase(current_phase - 1)
|
||||
|
||||
print("[MektonBulls] Shrinking arena to Phase %d (%dx%d)" % [current_phase, new_bounds.x, new_bounds.y])
|
||||
|
||||
# Apply locally first
|
||||
_apply_ring_shrink(old_bounds, new_bounds)
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||
rpc("sync_shrink_arena", current_phase)
|
||||
|
||||
emit_signal("phase_changed", current_phase)
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func sync_shrink_arena(new_phase: int) -> void:
|
||||
var old_bounds = arena_size_for_phase(current_phase)
|
||||
current_phase = new_phase
|
||||
var new_bounds = arena_size_for_phase(current_phase)
|
||||
_apply_ring_shrink(old_bounds, new_bounds)
|
||||
emit_signal("phase_changed", current_phase)
|
||||
|
||||
func _apply_ring_shrink(old_bounds: Vector2i, new_bounds: Vector2i) -> void:
|
||||
for x in range(20):
|
||||
for z in range(20):
|
||||
var pos = Vector2i(x, z)
|
||||
|
||||
if x >= new_bounds.x or z >= new_bounds.y:
|
||||
# It is now outside the new bounds -> WATER
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER)
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
arena_cells[pos] = CellState.WATER
|
||||
elif _is_boundary(pos):
|
||||
# New boundary -> No wall, just the edge of SAFE
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
arena_cells[pos] = CellState.SAFE
|
||||
|
||||
gridmap.update_grid_data()
|
||||
gridmap.initialize_astar()
|
||||
|
||||
_reposition_npc()
|
||||
_validate_blueprint_after_shrink()
|
||||
|
||||
|
||||
func _reposition_npc() -> void:
|
||||
# Reposition Mekton Bull to the center of the current bounds
|
||||
if not bull_node:
|
||||
return
|
||||
|
||||
var bounds = arena_size_for_phase(current_phase)
|
||||
# Center in world units
|
||||
var cx = (bounds.x / 2.0) * gridmap.cell_size.x
|
||||
var cz = (bounds.y / 2.0) * gridmap.cell_size.z
|
||||
|
||||
bull_node.position = Vector3(cx, 0, cz)
|
||||
|
||||
func get_spawn_points(player_count: int) -> Array[Vector2i]:
|
||||
var spawns: Array[Vector2i] = []
|
||||
var bounds = arena_size_for_phase(current_phase)
|
||||
# 4 players: inner corners
|
||||
spawns.append(Vector2i(1, 1))
|
||||
if bounds.x > 2: spawns.append(Vector2i(bounds.x - 2, 1))
|
||||
if bounds.y > 2: spawns.append(Vector2i(1, bounds.y - 2))
|
||||
if bounds.x > 2 and bounds.y > 2: spawns.append(Vector2i(bounds.x - 2, bounds.y - 2))
|
||||
|
||||
if player_count > 4:
|
||||
if bounds.x > 4: spawns.append(Vector2i(bounds.x / 2, 1))
|
||||
if bounds.x > 4 and bounds.y > 2: spawns.append(Vector2i(bounds.x / 2, bounds.y - 2))
|
||||
if player_count > 6:
|
||||
if bounds.y > 4: spawns.append(Vector2i(1, bounds.y / 2))
|
||||
if bounds.x > 2 and bounds.y > 4: spawns.append(Vector2i(bounds.x - 2, bounds.y / 2))
|
||||
|
||||
return spawns.slice(0, player_count)
|
||||
|
||||
func _spawn_bull() -> void:
|
||||
if bull_node == null:
|
||||
bull_node = BULL_SCENE.instantiate()
|
||||
bull_node.name = "MektonBull"
|
||||
# Use multiplayer spawner if appropriate, else just add child
|
||||
main_scene.add_child(bull_node)
|
||||
|
||||
var bounds = arena_size_for_phase(current_phase)
|
||||
var cx = (bounds.x / 2.0) * gridmap.cell_size.x
|
||||
var cz = (bounds.y / 2.0) * gridmap.cell_size.z
|
||||
var start_pos = Vector3(cx, 0, cz)
|
||||
|
||||
if bull_node.has_method("initialize"):
|
||||
bull_node.initialize(self, gridmap, start_pos)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null: return
|
||||
if not is_active: return
|
||||
|
||||
if multiplayer.is_server():
|
||||
round_timer -= delta
|
||||
time_remaining_changed.emit(round_timer)
|
||||
rpc("sync_time_remaining", round_timer)
|
||||
|
||||
if round_timer <= 0:
|
||||
_on_round_time_expired()
|
||||
|
||||
phase_timer -= delta
|
||||
if phase_timer <= 0:
|
||||
phase_timer = phase_interval
|
||||
_shrink_arena()
|
||||
|
||||
if flood_cooldown > 0:
|
||||
|
||||
flood_cooldown -= delta
|
||||
|
||||
if multiplayer.is_server():
|
||||
if flood_cooldown <= 0 and bull_node:
|
||||
var bull_pos_3d = gridmap.local_to_map(bull_node.position)
|
||||
var bull_pos_2d = Vector2i(bull_pos_3d.x, bull_pos_3d.z)
|
||||
if _is_boundary(bull_pos_2d):
|
||||
_trigger_water_flood()
|
||||
|
||||
|
||||
candy_tick_timer -= delta
|
||||
if candy_tick_timer <= 0:
|
||||
candy_tick_timer = 0.1
|
||||
_process_candy_tick()
|
||||
|
||||
for pid in player_cooldowns.keys():
|
||||
if player_cooldowns[pid] > 0:
|
||||
player_cooldowns[pid] -= delta
|
||||
|
||||
|
||||
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_time_remaining(time: float) -> void:
|
||||
round_timer = time
|
||||
time_remaining_changed.emit(round_timer)
|
||||
|
||||
func _on_round_time_expired() -> void:
|
||||
is_active = false
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for p in players:
|
||||
if p.is_in_group("Players"):
|
||||
var pid = p.name.to_int()
|
||||
if not elimination_order.has(pid):
|
||||
elimination_order.append(pid)
|
||||
player_placement[pid] = elimination_order.size()
|
||||
|
||||
_end_round()
|
||||
|
||||
func _trigger_water_flood() -> void:
|
||||
flood_cooldown = 3.0
|
||||
print("[MektonBulls] Bull is on boundary! Flooding outer ring!")
|
||||
|
||||
# Network sync
|
||||
rpc("sync_water_flood")
|
||||
_apply_water_flood()
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_water_flood() -> void:
|
||||
_apply_water_flood()
|
||||
|
||||
func _apply_water_flood() -> void:
|
||||
# 1. Eliminate any player whose cell is currently _is_boundary
|
||||
# (which is the outermost ring of the current arena_size)
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
var bounds = arena_size_for_phase(current_phase)
|
||||
|
||||
for p in players:
|
||||
if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated():
|
||||
var p_cell_3d = gridmap.local_to_map(p.position)
|
||||
var p_cell_2d = Vector2i(p_cell_3d.x, p_cell_3d.z)
|
||||
if _is_boundary(p_cell_2d):
|
||||
if multiplayer.is_server():
|
||||
if p.has_method("eliminate"):
|
||||
p.eliminate()
|
||||
else:
|
||||
player_eliminated.emit(p.name.to_int())
|
||||
|
||||
# 2. Play VFX / SFX (placeholder print if no actual scene yet)
|
||||
if main_scene and main_scene.get("vfx_manager") and main_scene.vfx_manager.has_method("play_splash"):
|
||||
var cx = (bounds.x / 2.0) * gridmap.cell_size.x
|
||||
var cz = (bounds.y / 2.0) * gridmap.cell_size.z
|
||||
main_scene.vfx_manager.play_splash(Vector3(cx, 0, cz))
|
||||
|
||||
if has_node("/root/SfxManager"):
|
||||
get_node("/root/SfxManager").play_rpc("water_flood")
|
||||
else:
|
||||
print("[MektonBulls] WHOOSH! Water flood VFX played.")
|
||||
|
||||
# 3. Set those cells to TILE_WATER
|
||||
for x in range(20):
|
||||
for z in range(20):
|
||||
var pos = Vector2i(x, z)
|
||||
if _is_boundary(pos):
|
||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER)
|
||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||
arena_cells[pos] = CellState.WATER
|
||||
|
||||
gridmap.update_grid_data()
|
||||
|
||||
func _assign_initial_blueprints() -> void:
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for p in players:
|
||||
if p.is_in_group("Players"):
|
||||
_reroll_blueprint(p.name.to_int())
|
||||
|
||||
func _reroll_blueprint(player_id: int) -> void:
|
||||
var bp = Blueprint3x3.new()
|
||||
bp.color = GOAL_COLORS[randi() % GOAL_COLORS.size()]
|
||||
bp.anchor = _get_valid_3x3_anchor()
|
||||
|
||||
# Generate 3x3 cells
|
||||
for dx in range(3):
|
||||
for dz in range(3):
|
||||
var cpos = Vector2i(bp.anchor.x + dx, bp.anchor.y + dz)
|
||||
bp.cells.append(cpos)
|
||||
# Paint it
|
||||
gridmap.set_cell_item(Vector3i(cpos.x, 0, cpos.y), bp.color)
|
||||
|
||||
player_blueprints[player_id] = bp
|
||||
gridmap.update_grid_data()
|
||||
rpc("sync_painted_cells", bp.cells, bp.color)
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_painted_cells(cells: Array, color: int) -> void:
|
||||
for c in cells:
|
||||
gridmap.set_cell_item(Vector3i(c.x, 0, c.y), color)
|
||||
|
||||
func _get_valid_3x3_anchor() -> Vector2i:
|
||||
var bounds = arena_size_for_phase(current_phase)
|
||||
# boundary is 0 and bounds-1
|
||||
# inner is 1 to bounds-2
|
||||
# 3x3 needs x to x+2 -> x+2 <= bounds-2 -> x <= bounds-4
|
||||
var max_x = bounds.x - 4
|
||||
var max_y = bounds.y - 4
|
||||
|
||||
if max_x < 1: max_x = 1
|
||||
if max_y < 1: max_y = 1
|
||||
|
||||
return Vector2i(randi_range(1, max_x), randi_range(1, max_y))
|
||||
|
||||
func _process_candy_tick() -> void:
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for p in players:
|
||||
if not p.is_in_group("Players") or (p.has_method("is_eliminated") and p.is_eliminated()):
|
||||
continue
|
||||
|
||||
var pid = p.name.to_int()
|
||||
if not player_blueprints.has(pid):
|
||||
continue
|
||||
|
||||
var bp: Blueprint3x3 = player_blueprints[pid]
|
||||
var pos_3d = gridmap.local_to_map(p.position)
|
||||
var pos = Vector2i(pos_3d.x, pos_3d.z)
|
||||
|
||||
if pos in bp.cells:
|
||||
var item = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
||||
if item == bp.color:
|
||||
# Pickup!
|
||||
bp.progress += 1
|
||||
# Remove color local
|
||||
gridmap.set_cell_item(Vector3i(pos.x, 0, pos.y), TILE_WALKABLE)
|
||||
rpc("sync_painted_cells", [pos], TILE_WALKABLE)
|
||||
|
||||
if bp.progress >= 9:
|
||||
_grant_power(pid)
|
||||
_reroll_blueprint(pid)
|
||||
|
||||
|
||||
func _grant_power(player_id: int) -> void:
|
||||
print("[MektonBulls] Blueprint complete! Prompting power picker for ", player_id)
|
||||
# Safely call rpc_id
|
||||
if multiplayer.has_multiplayer_peer() and multiplayer.get_peers().has(player_id) or player_id == multiplayer.get_unique_id():
|
||||
rpc_id(player_id, "prompt_power_picker")
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func prompt_power_picker() -> void:
|
||||
if hud_node and hud_node.has_method("show_power_picker"):
|
||||
hud_node.show_power_picker()
|
||||
|
||||
var hud_node: Node = null
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_score_completed(placements: Dictionary) -> void:
|
||||
if hud_node and hud_node.has_method("show_placement"):
|
||||
hud_node.show_placement(placements)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func try_pick_power(power_type_str: String) -> void:
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
if not player_powers.has(sender):
|
||||
player_powers[sender] = { "FREEZE": 0, "KNOCK": 0 }
|
||||
|
||||
if power_type_str == "FREEZE" or power_type_str == "KNOCK":
|
||||
player_powers[sender][power_type_str] += 1
|
||||
rpc("sync_player_powers", sender, player_powers[sender])
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_player_powers(pid: int, powers: Dictionary) -> void:
|
||||
player_powers[pid] = powers
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func try_use_freeze() -> void:
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
if player_cooldowns.get(sender, 0.0) > 0: return
|
||||
if not player_powers.has(sender) or player_powers[sender]["FREEZE"] <= 0: return
|
||||
|
||||
player_powers[sender]["FREEZE"] -= 1
|
||||
player_cooldowns[sender] = 1.0
|
||||
rpc("sync_player_powers", sender, player_powers[sender])
|
||||
|
||||
if bull_node and bull_node.has_method("apply_slow"):
|
||||
bull_node.apply_slow(3.0)
|
||||
if has_node("/root/SfxManager"):
|
||||
get_node("/root/SfxManager").play_rpc("freeze_burst")
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func try_use_knock(target_id: int, dir: Vector3) -> void:
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
if player_cooldowns.get(sender, 0.0) > 0: return
|
||||
if not player_powers.has(sender) or player_powers[sender]["KNOCK"] <= 0: return
|
||||
|
||||
player_powers[sender]["KNOCK"] -= 1
|
||||
player_cooldowns[sender] = 1.0
|
||||
rpc("sync_player_powers", sender, player_powers[sender])
|
||||
|
||||
rpc("sync_apply_knock", target_id, dir)
|
||||
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func sync_apply_knock(target_id: int, dir: Vector3) -> void:
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
for p in players:
|
||||
if p.name == str(target_id):
|
||||
# Translate position 1 cell in dir
|
||||
var move_dist = gridmap.cell_size.x
|
||||
# Normalize to 4 directions
|
||||
var dx = 0
|
||||
var dz = 0
|
||||
if abs(dir.x) > abs(dir.z):
|
||||
dx = sign(dir.x)
|
||||
else:
|
||||
dz = sign(dir.z)
|
||||
|
||||
p.position += Vector3(dx * move_dist, 0, dz * move_dist)
|
||||
|
||||
# Play a smack sound if available
|
||||
if has_node("/root/SfxManager"):
|
||||
get_node("/root/SfxManager").play_rpc("knock_burst")
|
||||
elif main_scene and main_scene.get("audio_manager") and main_scene.audio_manager.has_method("play_sfx"):
|
||||
main_scene.audio_manager.play_sfx("smack")
|
||||
|
||||
|
||||
func _validate_blueprint_after_shrink() -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
var bounds = arena_size_for_phase(current_phase)
|
||||
|
||||
for pid in player_blueprints.keys():
|
||||
var bp: Blueprint3x3 = player_blueprints[pid]
|
||||
var outside = false
|
||||
for c in bp.cells:
|
||||
if c.x <= 0 or c.x >= bounds.x - 1 or c.y <= 0 or c.y >= bounds.y - 1:
|
||||
outside = true
|
||||
break
|
||||
|
||||
if outside:
|
||||
# Clear old ones
|
||||
rpc("sync_painted_cells", bp.cells, TILE_WALKABLE)
|
||||
_reroll_blueprint(pid)
|
||||
|
||||
func _on_player_eliminated(player_id: int) -> void:
|
||||
if not elimination_order.has(player_id):
|
||||
elimination_order.append(player_id)
|
||||
print("[MektonBulls] Player %d eliminated. Rank: %d" % [player_id, elimination_order.size()])
|
||||
# Placement rank: 1 is first out
|
||||
player_placement[player_id] = elimination_order.size()
|
||||
|
||||
if multiplayer.is_server():
|
||||
# Check if only 1 player remains
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
var alive_count = 0
|
||||
var last_alive: int = -1
|
||||
for p in players:
|
||||
if p.is_in_group("Players"):
|
||||
var pid = p.name.to_int()
|
||||
if not elimination_order.has(pid):
|
||||
alive_count += 1
|
||||
last_alive = pid
|
||||
|
||||
if alive_count <= 1:
|
||||
if last_alive != -1 and not elimination_order.has(last_alive):
|
||||
elimination_order.append(last_alive)
|
||||
player_placement[last_alive] = elimination_order.size()
|
||||
is_active = false
|
||||
_end_round()
|
||||
|
||||
func _end_round() -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
print("[MektonBulls] Round ended. Computing placement scores...")
|
||||
|
||||
var total_players = elimination_order.size()
|
||||
if total_players == 0: return
|
||||
|
||||
var min_pts = LobbyManager.mekton_bulls_min_points
|
||||
var max_pts = LobbyManager.mekton_bulls_max_points
|
||||
|
||||
var scores = {}
|
||||
for i in range(total_players):
|
||||
var pid = elimination_order[i]
|
||||
var rank = i + 1 # 1 = first out
|
||||
|
||||
var pts = min_pts
|
||||
if total_players > 1:
|
||||
var t = float(rank - 1) / float(total_players - 1)
|
||||
pts = int(lerp(float(min_pts), float(max_pts), t))
|
||||
|
||||
scores[pid] = pts
|
||||
print("[MektonBulls] Player %d finished rank %d -> %d pts" % [pid, rank, pts])
|
||||
|
||||
# In the real game, we'd sync this to the scores manager
|
||||
# main_scene.rpc("sync_score_updated", scores) - wait, is there a direct scoreboard in Mekton Bulls?
|
||||
# Typically GoalsCycleManager tracks scores, or main.gd
|
||||
if main_scene and main_scene.get("goals_cycle_manager"):
|
||||
for pid in scores.keys():
|
||||
main_scene.goals_cycle_manager.add_score(pid, scores[pid])
|
||||
|
||||
rpc("sync_score_completed", player_placement)
|
||||
|
||||
# End the goal cycle match if it hasn't already
|
||||
if main_scene and main_scene.get("goals_cycle_manager") and main_scene.goals_cycle_manager.is_active:
|
||||
main_scene.goals_cycle_manager.end_match()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnsxsqvj2ea7f
|
||||
@@ -0,0 +1,162 @@
|
||||
extends Area3D
|
||||
|
||||
enum State {
|
||||
ROAM,
|
||||
CHARGE,
|
||||
COOLDOWN
|
||||
}
|
||||
|
||||
var current_state: State = State.ROAM
|
||||
var gridmap: Node
|
||||
var arena_manager: Node
|
||||
|
||||
var move_speed: float = 3.0
|
||||
var charge_speed: float = 12.0
|
||||
var target_pos: Vector3
|
||||
var state_timer: float = 0.0
|
||||
var slow_timer: float = 0.0
|
||||
|
||||
func _ready():
|
||||
add_to_group("MektonBulls")
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func initialize(manager: Node, grid: Node, start_pos: Vector3):
|
||||
arena_manager = manager
|
||||
gridmap = grid
|
||||
position = start_pos
|
||||
_pick_roam_target()
|
||||
|
||||
func apply_slow(duration: float) -> void:
|
||||
slow_timer = duration
|
||||
|
||||
func _physics_process(delta: float):
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
state_timer -= delta
|
||||
if slow_timer > 0:
|
||||
slow_timer -= delta
|
||||
|
||||
match current_state:
|
||||
State.ROAM:
|
||||
_process_roam(delta)
|
||||
if state_timer <= 0:
|
||||
_try_start_charge()
|
||||
State.CHARGE:
|
||||
_process_charge(delta)
|
||||
State.COOLDOWN:
|
||||
if state_timer <= 0:
|
||||
current_state = State.ROAM
|
||||
_pick_roam_target()
|
||||
|
||||
func _process_roam(delta: float):
|
||||
var dist = position.distance_to(target_pos)
|
||||
if dist < 0.1:
|
||||
_pick_roam_target()
|
||||
else:
|
||||
var dir = (target_pos - position).normalized()
|
||||
dir.y = 0
|
||||
if dir.length_squared() > 0:
|
||||
var actual_speed = move_speed
|
||||
if slow_timer > 0: actual_speed *= 0.5
|
||||
position += dir * actual_speed * delta
|
||||
# Face direction implicitly
|
||||
var look_target = position + dir
|
||||
look_target.y = position.y
|
||||
if position.distance_squared_to(look_target) > 0.01:
|
||||
look_at(look_target, Vector3.UP)
|
||||
|
||||
func _pick_roam_target():
|
||||
if not arena_manager: return
|
||||
|
||||
var bounds = arena_manager.arena_size_for_phase(arena_manager.current_phase)
|
||||
# Random walkable position within bounds
|
||||
# We know 0,0 is boundary, bounds.x-1 is boundary
|
||||
var min_x = 1
|
||||
var max_x = bounds.x - 2
|
||||
var min_z = 1
|
||||
var max_z = bounds.y - 2
|
||||
|
||||
if min_x > max_x: max_x = min_x
|
||||
if min_z > max_z: max_z = min_z
|
||||
|
||||
# Avoid exact center (where the static delivery target presumably sits)
|
||||
var cx = int(bounds.x / 2.0)
|
||||
var cz = int(bounds.y / 2.0)
|
||||
|
||||
var rx = cx
|
||||
var rz = cz
|
||||
while rx == cx and rz == cz:
|
||||
rx = randi_range(min_x, max_x)
|
||||
rz = randi_range(min_z, max_z)
|
||||
|
||||
var world_x = rx * gridmap.cell_size.x + gridmap.cell_size.x / 2.0
|
||||
var world_z = rz * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||
|
||||
target_pos = Vector3(world_x, position.y, world_z)
|
||||
state_timer = randf_range(2.0, 4.0)
|
||||
|
||||
func _try_start_charge():
|
||||
# Find closest player
|
||||
var players = get_tree().get_nodes_in_group("Players")
|
||||
var closest_player = null
|
||||
var min_dist = INF
|
||||
|
||||
for p in players:
|
||||
if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated():
|
||||
var d = position.distance_to(p.position)
|
||||
if d < min_dist and d < 15.0: # Range check
|
||||
min_dist = d
|
||||
closest_player = p
|
||||
|
||||
if closest_player:
|
||||
current_state = State.CHARGE
|
||||
target_pos = closest_player.position
|
||||
target_pos.y = position.y
|
||||
state_timer = 2.0 # Max charge duration
|
||||
if has_node("/root/SfxManager"):
|
||||
get_node("/root/SfxManager").play_rpc("bull_charge")
|
||||
else:
|
||||
_pick_roam_target()
|
||||
|
||||
func _process_charge(delta: float):
|
||||
var dir = (target_pos - position).normalized()
|
||||
dir.y = 0
|
||||
var actual_speed = charge_speed
|
||||
if slow_timer > 0: actual_speed *= 0.5
|
||||
position += dir * actual_speed * delta
|
||||
|
||||
var look_target = position + dir
|
||||
look_target.y = position.y
|
||||
if position.distance_squared_to(look_target) > 0.01:
|
||||
look_at(look_target, Vector3.UP)
|
||||
|
||||
var dist = position.distance_to(target_pos)
|
||||
if dist < 0.5 or state_timer <= 0:
|
||||
# Hit destination or timeout
|
||||
current_state = State.COOLDOWN
|
||||
state_timer = 1.5
|
||||
|
||||
func _on_body_entered(body: Node3D):
|
||||
if body.is_in_group("Players") and multiplayer.is_server():
|
||||
# Knock them out
|
||||
if body.has_method("eliminate"):
|
||||
body.eliminate()
|
||||
else:
|
||||
print("[MektonBull] Knocked out player", body.name)
|
||||
# Dispatch via manager
|
||||
if arena_manager and arena_manager.has_signal("player_eliminated"):
|
||||
arena_manager.player_eliminated.emit(body.name.to_int())
|
||||
|
||||
# Polish: SFX + Camera Shake
|
||||
rpc("sync_bull_impact")
|
||||
|
||||
@rpc("authority", "call_local", "unreliable")
|
||||
func sync_bull_impact() -> void:
|
||||
if has_node("/root/SfxManager"):
|
||||
get_node("/root/SfxManager").play("bull_impact")
|
||||
|
||||
var root = get_tree().root
|
||||
var main = root.get_node_or_null("Main")
|
||||
if main and main.get("screen_shake_manager"):
|
||||
main.screen_shake_manager.shake(0.2, 0.5)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ciytpot4av5gw
|
||||
@@ -0,0 +1,57 @@
|
||||
extends Control
|
||||
|
||||
@onready var bull_tracker = $BullTracker
|
||||
@onready var power_picker = $PowerPicker
|
||||
@onready var placement_panel = $PlacementPanel
|
||||
@onready var placement_list = $PlacementPanel/VBoxContainer/List
|
||||
@onready var counters_lbl = $PowerCounters/Label
|
||||
|
||||
var local_pid: int = -1
|
||||
var arena_manager: Node
|
||||
|
||||
func _ready():
|
||||
power_picker.hide()
|
||||
placement_panel.hide()
|
||||
|
||||
func set_local_player(pid: int):
|
||||
local_pid = pid
|
||||
|
||||
func initialize(manager: Node):
|
||||
arena_manager = manager
|
||||
|
||||
func _process(delta: float):
|
||||
# Hide tracker if bull is close/visible, else point to it
|
||||
if not arena_manager: return
|
||||
|
||||
var local_powers = arena_manager.player_powers.get(local_pid, {"FREEZE": 0, "KNOCK": 0})
|
||||
counters_lbl.text = "Freeze: %d | Knock: %d" % [local_powers.get("FREEZE", 0), local_powers.get("KNOCK", 0)]
|
||||
|
||||
func show_power_picker():
|
||||
power_picker.show()
|
||||
|
||||
func _on_freeze_btn_pressed():
|
||||
if arena_manager:
|
||||
arena_manager.rpc_id(1, "try_pick_power", "FREEZE")
|
||||
power_picker.hide()
|
||||
|
||||
func _on_knock_btn_pressed():
|
||||
if arena_manager:
|
||||
arena_manager.rpc_id(1, "try_pick_power", "KNOCK")
|
||||
power_picker.hide()
|
||||
|
||||
func show_placement(scores: Dictionary):
|
||||
placement_panel.show()
|
||||
for child in placement_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var items = []
|
||||
for pid in scores.keys():
|
||||
items.append({"pid": pid, "rank": scores[pid]})
|
||||
|
||||
items.sort_custom(func(a, b): return a.rank < b.rank)
|
||||
|
||||
for item in items:
|
||||
var lbl = Label.new()
|
||||
lbl.text = "Player %s - Rank %s" % [str(item.pid), str(item.rank)]
|
||||
placement_list.add_child(lbl)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://h2uragoekxs1
|
||||
Reference in New Issue
Block a user