Initial Void Bastion 0.20 prototype
This commit is contained in:
commit
7362ca0f02
251 files changed
+29688
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
.godot/
|
||||||
|
server/nakama/.env
|
||||||
|
server/nakama/backups/
|
||||||
|
tools/godot-agent-tools-mcp/node_modules/
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"godot-agent-tools": {
|
||||||
|
"command": "C:\\Users\\Domin\\Documents\\Codex\\tools\\node-v24.19.0-win-x64\\node.exe",
|
||||||
|
"args": [
|
||||||
|
"C:\\Users\\Domin\\Documents\\Codex\\2026-10-02\\ich\\outputs\\void-bastion\\tools\\godot-agent-tools-mcp\\server.mjs"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GODOT_AGENT_HOST": "127.0.0.1",
|
||||||
|
"GODOT_AGENT_PORT": "9920"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
# Grafik-Prompts
|
||||||
|
|
||||||
|
Erzeugt mit dem integrierten OpenAI-Bildgenerator. Die Sprite-Sheets besitzen echte Transparenz.
|
||||||
|
|
||||||
|
## orbital_platform.png
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: stylized-concept
|
||||||
|
Asset type: 2D game battlefield background
|
||||||
|
Primary request: a realistic science-fiction orbital defense platform viewed directly from above, designed as a tower-defense map
|
||||||
|
Scene/backdrop: deep space with subtle blue nebula, distant stars, dark armored station plates forming a broad central combat zone, one clearly readable curved enemy flight corridor from the upper-left edge toward a glowing reactor core on the right
|
||||||
|
Style/medium: polished realistic 2D game environment art, physically plausible hard-surface materials, cinematic but readable
|
||||||
|
Composition/framing: exact top-down orthographic view, 16:9 landscape, gameplay area remains visually uncluttered, route readable without arrows or text
|
||||||
|
Lighting/mood: cold blue space light, restrained cyan reactor glow, high contrast silhouettes
|
||||||
|
Color palette: graphite, gunmetal, navy, cold cyan, small amber hazard lights
|
||||||
|
Constraints: no text, no logos, no watermark, no UI, no ships, no turrets, no perspective tilt
|
||||||
|
```
|
||||||
|
|
||||||
|
## defense_towers.png
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: stylized-concept
|
||||||
|
Asset type: transparent 2D game tower sprite sheet
|
||||||
|
Primary request: three separate realistic science-fiction defensive turrets: rapid kinetic cannon, focused energy laser emitter, heavy missile launcher
|
||||||
|
Subject: each turret is a complete circular-base installation with a distinct readable silhouette, viewed directly from above
|
||||||
|
Style/medium: polished realistic 2D game sprites, hard-surface military spacecraft design, consistent scale and lighting
|
||||||
|
Composition/framing: exact top-down orthographic view, three objects evenly spaced in one horizontal row, generous transparent padding, no overlap
|
||||||
|
Lighting/mood: cold blue rim light with subtle cyan emissive details
|
||||||
|
Color palette: gunmetal, graphite, muted navy, cyan accents, tiny amber details
|
||||||
|
Constraints: genuinely transparent background, no labels, no text, no logos, no watermark, no shadows extending far from objects, no perspective tilt
|
||||||
|
```
|
||||||
|
|
||||||
|
## enemy_ships.png
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: stylized-concept
|
||||||
|
Asset type: transparent 2D game enemy spaceship sprite sheet
|
||||||
|
Primary request: three separate hostile spacecraft: fast interceptor, armored assault frigate, heavy command cruiser
|
||||||
|
Subject: complete ships with increasingly large and armored silhouettes, noses pointing to the right, viewed directly from above
|
||||||
|
Style/medium: polished realistic 2D game sprites, plausible mechanical panels and engines, consistent faction design
|
||||||
|
Composition/framing: exact top-down orthographic view, three objects evenly spaced in one horizontal row, generous transparent padding, no overlap
|
||||||
|
Lighting/mood: cold space lighting, restrained red-orange engine and weapon glow
|
||||||
|
Color palette: dark titanium, worn steel, deep red markings, red-orange emissive details
|
||||||
|
Constraints: genuinely transparent background, no labels, no text, no logos, no watermark, no cropped parts, no perspective tilt
|
||||||
|
```
|
||||||
|
|
||||||
|
## app_icon.png
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: logo-brand
|
||||||
|
Asset type: mobile and desktop game app icon
|
||||||
|
Primary request: original icon for a realistic science-fiction tower-defense game, showing a cyan glowing orbital reactor core protected by three dark gunmetal shield segments while a small red hostile ship approaches
|
||||||
|
Style/medium: polished realistic 2D game icon, hard-surface science fiction, strong readable silhouette at small size
|
||||||
|
Composition/framing: square, centered emblem, close crop, generous safe margin for rounded app-icon masks
|
||||||
|
Lighting/mood: cold cyan reactor glow against deep space, restrained red threat accent
|
||||||
|
Color palette: graphite, navy, cyan, small red-orange accent
|
||||||
|
Constraints: no text, no letters, no logos or trademarks, no watermark, no thin fragile details, no transparent background
|
||||||
|
```
|
||||||
|
|
||||||
|
## orbital_shipyard.png
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: stylized-concept
|
||||||
|
Asset type: 2D tower-defense game map background
|
||||||
|
Primary request: Create a realistic cinematic top-down orbital shipyard map for a science-fiction tower-defense game.
|
||||||
|
Scene/backdrop: Huge rectangular orbital industrial platform floating in deep space, full landscape composition, broad playable deck filling most of the frame.
|
||||||
|
Subject: One single unmistakable enemy roadway begins at the far left edge, travels horizontally, bends downward through the middle, bends back upward, and ends at a bright cyan reactor at the far right edge. The roadway must remain continuously visible, wide, unobstructed, and visually separated from buildable deck areas.
|
||||||
|
Style/medium: realistic high-detail hard-surface science-fiction concept art, orthographic top-down game map, dark steel, subtle cyan and amber lighting.
|
||||||
|
Composition/framing: landscape 16:9, edge-to-edge map, large open construction zones above and below the roadway, no perspective tilt.
|
||||||
|
Constraints: no towers, no ships, no enemies, no characters, no text, no labels, no UI, no watermark; exactly one continuous route; reactor only at route end.
|
||||||
|
```
|
||||||
|
|
||||||
|
## asteroid_refinery.png
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: stylized-concept
|
||||||
|
Asset type: 2D tower-defense game map background
|
||||||
|
Primary request: Create a realistic cinematic top-down asteroid refinery map for a science-fiction tower-defense game.
|
||||||
|
Scene/backdrop: Massive mining and refinery station built across a rust-red asteroid field, full landscape composition, broad playable metal deck filling most of the frame.
|
||||||
|
Subject: One single unmistakable armored convoy roadway begins at the upper-left edge, curves down around a central circular refinery pit, continues along the lower middle, then turns upward and ends at a bright orange-white reactor gate on the far right edge. The roadway must be continuously visible, wide, unobstructed, and clearly distinct from buildable deck areas.
|
||||||
|
Style/medium: realistic high-detail hard-surface science-fiction concept art, orthographic top-down game map, dark iron, copper, rust-red rock, restrained orange and teal lights.
|
||||||
|
Composition/framing: landscape 16:9, edge-to-edge map, generous open construction zones around the route, no perspective tilt.
|
||||||
|
Constraints: no towers, no ships, no enemies, no characters, no text, no labels, no UI, no watermark; exactly one continuous route; reactor only at route end.
|
||||||
|
```
|
||||||
|
|
||||||
|
## HD-Neuaufbereitung der drei Karten
|
||||||
|
|
||||||
|
Für `orbital_platform_hd.png`, `orbital_shipyard_hd.png` und `asteroid_refinery_hd.png` wurde jeweils das zugehörige vorhandene Kartenbild als Editierziel verwendet. Der Kartenname im folgenden Prompt wurde pro Motiv angepasst:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Asset type: high-resolution top-down 2D tower-defense map background for Windows PC
|
||||||
|
Primary request: Re-render and upscale the supplied map to a crisp 4K-quality 16:9 game background while preserving the exact road route, reactor placement, platform layout, camera angle, colors, and overall composition.
|
||||||
|
Style/medium: realistic high-detail hard-surface science-fiction environment, sharp physically plausible metal, readable panel seams, clean road edges.
|
||||||
|
Composition/framing: exact same top-down framing and aspect ratio as the input; every road bend and gameplay-relevant platform must remain at the same relative coordinates.
|
||||||
|
Constraints: preserve gameplay geometry exactly; improve fine texture detail and edge clarity for display at 2800x1575 and larger; no text, no UI, no grid, no towers, no enemies, no watermark.
|
||||||
|
Avoid: changing the route, adding or removing road branches, changing the reactor position, blur, painterly texture, depth-of-field, perspective tilt.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verlängerte Routen für Version 0.7
|
||||||
|
|
||||||
|
Für `orbital_platform_long.png` und `orbital_shipyard_long.png` wurde die jeweilige HD-Karte als Editierziel verwendet. Die Pyrax-Raffinerie behält ihre vorhandene große Ringschleife, da sie den Bauraum bereits sinnvoll erschließt.
|
||||||
|
|
||||||
|
Gemeinsamer Rahmen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Asset type: full-screen top-down 2D tower-defense battle map
|
||||||
|
Style/medium: preserve the supplied realistic high-detail hard-surface science-fiction station, exact top-down orthographic camera, crisp metal panels and readable road edges.
|
||||||
|
Composition/framing: preserve the 16:9 frame, existing station silhouette, environment, lighting palette, and reactor identity; the entire route must be visible at once on PC and mobile.
|
||||||
|
Constraints: change the roadway and adjacent deck only as needed; exactly one continuous road from the left entrance to the right reactor; road width consistent everywhere; long bends with no crossings or branches; generous buildable metal areas always within tower range of at least one road segment; no UI, grid, towers, enemies, text, arrows, labels, or watermark.
|
||||||
|
Avoid: short direct paths, dead ends, route crossings, hidden road sections, perspective tilt, blur.
|
||||||
|
```
|
||||||
|
|
||||||
|
Kartenspezifische Routenanweisungen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Bastion Prime: Replace the current short roadway with one single much longer non-crossing three-pass serpentine road: it enters from the far left in the upper fifth, crosses far toward the right, makes a broad 180-degree bend downward, returns left across the middle, makes a second broad 180-degree bend downward, then crosses right across the lower-middle and connects to the existing cyan reactor on the far right. Keep large buildable deck islands inside and around the bends.
|
||||||
|
|
||||||
|
Helios-Werft: Replace the current roadway with one single longer non-crossing industrial S-route using three horizontal firing lanes: enter at the far left upper-middle, travel far right, make a broad downward U-turn, travel back left through the center, make another downward U-turn, then travel right along the lower-middle and curve upward into the existing cyan reactor at the far right. Use most of the station deck while leaving clear buildable islands between the lanes.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Straßenfreie Rasterbasen für Version 0.12
|
||||||
|
|
||||||
|
Für die drei final verwendeten Dateien `orbital_platform_grid_base.png`, `orbital_shipyard_grid_base.png` und `asteroid_refinery_grid_base.png` wurde jeweils die vorhandene Karte als Editierziel verwendet. Die Straße wird nicht mehr in das Bild eingebacken, sondern im Spiel exakt aus dem 80-Pixel-Raster erzeugt.
|
||||||
|
|
||||||
|
### Bastion Prime
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Asset type: top-down 2D tower-defense game map background
|
||||||
|
Primary request: Remove every baked-in roadway, racetrack lane, glowing road border, road center line, and curved driving route from this space-station map. Replace all former road areas with continuous realistic dark steel station decking and modular armored platform panels that match the surrounding construction surfaces.
|
||||||
|
Input image: edit target; preserve the map's identity.
|
||||||
|
Style/medium: realistic cinematic top-down science-fiction industrial station, crisp game-ready detail.
|
||||||
|
Composition/framing: preserve the exact camera, top-down framing, outer station silhouette, blue space background, right-side blue reactor, machinery, bridges, lighting direction, and overall color palette.
|
||||||
|
Constraints: the final surface must be a neutral continuous buildable deck with absolutely no implied travel route; preserve the reactor and major station structures; no towers, enemies, grid, UI, text, symbols, arrows, tracks, roads, lanes, curves, path markings, or watermark.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helios-Werft
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Asset type: top-down 2D tower-defense game map background
|
||||||
|
Primary request: Remove every baked-in roadway, racetrack lane, glowing road border, road center line, and curved driving route from this orbital shipyard map. Replace all former road areas with continuous realistic dark steel shipyard decking and modular armored platform panels matching the surrounding station.
|
||||||
|
Input image: edit target; preserve the Helios shipyard identity.
|
||||||
|
Style/medium: realistic cinematic top-down science-fiction industrial shipyard, crisp game-ready detail.
|
||||||
|
Composition/framing: preserve the exact camera, top-down framing, outer station silhouette, space backdrop, right-side blue reactor, gantries, towers, machinery, warm industrial lighting, and palette.
|
||||||
|
Constraints: neutral continuous buildable deck with absolutely no implied travel route; preserve the reactor and major structures; no towers, enemies, grid, UI, text, symbols, arrows, tracks, roads, lanes, curves, path markings, or watermark.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pyrax-Raffinerie
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Asset type: top-down 2D tower-defense game map background
|
||||||
|
Primary request: Remove every baked-in roadway, curved vehicle lane, glowing road border, road center line, and implied driving route from this orange asteroid refinery map. Replace former roads with continuous realistic rugged refinery decking, armored steel plates, industrial grating, pipes, and platform panels matching the surrounding buildable areas.
|
||||||
|
Input image: edit target; preserve the Pyrax refinery identity.
|
||||||
|
Style/medium: realistic cinematic top-down science-fiction asteroid refinery, crisp game-ready detail.
|
||||||
|
Composition/framing: preserve the exact camera, top-down framing, outer refinery silhouette, orange lava-space environment, right-side orange reactor, central open furnace/reactor chamber, machinery, industrial lighting, and palette.
|
||||||
|
Constraints: neutral continuous platform deck with absolutely no implied travel route; preserve both reactor areas and major structures; no towers, enemies, grid, UI, text, symbols, arrows, tracks, roads, lanes, curves, path markings, or watermark.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Beleuchtete Straßenoberfläche für Version 0.13
|
||||||
|
|
||||||
|
Die Datei `road_surface_material_v1.png` wurde mit dem integrierten OpenAI-Bildgenerator als realistische Materialquelle erzeugt. Das Spiel setzt Ausschnitte dieser Textur ausschließlich innerhalb der exakt rastergebundenen Straßenmodule ein; Form und Laufweg werden weiterhin deterministisch von Godot vorgegeben.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: stylized-concept
|
||||||
|
Asset type: seamless material texture for modular top-down 2D tower-defense roadway tiles
|
||||||
|
Primary request: Create a highly detailed realistic science-fiction orbital-station roadway surface material with the rich cinematic finish of premium game environment art.
|
||||||
|
Subject: edge-to-edge brushed titanium and gunmetal road decking with layered armored panels, fine recessed seams, subtle wear, small rivets, narrow cyan energy conduits, warm amber embedded navigation lights, soft pools of orange light, restrained blue reflections, and localized luminous rays across the metal.
|
||||||
|
Style/medium: photorealistic cinematic hard-surface science-fiction game texture, crisp high-resolution PBR-like detail, exact orthographic top-down view.
|
||||||
|
Composition/framing: square seamless tileable material swatch; continuous surface reaches every edge; balanced details without a central object; suitable for repeatedly tiling inside straight and curved road masks.
|
||||||
|
Lighting/mood: active inhabited station, bright readable metal, warm amber practical lights mixed with cool cyan technology glow, subtle volumetric light streaks and reflections, high contrast but not dark.
|
||||||
|
Color palette: silver titanium, graphite, cool steel blue, cyan accents, amber-orange lights.
|
||||||
|
Constraints: seamless/tileable on all four edges; exact top-down orthographic view; uniform scale; no road shape, no curve, no perspective, no background scenery, no space, no vehicles, no towers, no enemies, no text, no symbols, no arrows, no UI, no watermark.
|
||||||
|
Avoid: flat gray fill, large empty areas, obvious central composition, thick yellow outline, cartoon look, painterly blur, perspective tilt, lane borders that would conflict with a mask.
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Void Bastion Online 0.19
|
||||||
|
|
||||||
|
## Fertiger Stand
|
||||||
|
|
||||||
|
Das Spiel nutzt den offiziellen Nakama-Godot-Client 3.4.0. Beim normalen Start wird automatisch ein app-eigenes Gastkonto angemeldet. Das lokale Profil bleibt auch offline nutzbar; neue Bestwerte werden gespeichert und bei der nächsten erfolgreichen Verbindung nachgereicht.
|
||||||
|
|
||||||
|
Der Unraid-Stack besteht aus:
|
||||||
|
|
||||||
|
- Nakama 3.37.0 mit eigener JavaScript-Serverlogik
|
||||||
|
- PostgreSQL 16 ohne öffentlich freigegebenen Datenbank-Port
|
||||||
|
- täglichem komprimiertem Backup mit 14 Tagen Aufbewahrung
|
||||||
|
- ausschließlich lokal erreichbarer Nakama-Verwaltungskonsole
|
||||||
|
- getrennten zufälligen Schlüsseln für Sitzungen, Sitzungserneuerung, Verwaltung und Server-RPCs
|
||||||
|
|
||||||
|
## Ranglisten
|
||||||
|
|
||||||
|
Es existieren 36 autoritative Ranglisten: vier Spielarten mal drei Karten mal drei Schwierigkeitsgrade. Der Client kann nicht direkt in diese Ranglisten schreiben. Ergebnisse laufen durch `submit_run`; Karte, Schwierigkeit, Modus, Welle und eine großzügige Plausibilitätsgrenze für die Punktzahl werden vor dem Eintrag geprüft.
|
||||||
|
|
||||||
|
Die Kennungen folgen diesem Muster: `mission_map0_difficulty0`.
|
||||||
|
|
||||||
|
Mögliche Spielarten sind `mission`, `endless`, `coop_ai` und `coop_online`.
|
||||||
|
|
||||||
|
## Multiplayer
|
||||||
|
|
||||||
|
Die Godot-Anbindung öffnet nach der Anmeldung eine WSS/WS-Echtzeitverbindung. Die schnelle Spielersuche vermittelt exakt zwei Spieler mit derselben Suchart in eine serverautoritative Koop-Sitzung. Der Matchserver verwaltet Teilnehmer, gemeinsame Sitzungsdaten und die Weiterleitung zulässiger Spielnachrichten.
|
||||||
|
|
||||||
|
Der lokale KI-Koop bleibt unabhängig davon spielbar. Die vollständige Synchronisation von Türmen, Gegnern und gemeinsamem Reaktor zwischen zwei sichtbaren Spielclients ist der nächste Gameplay-Schritt; Konto, Transport, Vermittlung und Server-Match sind bereits funktionsfähig.
|
||||||
|
|
||||||
|
## Adressen
|
||||||
|
|
||||||
|
- Produktion: `https://api.void-bastion.gergames.de` und `wss://api.void-bastion.gergames.de`
|
||||||
|
- Entwicklung im Heimnetz: `http://192.168.178.20:7350` und `ws://192.168.178.20:7350`
|
||||||
|
|
||||||
|
In Debug-Builds versucht das Spiel nach einer fehlgeschlagenen Produktionsverbindung automatisch den Heimnetz-Server. Release-Builds verwenden ausschließlich HTTPS/WSS.
|
||||||
|
|
||||||
|
Die öffentliche Freischaltung ist abgeschlossen: Der autoritative DNS-Eintrag `api.void-bastion.gergames.de` zeigt als CNAME auf `bs6o73mrdi3e60p0.myfritz.net`; Reverse Proxy, HTTPS/WSS und das automatisch erneuerte Let’s-Encrypt-Zertifikat sind aktiv.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Offline-/Gameplaytest: `godot --headless --path . --script res://tests/smoke_test.gd`
|
||||||
|
- echter Onlinetest im Heimnetz: `godot --headless --path . --script res://tests/online_integration_test.gd`
|
||||||
|
- Produktionstest: `VOID_BASTION_TEST_HOST=api.void-bastion.gergames.de VOID_BASTION_TEST_SCHEME=https VOID_BASTION_TEST_PORT=443 godot --headless --path . --script res://tests/online_integration_test.gd`
|
||||||
|
|
||||||
|
Der Onlinetest prüft zwei Gastanmeldungen, RPC, Ergebnisannahme, Ranglistenabruf, zwei WebSocket-Verbindungen, Matchmaking und den Beitritt beider Clients zur selben autoritativen Partie.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Void Bastion – spielbarer Prototyp 0.19
|
||||||
|
|
||||||
|
Ein plattformübergreifender 2D-Tower-Defense-Prototyp für Godot 4. Der aktuelle Stand ist für Windows, Android und später iOS ausgelegt.
|
||||||
|
|
||||||
|
## Enthalten
|
||||||
|
|
||||||
|
- drei auswählbare realistische Weltraumkarten im Querformat
|
||||||
|
- vollständig sichtbare Einsatzkarten ohne Scrollen, Minikarte oder Richtungstasten
|
||||||
|
- hellere, kontrastreichere Stationskarten und eigens gestaltete modulare Metallfahrbahnen mit realistischen Materialtexturen auf Geraden und Kurven, Führungsschienen, gebürsteten Paneelen, cyanfarbenen Energieleitungen, farbigen Reflexionen, Schraubpunkten, warmen Positionslichtern und dezenten Lichtkegeln
|
||||||
|
- feste Benutzeroberfläche mit 1600 × 900 Referenzauflösung
|
||||||
|
- eine exakt eine Rasterzelle (80 Pixel) breite Straße, die direkt aus den Wegdaten gezeichnet wird
|
||||||
|
- sichtbare Straßenmitte, Gegner-Lauflinie und gesperrte Bauzellen verwenden auf jeder Karte dieselbe Datenquelle und können deshalb nicht mehr gegeneinander verrutschen
|
||||||
|
- deutlich längere Wendeschleifen auf Bastion Prime und der Helios-Werft, damit mehr Baufläche taktisch relevant wird
|
||||||
|
- sichtbares 80-Pixel-Bauraster mit automatischem Einrasten der Türme
|
||||||
|
- jede geeignete Plattformzelle ist bebaubar; ausschließlich Wegzellen, Reaktoren und Bereiche außerhalb der Plattform sind gesperrt
|
||||||
|
- neu erzeugte, straßenfreie HD-Kartenbasen mit 3344 × 1882 Pixeln und Mipmaps für eine schärfere Windows-Darstellung
|
||||||
|
- drei Türme: Kinetik, Laser und Rakete
|
||||||
|
- drei Gegnerklassen mit unterschiedlichen Werten
|
||||||
|
- 15 automatisch skalierende Missionswellen
|
||||||
|
- drei wählbare Schwierigkeitsgrade: Leicht entspricht dem bisherigen Balancing, Mittel ist der neue Standard und Schwer erhöht Gegnerhülle, Tempo und Anzahl deutlich
|
||||||
|
- höhere Schwierigkeitsgrade beginnen mit knapperen Ressourcen und belohnen einen Kartensieg mit mehr Kernkristallen
|
||||||
|
- freiwilliger Endlosmodus nach Welle 15 mit unbegrenzt weiter skalierenden Gegnern
|
||||||
|
- das Ende eines Endloslaufs zählt ausdrücklich nicht als gescheiterte Mission
|
||||||
|
- Kommandoschiff-Boss in jeder fünften Welle
|
||||||
|
- Platzierung per Maus oder Touch
|
||||||
|
- dreistufiges interaktives Einstiegstutorial
|
||||||
|
- Platzierungsvorschau mit gültiger beziehungsweise ungültiger Bauposition
|
||||||
|
- Turmverbesserungen bis Stufe 3 und Verkauf
|
||||||
|
- sichtbarer Reichweitenkreis bereits vor der Platzierung
|
||||||
|
- Turmkarten mit Schaden, Schüssen pro Sekunde, Reichweite sowie Einzel- oder Flächenschaden
|
||||||
|
- Upgrade-Vorschau mit dem konkreten Zuwachs an Schaden, Feuerrate und Reichweite
|
||||||
|
- kompaktes Turm-Informationsfenster, damit möglichst wenig von der Karte verdeckt wird
|
||||||
|
- korrigierte Zielausrichtung des Kinetik-Geschützes
|
||||||
|
- zusätzliche sinnvolle Bauplätze auf Bastion Prime, insbesondere zwischen den Wegschleifen und auf der linken Plattform
|
||||||
|
- zusätzliche Bauplätze zwischen den parallelen Fahrbahnen der Helios-Werft
|
||||||
|
- feste Reaktor-Sperrzonen auf allen Karten; auf Reaktoren und offenen Reaktorkammern wird kein Bauraster angezeigt und es können dort keine Türme platziert werden
|
||||||
|
- auf eine Rasterzelle verkleinerte Turmdarstellung, damit angrenzende Türme nicht in die Fahrbahn hineinragen
|
||||||
|
- dauerhaft gespeicherte Kernkristalle als spätere Freischaltwährung
|
||||||
|
- dauerhaftes lokales Gastprofil mit stabiler Spieler-ID als Grundlage für Apple-, Google- und Windows-Anmeldung
|
||||||
|
- eigene Profilansicht mit lokalen Missions- und Endlosbestwerten pro Karte
|
||||||
|
- wartende, lokal gespeicherte Highscore-Einträge für die spätere sichere Serverübertragung
|
||||||
|
- eigener Multiplayerbereich mit schnellem Spiel, Freundeseinladung und privatem Raum als vorbereitete Sucharten
|
||||||
|
- Vorschau einer symmetrisch gespiegelten Zwei-Spieler-Arena mit gemeinsamem Reaktor
|
||||||
|
- getrennte Ressourcenmodelle für beide Koop-Spieler sowie gemeinsam geteilte Reaktorleben
|
||||||
|
- sichtbarer, abbrechbarer Vorbereitungszustand für die spätere servergestützte Spielersuche
|
||||||
|
- bereits spielbarer lokaler Koop-Modus mit einem KI-Partner auf der gespiegelten Kartenhälfte
|
||||||
|
- nahezu verdoppelte Koop-Weltbreite: Beide Spieler erhalten eine vollständige Kartenhälfte in ursprünglicher Größe
|
||||||
|
- Spiegelachse direkt durch den vorhandenen Kartenreaktor, sodass Hintergrundreaktor, gemeinsamer Zielpunkt und Laufwege wieder zusammenpassen
|
||||||
|
- eigener Ansichtswechsel zwischen der unverkleinerten Spieler- und KI-Kartenhälfte
|
||||||
|
- zwei gleichwertige Gegnerströme von den äußeren Kartenrändern zum gemeinsamen Reaktor
|
||||||
|
- die KI baut und verbessert selbstständig Kinetik-, Laser- und Raketentürme ausschließlich auf ihrer Seite
|
||||||
|
- Abschussbelohnungen und Baukosten werden dem jeweiligen Spieler beziehungsweise KI-Partner getrennt zugerechnet
|
||||||
|
- farblich getrennte Bauflächen und markierte KI-Türme zur schnellen Zuordnung im Gefecht
|
||||||
|
- fünf Kernkristalle als Belohnung für jede erfolgreich abgeschlossene Karte
|
||||||
|
- normale und doppelte Spielgeschwindigkeit
|
||||||
|
- Pause, Missionssieg, Niederlage und lokaler Wellenrekord
|
||||||
|
- jederzeit sichtbarer Zurück-Button zum Abbrechen der laufenden Runde und Wechseln ins Kartenmenü
|
||||||
|
- lokaler Speicherstand für Rekord und Tutorialstatus
|
||||||
|
- für Mobilgeräte geeigneter Compatibility-Renderer
|
||||||
|
- vorbereitete AdMob-Anbindung für Android und iOS mit Einwilligungsabfrage
|
||||||
|
- freiwillige Belohnungswerbung für einmalig 150 Notfallenergie pro Mission
|
||||||
|
- zurückhaltende Vollbildwerbung frühestens nach drei Missionen und mit 15 Minuten Mindestabstand
|
||||||
|
- keine Bannerwerbung und keine erzwungene Werbung während einer Angriffswelle
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
1. Godot 4.x installieren.
|
||||||
|
2. Im Godot-Projektmanager `project.godot` aus diesem Ordner importieren.
|
||||||
|
3. Das Projekt öffnen und **F6** oder **F5** drücken.
|
||||||
|
|
||||||
|
Die Oberfläche ist für 1600 × 900 entworfen und skaliert automatisch auf andere Querformat-Auflösungen.
|
||||||
|
|
||||||
|
Beim ersten Öffnen einer frisch entpackten Kopie kann der Import der drei großen HD-Karten je nach PC ungefähr eine Minute dauern. Danach startet das Projekt normal.
|
||||||
|
|
||||||
|
Der Prototyp wurde mit der offiziellen Godot-Version 4.7.2 importiert und im automatisierten Headless-Test ohne Skript- oder Laufzeitfehler gestartet.
|
||||||
|
|
||||||
|
Der mitgelieferte Logiktest lässt sich bei Bedarf mit `godot --headless --path . --script res://tests/smoke_test.gd` erneut ausführen.
|
||||||
|
|
||||||
|
## Werbung und Datenschutz
|
||||||
|
|
||||||
|
Die mobile Werbeanbindung ist technisch vorbereitet, aber absichtlich noch nicht für echte Werbung freigeschaltet:
|
||||||
|
|
||||||
|
- `is_real` steht auf `false`; Android und iOS verwenden ausschließlich Googles offizielle Test-App- und Test-Anzeigen-IDs.
|
||||||
|
- Unter Windows bleibt das Werbesystem vollständig deaktiviert.
|
||||||
|
- Ein Belohnungsvideo erscheint nur nach einem freiwilligen Tipp auf **Video: +150 Notfallenergie** und höchstens einmal pro Mission.
|
||||||
|
- Vollbildwerbung kann nur am Ende einer Mission erscheinen, frühestens nach drei Missionen und niemals häufiger als alle 15 Minuten.
|
||||||
|
- Banner, App-Open-Anzeigen und Rewarded-Interstitials werden nicht verwendet.
|
||||||
|
- Vor der Initialisierung der Anzeigen wird auf Mobilgeräten Googles Consent-Information aktualisiert. Wenn eine Einwilligung erforderlich ist, wird zuerst das UMP-Formular angezeigt.
|
||||||
|
- Apples App-Tracking-Transparency-Abfrage ist derzeit deaktiviert. Sie wird erst aktiviert, wenn später tatsächlich Tracking beziehungsweise personalisierte Werbung benötigt und rechtlich geprüft wird.
|
||||||
|
|
||||||
|
Für die spätere Veröffentlichung müssen im AdMob-Konto eigene App- und Anzeigen-IDs angelegt, in `ads_manager.gd` beziehungsweise den beiden Export-Konfigurationsdateien eingetragen und `is_real` bewusst auf `true` gesetzt werden. Zusätzlich brauchen wir eine veröffentlichte Datenschutzerklärung, die korrekte Datenschutzdeklaration in App Store Connect und Google Play sowie eine erreichbare `app-ads.txt`-Datei auf der Entwickler-Webseite. Bis diese Punkte erledigt und echte Geräte getestet sind, dürfen die Test-IDs nicht ersetzt werden.
|
||||||
|
|
||||||
|
Verwendet wird **Godot AdMob Plugin 7.0** aus `godot-sdk-integrations/godot-admob`. Die Android- und iOS-Erweiterungen liegen mit im Projekt; ein iOS-Build benötigt später weiterhin macOS, Xcode und ein Apple-Developer-Konto.
|
||||||
|
|
||||||
|
## Online-Backend
|
||||||
|
|
||||||
|
Unter `server/nakama` liegt der reproduzierbare Docker-Compose-Stack für das selbst gehostete Online-Backend. Er besteht aus Nakama 3.37.0, einer eigenen internen PostgreSQL-16-Datenbank und einem täglichen Backup-Dienst mit 14 Tagen Aufbewahrung.
|
||||||
|
|
||||||
|
Der Stack ist auf dem vorgesehenen Unraid-Server eingerichtet. Nakamas Spiel-API läuft auf Port 7350, die Verwaltungskonsole ist ausschließlich an den Loopback-Port 7351 gebunden und PostgreSQL besitzt keinen veröffentlichten Host-Port. Echte Geheimnisse werden nur in der geschützten `.env`-Datei auf Unraid gespeichert und durch `.gitignore` ausdrücklich aus dem Quellpaket ausgeschlossen.
|
||||||
|
|
||||||
|
Die öffentliche Spiel-API ist unter `api.void-bastion.gergames.de` erreichbar. DNS, HTTPS/WSS über den vorhandenen Nginx Proxy Manager und die Godot-Nakama-Clientanbindung sind eingerichtet. Website und Spielserver verwenden damit getrennte Adressen.
|
||||||
|
|
||||||
|
## Steuerung
|
||||||
|
|
||||||
|
> **Online-Stand 0.19:** Der offizielle Nakama-Godot-Client, automatische Gastanmeldung, 36 autoritative Ranglisten, geprüfte Ergebnisübermittlung, Echtzeit-Sockets und das serverautoritative Zwei-Spieler-Matchmaking sind eingebaut und gegen Unraid getestet. Die öffentliche API ist über `https://api.void-bastion.gergames.de` mit einem gültigen Let’s-Encrypt-Zertifikat freigeschaltet. Details stehen in `ONLINE_SETUP.md`.
|
||||||
|
|
||||||
|
- Turm unten auswählen und auf eine freie Plattformfläche klicken oder tippen.
|
||||||
|
- Am PC folgt der Reichweitenkreis vor dem Bau dem Mauszeiger. Auf Mobilgeräten markiert der erste Tipp den Bauplatz samt Reichweite; der zweite Tipp auf dasselbe Feld bestätigt den Bau.
|
||||||
|
- Nur die markierten Rasterfelder sind bebaubar; Türme rasten automatisch in der Feldmitte ein. Die sichtbare Straße wird exakt auf denselben Rasterzellen und derselben Mittellinie gezeichnet, denen auch die Gegner folgen.
|
||||||
|
- Die Einsatzkarte wird vor dem Start im Hauptmenü gewählt.
|
||||||
|
- Im Hauptmenü wird außerdem der Schwierigkeitsgrad gewählt; die eingeblendete Kurzbeschreibung zeigt die wichtigsten Auswirkungen und den Kristallbonus.
|
||||||
|
- Die komplette Einsatzkarte bleibt auf PC und Mobilgeräten sichtbar; ein Verschieben ist nicht notwendig.
|
||||||
|
- Einen vorhandenen Turm auswählen, um ihn zu verbessern oder zu verkaufen.
|
||||||
|
- Das Informationsfeld rechts oben zeigt dabei die aktuellen Werte und den Mehrwert der nächsten Stufe.
|
||||||
|
- `Nächste Welle` startet den Angriff.
|
||||||
|
- `1×` beziehungsweise `2×` ändert die Spielgeschwindigkeit.
|
||||||
|
- `P`, Escape oder die Pause-Schaltfläche pausiert das Spiel.
|
||||||
|
- Nach Welle 15 kann die erfolgreiche Mission beendet oder freiwillig im Endlosmodus fortgesetzt werden.
|
||||||
|
- Unter **Multiplayer → Mit KI-Partner** startet die gespiegelte Koop-Arena sofort. Du baust auf der vollständigen linken Karte, die KI auf deren vollständiger Spiegelung; beide Seiten verteidigen denselben Reaktor. Mit **KI >** und **< ICH** wird ohne Verkleinerung zwischen beiden Seiten gewechselt.
|
||||||
|
|
||||||
|
## Nächste Produktionsschritte
|
||||||
|
|
||||||
|
1. Balancing sowie Testanzeigen auf echten Android- und iOS-Geräten prüfen
|
||||||
|
2. Soundeffekte, Musik und zusätzliche Trefferanimationen
|
||||||
|
3. Kampagnenfortschritt und zusätzliche Bossvarianten
|
||||||
|
4. Freischaltmenü für neue Türme und Karten mit den bereits gespeicherten Kernkristallen
|
||||||
|
5. Plattformkonten mit einer eigenen plattformübergreifenden Spieler-ID verbinden
|
||||||
|
6. Autoritativen Koop-Spielserver, Räume, Freundeseinladungen und Spielersuche anbinden
|
||||||
|
7. Die gespiegelte Koop-Arena spielbar machen und anschließend über echte Geräte synchron testen
|
||||||
|
8. Store-Käufe erst nach stabiler Offline- und Koop-Kernversion
|
||||||
|
|
||||||
|
## Godot Agent Tools MCP
|
||||||
|
|
||||||
|
Das Projekt enthält das MIT-lizenzierte Godot-Addon **Agent Tools 0.3.1** unter `addons/agent_tools` sowie die lokale MCP-Brücke unter `tools/godot-agent-tools-mcp`.
|
||||||
|
|
||||||
|
Der Plugin-Eintrag ist in `project.godot` aktiviert. Beim geöffneten Godot-Editor lauscht die lokale Brücke ausschließlich auf `127.0.0.1:9920`. Codex wurde systemweit mit dem Namen `godot-agent-tools` auf diese lokale Brücke konfiguriert.
|
||||||
|
|
||||||
|
Geprüfter Zustand:
|
||||||
|
|
||||||
|
- 70 MCP-Werkzeuge erkannt
|
||||||
|
- `res://main.tscn` als aktuelle Szene erkannt
|
||||||
|
- Szenenwurzel `VoidBastion` vom Typ `Node2D` erkannt
|
||||||
|
- 57 Projektverweise geprüft, keine Probleme gefunden
|
||||||
|
|
||||||
|
Nach einem Neustart beziehungsweise einer neuen lokalen Codex-Sitzung stehen die Godot-Werkzeuge automatisch zur Verfügung, solange dieses Projekt im Godot-Editor geöffnet ist.
|
||||||
|
|
||||||
|
## Grafikherkunft
|
||||||
|
|
||||||
|
Die Karten und Sprite-Grafiken im Ordner `assets` wurden für dieses Projekt mit dem integrierten OpenAI-Bildgenerator erzeugt. Für Version 0.12 wurden die drei Karten als straßenfreie, hochauflösende Stationsdecks neu erzeugt und auf 3344 × 1882 Pixel aufbereitet. Die exakten Rasterstraßen entstehen nun im Spiel aus denselben Wegdaten wie Gegnerlinie und Bausperre. Die final verwendeten Prompts stehen in `ART_ASSETS.md`.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Drittanbieter-Hinweise
|
||||||
|
|
||||||
|
## Nakama Godot Client
|
||||||
|
|
||||||
|
Dieses Projekt enthält den offiziellen Nakama Godot Client 3.4.0 von Heroic Labs.
|
||||||
|
Der Client steht unter der Apache License 2.0. Der vollständige Lizenztext liegt unter `addons/com.heroiclabs.nakama/LICENSE`.
|
||||||
|
|
||||||
|
## Godot AdMob Plugin
|
||||||
|
|
||||||
|
Dieses Projekt enthält Version 7.0 des Projekts `godot-sdk-integrations/godot-admob`.
|
||||||
|
|
||||||
|
Copyright (c) 2024-present cengiz-pz und Mitwirkende
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
class_name AccountManager
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal profile_changed
|
||||||
|
|
||||||
|
const ACCOUNT_PATH := "user://void_bastion_account.json"
|
||||||
|
|
||||||
|
var player_id := ""
|
||||||
|
var display_name := ""
|
||||||
|
var primary_provider := "guest"
|
||||||
|
var linked_identities: Array = []
|
||||||
|
var highscores: Dictionary = {}
|
||||||
|
var pending_submissions: Array = []
|
||||||
|
var created_at := 0
|
||||||
|
var storage_enabled := true
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_load_or_create_profile()
|
||||||
|
|
||||||
|
|
||||||
|
func _load_or_create_profile() -> void:
|
||||||
|
if FileAccess.file_exists(ACCOUNT_PATH):
|
||||||
|
var file := FileAccess.open(ACCOUNT_PATH, FileAccess.READ)
|
||||||
|
if file:
|
||||||
|
var data = JSON.parse_string(file.get_as_text())
|
||||||
|
if data is Dictionary:
|
||||||
|
player_id = str(data.get("player_id", ""))
|
||||||
|
display_name = str(data.get("display_name", ""))
|
||||||
|
primary_provider = str(data.get("primary_provider", "guest"))
|
||||||
|
linked_identities = data.get("linked_identities", [])
|
||||||
|
highscores = data.get("highscores", {})
|
||||||
|
pending_submissions = data.get("pending_submissions", [])
|
||||||
|
created_at = int(data.get("created_at", 0))
|
||||||
|
if player_id.is_empty():
|
||||||
|
_create_guest_profile()
|
||||||
|
if display_name.is_empty():
|
||||||
|
display_name = "PILOT-%s" % player_id.right(4).to_upper()
|
||||||
|
_save_profile()
|
||||||
|
|
||||||
|
|
||||||
|
func _create_guest_profile() -> void:
|
||||||
|
var random_bytes := Crypto.new().generate_random_bytes(16)
|
||||||
|
player_id = "guest_%s" % random_bytes.hex_encode()
|
||||||
|
display_name = "PILOT-%s" % player_id.right(4).to_upper()
|
||||||
|
primary_provider = "guest"
|
||||||
|
linked_identities = []
|
||||||
|
highscores = {}
|
||||||
|
pending_submissions = []
|
||||||
|
created_at = int(Time.get_unix_time_from_system())
|
||||||
|
|
||||||
|
|
||||||
|
func _save_profile() -> void:
|
||||||
|
if not storage_enabled:
|
||||||
|
return
|
||||||
|
var file := FileAccess.open(ACCOUNT_PATH, FileAccess.WRITE)
|
||||||
|
if not file:
|
||||||
|
return
|
||||||
|
file.store_string(JSON.stringify({
|
||||||
|
"player_id": player_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"primary_provider": primary_provider,
|
||||||
|
"linked_identities": linked_identities,
|
||||||
|
"highscores": highscores,
|
||||||
|
"pending_submissions": pending_submissions,
|
||||||
|
"created_at": created_at
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
func record_run(map_index: int, difficulty_index: int, endless: bool, reached_wave: int, score: int) -> bool:
|
||||||
|
var mode := "endless" if endless else "mission"
|
||||||
|
var key := "%d:%d:%s" % [map_index, difficulty_index, mode]
|
||||||
|
var previous: Dictionary = highscores.get(key, {})
|
||||||
|
if int(previous.get("score", -1)) >= score:
|
||||||
|
return false
|
||||||
|
var record := {
|
||||||
|
"map": map_index,
|
||||||
|
"difficulty": difficulty_index,
|
||||||
|
"mode": mode,
|
||||||
|
"wave": reached_wave,
|
||||||
|
"score": score,
|
||||||
|
"recorded_at": int(Time.get_unix_time_from_system())
|
||||||
|
}
|
||||||
|
highscores[key] = record
|
||||||
|
pending_submissions.append(record.duplicate(true))
|
||||||
|
_save_profile()
|
||||||
|
profile_changed.emit()
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func attach_online_identity(online_user_id: String, online_username: String) -> void:
|
||||||
|
if online_user_id.is_empty():
|
||||||
|
return
|
||||||
|
var identity := {"provider": "nakama", "user_id": online_user_id}
|
||||||
|
if not linked_identities.has(identity):
|
||||||
|
linked_identities.append(identity)
|
||||||
|
if not online_username.is_empty() and display_name.begins_with("PILOT-"):
|
||||||
|
display_name = online_username
|
||||||
|
_save_profile()
|
||||||
|
profile_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func mark_submission_complete(record: Dictionary) -> void:
|
||||||
|
for index in range(pending_submissions.size() - 1, -1, -1):
|
||||||
|
var queued = pending_submissions[index]
|
||||||
|
if queued is Dictionary and _same_run(queued, record):
|
||||||
|
pending_submissions.remove_at(index)
|
||||||
|
_save_profile()
|
||||||
|
profile_changed.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
func _same_run(left: Dictionary, right: Dictionary) -> bool:
|
||||||
|
return int(left.get("map", -1)) == int(right.get("map", -2)) \
|
||||||
|
and int(left.get("difficulty", -1)) == int(right.get("difficulty", -2)) \
|
||||||
|
and str(left.get("mode", "")) == str(right.get("mode", "_")) \
|
||||||
|
and int(left.get("wave", -1)) == int(right.get("wave", -2)) \
|
||||||
|
and int(left.get("score", -1)) == int(right.get("score", -2))
|
||||||
|
|
||||||
|
|
||||||
|
func get_map_best_wave(map_index: int, endless := false) -> int:
|
||||||
|
var mode := "endless" if endless else "mission"
|
||||||
|
var result := 0
|
||||||
|
for difficulty_index in 3:
|
||||||
|
var key := "%d:%d:%s" % [map_index, difficulty_index, mode]
|
||||||
|
var record: Dictionary = highscores.get(key, {})
|
||||||
|
result = maxi(result, int(record.get("wave", 0)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func get_map_best_score(map_index: int, endless := false) -> int:
|
||||||
|
var mode := "endless" if endless else "mission"
|
||||||
|
var result := 0
|
||||||
|
for difficulty_index in 3:
|
||||||
|
var key := "%d:%d:%s" % [map_index, difficulty_index, mode]
|
||||||
|
var record: Dictionary = highscores.get(key, {})
|
||||||
|
result = maxi(result, int(record.get("score", 0)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func provider_label() -> String:
|
||||||
|
match primary_provider:
|
||||||
|
"apple":
|
||||||
|
return "APPLE / GAME CENTER"
|
||||||
|
"google":
|
||||||
|
return "GOOGLE PLAY GAMES"
|
||||||
|
"email":
|
||||||
|
return "E-MAIL"
|
||||||
|
_:
|
||||||
|
return "GASTPROFIL • NUR DIESES GERÄT"
|
||||||
|
|
||||||
|
|
||||||
|
func short_id() -> String:
|
||||||
|
return player_id.right(12).to_upper()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://0vxrdsrx3qj2
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
uid://dssu2uimllcg6
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
|
||||||
|
@tool
|
||||||
|
extends EditorPlugin
|
||||||
|
|
||||||
|
const PLUGIN_NAME: String = "AdmobPlugin"
|
||||||
|
const ANDROID_DEPENDENCIES: Array = [ "androidx.appcompat:appcompat:1.7.1", "androidx.lifecycle:lifecycle-process:2.8.3", "com.google.android.gms:play-services-ads:24.9.0" ]
|
||||||
|
const IOS_PLATFORM_VERSION: String = "14.3"
|
||||||
|
const IOS_FRAMEWORKS: Array = [ "Foundation.framework", "AppTrackingTransparency.framework" ]
|
||||||
|
const IOS_EMBEDDED_FRAMEWORKS: Array = [ ]
|
||||||
|
const IOS_LINKER_FLAGS: Array = [ "-ObjC" ]
|
||||||
|
const IOS_BUNDLE_FILES: Array = [ ]
|
||||||
|
const SPM_DEPENDENCIES: Array = [ {&"url": "https://github.com/googleads/swift-package-manager-google-mobile-ads.git", &"version": "12.14.0", &"products": ["GoogleMobileAds"]} ]
|
||||||
|
|
||||||
|
var android_export_plugin: AndroidExportPlugin
|
||||||
|
var ios_export_plugin: IosExportPlugin
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
android_export_plugin = AndroidExportPlugin.new()
|
||||||
|
add_export_plugin(android_export_plugin)
|
||||||
|
ios_export_plugin = IosExportPlugin.new()
|
||||||
|
add_export_plugin(ios_export_plugin)
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
remove_export_plugin(android_export_plugin)
|
||||||
|
android_export_plugin = null
|
||||||
|
remove_export_plugin(ios_export_plugin)
|
||||||
|
ios_export_plugin = null
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidExportPlugin extends EditorExportPlugin:
|
||||||
|
const APP_ID_META_TAG = """
|
||||||
|
<meta-data
|
||||||
|
tools:replace="android:value"
|
||||||
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
|
android:value="%s"/>
|
||||||
|
"""
|
||||||
|
|
||||||
|
var _plugin_name = PLUGIN_NAME
|
||||||
|
var _export_config: AdmobAndroidExportConfig
|
||||||
|
|
||||||
|
|
||||||
|
func _supports_platform(platform: EditorExportPlatform) -> bool:
|
||||||
|
return platform is EditorExportPlatformAndroid
|
||||||
|
|
||||||
|
|
||||||
|
func _get_android_libraries(platform: EditorExportPlatform, debug: bool) -> PackedStringArray:
|
||||||
|
if debug:
|
||||||
|
return PackedStringArray(["%s/bin/debug/%s-debug.aar" % [_plugin_name, _plugin_name]])
|
||||||
|
else:
|
||||||
|
return PackedStringArray(["%s/bin/release/%s-release.aar" % [_plugin_name, _plugin_name]])
|
||||||
|
|
||||||
|
|
||||||
|
func _get_name() -> String:
|
||||||
|
return _plugin_name
|
||||||
|
|
||||||
|
|
||||||
|
func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void:
|
||||||
|
if _supports_platform(get_export_platform()):
|
||||||
|
_export_config = AdmobAndroidExportConfig.new()
|
||||||
|
if not _export_config.export_config_file_exists() or _export_config.load_export_config_from_file() != OK:
|
||||||
|
_export_config.load_export_config_from_node()
|
||||||
|
|
||||||
|
|
||||||
|
func _get_android_dependencies(platform: EditorExportPlatform, debug: bool) -> PackedStringArray:
|
||||||
|
var deps: PackedStringArray = PackedStringArray(ANDROID_DEPENDENCIES)
|
||||||
|
if _export_config and _export_config.enabled_mediation_networks.size() > 0:
|
||||||
|
for __network in _export_config.enabled_mediation_networks:
|
||||||
|
for __dependency in __network.android_dependencies:
|
||||||
|
deps.append(__dependency)
|
||||||
|
|
||||||
|
GmpLogger.log_info("Android dependencies: %s" % str(deps))
|
||||||
|
|
||||||
|
return deps
|
||||||
|
|
||||||
|
|
||||||
|
func _get_android_dependencies_maven_repos(platform: EditorExportPlatform, debug: bool) -> PackedStringArray:
|
||||||
|
var __custom_repos: PackedStringArray = []
|
||||||
|
|
||||||
|
if _export_config and _export_config.enabled_mediation_networks.size() > 0:
|
||||||
|
for network in _export_config.enabled_mediation_networks:
|
||||||
|
if network.android_custom_maven_repo and not network.android_custom_maven_repo.is_empty():
|
||||||
|
__custom_repos.append(network.android_custom_maven_repo)
|
||||||
|
GmpLogger.log_info(
|
||||||
|
"Added custom Maven repo for %s mediation: %s" %
|
||||||
|
[network.tag, network.android_custom_maven_repo],
|
||||||
|
)
|
||||||
|
|
||||||
|
return __custom_repos
|
||||||
|
|
||||||
|
|
||||||
|
func _get_android_manifest_application_element_contents(platform: EditorExportPlatform, debug: bool) -> String:
|
||||||
|
var __contents: String
|
||||||
|
|
||||||
|
if _export_config:
|
||||||
|
__contents = APP_ID_META_TAG % (_export_config.real_application_id if _export_config.is_real \
|
||||||
|
else _export_config.debug_application_id )
|
||||||
|
else:
|
||||||
|
GmpLogger.log_warn("Export config not found for %s!" % _plugin_name)
|
||||||
|
__contents = ""
|
||||||
|
|
||||||
|
return __contents
|
||||||
|
|
||||||
|
|
||||||
|
class IosExportPlugin extends EditorExportPlugin:
|
||||||
|
const NS_APP_TRANSPORT_SECURITY: String = """
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
"""
|
||||||
|
|
||||||
|
var _plugin_name = PLUGIN_NAME
|
||||||
|
var _spm_dependencies = []
|
||||||
|
var _export_config: AdmobIosExportConfig
|
||||||
|
var _export_path: String
|
||||||
|
|
||||||
|
|
||||||
|
func _supports_platform(platform: EditorExportPlatform) -> bool:
|
||||||
|
return platform is EditorExportPlatformIOS
|
||||||
|
|
||||||
|
|
||||||
|
func _get_name() -> String:
|
||||||
|
return _plugin_name
|
||||||
|
|
||||||
|
|
||||||
|
func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void:
|
||||||
|
if _supports_platform(get_export_platform()):
|
||||||
|
_export_path = path.simplify_path()
|
||||||
|
_export_config = AdmobIosExportConfig.new()
|
||||||
|
if not _export_config.export_config_file_exists() or _export_config.load_export_config_from_file() != OK:
|
||||||
|
_export_config.load_export_config_from_node()
|
||||||
|
|
||||||
|
add_apple_embedded_platform_plist_content("<key>GADApplicationIdentifier</key>")
|
||||||
|
add_apple_embedded_platform_plist_content(
|
||||||
|
"\t<string>%s</string>" % (_export_config.real_application_id
|
||||||
|
if _export_config.is_real else _export_config.debug_application_id ),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _export_config.att_enabled and _export_config.att_text and not _export_config.att_text.is_empty():
|
||||||
|
add_apple_embedded_platform_plist_content("<key>NSUserTrackingUsageDescription</key>")
|
||||||
|
add_apple_embedded_platform_plist_content("<string>%s</string>" % _export_config.att_text)
|
||||||
|
|
||||||
|
add_apple_embedded_platform_plist_content(
|
||||||
|
MediationNetwork.generate_sk_ad_network_plist(
|
||||||
|
_export_config.enabled_mediation_networks,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
add_apple_embedded_platform_plist_content(NS_APP_TRANSPORT_SECURITY)
|
||||||
|
|
||||||
|
for __framework in IOS_FRAMEWORKS:
|
||||||
|
add_apple_embedded_platform_framework(__framework)
|
||||||
|
|
||||||
|
for __framework in IOS_EMBEDDED_FRAMEWORKS:
|
||||||
|
add_apple_embedded_platform_embedded_framework(__framework)
|
||||||
|
|
||||||
|
for __flag in IOS_LINKER_FLAGS:
|
||||||
|
add_apple_embedded_platform_linker_flags(__flag)
|
||||||
|
|
||||||
|
for __bundle_file in IOS_BUNDLE_FILES:
|
||||||
|
add_apple_embedded_platform_bundle_file(__bundle_file)
|
||||||
|
|
||||||
|
for __spm_dep in SPM_DEPENDENCIES:
|
||||||
|
_spm_dependencies.append(SpmDependency.new(__spm_dep))
|
||||||
|
|
||||||
|
|
||||||
|
func _end_generate_apple_embedded_project(path: String, will_build_archive: bool) -> void:
|
||||||
|
GmpLogger.log_info("Apple export project generated at: %s. Will build archive: %s"
|
||||||
|
% [path, str(will_build_archive)])
|
||||||
|
|
||||||
|
if _supports_platform(get_export_platform()):
|
||||||
|
_spm_dependencies.append_array(_get_extra_dependencies())
|
||||||
|
|
||||||
|
if _spm_dependencies.is_empty():
|
||||||
|
GmpLogger.log_info("No SPM dependencies to install. Skipping.")
|
||||||
|
else:
|
||||||
|
GmpLogger.log_info("Installing %d SPM dependencies." % _spm_dependencies.size())
|
||||||
|
_install_dependencies(path.get_base_dir(), path.get_file().get_basename())
|
||||||
|
|
||||||
|
|
||||||
|
func _get_extra_dependencies() -> Array[SpmDependency]:
|
||||||
|
var __extra_dependencies:= [] as Array[SpmDependency]
|
||||||
|
|
||||||
|
__extra_dependencies.append_array(
|
||||||
|
MediationNetwork.get_spm_dependencies(_export_config.enabled_mediation_networks))
|
||||||
|
|
||||||
|
return __extra_dependencies
|
||||||
|
|
||||||
|
|
||||||
|
func _install_dependencies(a_base_dir: String, a_project_name: String) -> void:
|
||||||
|
var __project_file_name:= "%s.xcodeproj" % a_project_name
|
||||||
|
var __project_file_path:= a_base_dir.path_join(__project_file_name)
|
||||||
|
if not DirAccess.dir_exists_absolute(__project_file_path):
|
||||||
|
GmpLogger.log_error("Xcode project '%s' does not exist! Can't install SPM dependencies."
|
||||||
|
% __project_file_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
var __script_name = "add_dependency.rb"
|
||||||
|
var __add_dependency_script_path = a_base_dir.path_join(__script_name)
|
||||||
|
var __result = _generate_add_dependency_script(__add_dependency_script_path)
|
||||||
|
if __result != Error.OK:
|
||||||
|
GmpLogger.log_error("Failed to generate '%s' script with error %d!" % [__script_name, __result])
|
||||||
|
return
|
||||||
|
|
||||||
|
GmpLogger.log_info("Adding SPM dependencies to %s..." % __project_file_path)
|
||||||
|
|
||||||
|
for __spm_dep: SpmDependency in _spm_dependencies:
|
||||||
|
for __spm_dep_product: String in __spm_dep.get_products():
|
||||||
|
var exec_output: Array = []
|
||||||
|
var exec_code = OS.execute("ruby", [
|
||||||
|
__add_dependency_script_path,
|
||||||
|
__project_file_path,
|
||||||
|
__spm_dep.get_url(),
|
||||||
|
__spm_dep.get_version(),
|
||||||
|
__spm_dep_product,
|
||||||
|
], exec_output, true, false)
|
||||||
|
|
||||||
|
if exec_code == 0:
|
||||||
|
GmpLogger.log_info("Product %s for SPM dependency %s added successfully!"
|
||||||
|
% [__spm_dep_product, __spm_dep.format_to_string()])
|
||||||
|
for line in exec_output:
|
||||||
|
GmpLogger.log_info("SPM: %s" % line)
|
||||||
|
else:
|
||||||
|
GmpLogger.log_info("Failed to add product %s for SPM dependency %s !"
|
||||||
|
% [__spm_dep_product, __spm_dep.format_to_string()])
|
||||||
|
for line in exec_output:
|
||||||
|
GmpLogger.log_error("SPM: %s" % line)
|
||||||
|
|
||||||
|
GmpLogger.log_info("Resolving SPM dependencies...")
|
||||||
|
|
||||||
|
__script_name = "resolve_dependencies.sh"
|
||||||
|
var __resolve_dependencies_script_path = a_base_dir.path_join(__script_name)
|
||||||
|
__result = _generate_resolve_dependencies_script(__resolve_dependencies_script_path, a_base_dir, a_project_name)
|
||||||
|
if __result != Error.OK:
|
||||||
|
GmpLogger.log_error("Failed to generate '%s' script with error %d!" % [__script_name, __result])
|
||||||
|
return
|
||||||
|
|
||||||
|
var exec_output: Array = []
|
||||||
|
var exec_code = OS.execute(__resolve_dependencies_script_path, [], exec_output, true, false)
|
||||||
|
|
||||||
|
if exec_code == 0:
|
||||||
|
for line in exec_output:
|
||||||
|
GmpLogger.log_info("SPM: %s" % line)
|
||||||
|
GmpLogger.log_info("Resolved dependencies successfully!")
|
||||||
|
else:
|
||||||
|
for line in exec_output:
|
||||||
|
GmpLogger.log_error("SPM: %s" % line)
|
||||||
|
GmpLogger.log_info("Failed to resolve dependencies! Try manually in Xcode.")
|
||||||
|
|
||||||
|
|
||||||
|
const ADD_DEPENDENCY_RUBY_SCRIPT = """
|
||||||
|
require 'xcodeproj'
|
||||||
|
|
||||||
|
project_path = ARGV[0]
|
||||||
|
url = ARGV[1].strip
|
||||||
|
version = ARGV[2].strip
|
||||||
|
product_name = ARGV[3].strip
|
||||||
|
|
||||||
|
unless File.exist?(project_path)
|
||||||
|
puts "Error: Xcode project not found at #{project_path}"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if url.empty? || version.empty? || product_name.empty?
|
||||||
|
puts "Error: url, version, and product_name must all be non-empty."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
project = Xcodeproj::Project.open(project_path)
|
||||||
|
target = project.targets.first
|
||||||
|
|
||||||
|
if target.nil?
|
||||||
|
puts "Error: No targets found in the Xcode project."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
existing_dep = target.package_product_dependencies.find do |dep|
|
||||||
|
dep.product_name == product_name
|
||||||
|
end
|
||||||
|
|
||||||
|
if existing_dep
|
||||||
|
puts "Warning: Product dependency '#{product_name}' already exists in the project. Skipping add.\n\n"
|
||||||
|
else
|
||||||
|
# Reuse an existing package reference for the same URL, or create a new one
|
||||||
|
pkg = project.root_object.package_references.find do |p|
|
||||||
|
p.repositoryURL == url
|
||||||
|
end
|
||||||
|
|
||||||
|
if pkg
|
||||||
|
puts "Reusing existing package reference for '#{url}'."
|
||||||
|
else
|
||||||
|
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
|
||||||
|
pkg.repositoryURL = url
|
||||||
|
pkg.requirement = {
|
||||||
|
'kind' => 'upToNextMajorVersion',
|
||||||
|
'minimumVersion' => version
|
||||||
|
}
|
||||||
|
project.root_object.package_references << pkg
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create the product dependency and link it to the shared package reference
|
||||||
|
ref = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
|
||||||
|
ref.product_name = product_name
|
||||||
|
ref.package = pkg
|
||||||
|
target.package_product_dependencies << ref
|
||||||
|
|
||||||
|
puts "Successfully added SPM dependency '#{product_name}' " \
|
||||||
|
"(#{url} @ #{version}) to #{File.basename(project_path)}\n\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
project.save
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
puts "An error occurred: #{e.message}\n\n"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
"""
|
||||||
|
func _generate_add_dependency_script(a_script_path: String) -> Error:
|
||||||
|
var __result = Error.OK
|
||||||
|
|
||||||
|
var __script_content = ADD_DEPENDENCY_RUBY_SCRIPT
|
||||||
|
|
||||||
|
__result = _create_script(a_script_path, __script_content)
|
||||||
|
|
||||||
|
return __result
|
||||||
|
|
||||||
|
|
||||||
|
const RESOLVE_DEPENDENCIES_BASH_SCRIPT = """
|
||||||
|
#!/bin/bash
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
xcodebuild -resolvePackageDependencies \
|
||||||
|
-project "%s.xcodeproj" \
|
||||||
|
-scheme "%s"
|
||||||
|
"""
|
||||||
|
func _generate_resolve_dependencies_script(a_script_path: String, a_base_dir: String,
|
||||||
|
a_project_name: String) -> Error:
|
||||||
|
var __result: Error = Error.OK
|
||||||
|
|
||||||
|
var __script_content = RESOLVE_DEPENDENCIES_BASH_SCRIPT \
|
||||||
|
% [ ProjectSettings.globalize_path(a_base_dir.path_join(a_project_name)), a_project_name ]
|
||||||
|
|
||||||
|
__result = _create_script(a_script_path, __script_content)
|
||||||
|
|
||||||
|
return __result
|
||||||
|
|
||||||
|
|
||||||
|
func _create_script(a_script_path: String, a_script_content: String) -> Error:
|
||||||
|
var __result: Error = Error.OK
|
||||||
|
|
||||||
|
var __script_file = FileAccess.open(a_script_path, FileAccess.WRITE)
|
||||||
|
if __script_file:
|
||||||
|
__script_file.store_string(a_script_content)
|
||||||
|
__script_file.close()
|
||||||
|
else:
|
||||||
|
__result = Error.ERR_FILE_CANT_WRITE
|
||||||
|
|
||||||
|
var chmod_output: Array = []
|
||||||
|
var chmod_code = OS.execute("chmod", ["+x", a_script_path], chmod_output, true, false)
|
||||||
|
if chmod_code != 0:
|
||||||
|
GmpLogger.log_error("Failed to chmod %s script: %s"
|
||||||
|
% [a_script_path, (chmod_output if chmod_output.size() > 0 else "Unknown error")])
|
||||||
|
__result = Error.ERR_FILE_NO_PERMISSION
|
||||||
|
|
||||||
|
return __result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ombwj85k4mvh
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
|
||||||
|
@icon("icon.png")
|
||||||
|
class_name InlineAdaptiveBanner
|
||||||
|
extends Control
|
||||||
|
|
||||||
|
const MINIMUM_VALID_WIDTH: float = 50.0
|
||||||
|
const MINIMUM_CLIP_THRESHOLD: float = 0.05
|
||||||
|
const MAXIMUM_CLIP_THRESHOLD: float = 1.0
|
||||||
|
const MINIMUM_RESIZE_THRESHOLD: float = 10.0
|
||||||
|
const MAXIMUM_RESIZE_THRESHOLD: float = 500.0
|
||||||
|
|
||||||
|
## Maximum pixel height of the requested ad. If set to -1, height will be automatically determined.
|
||||||
|
@export var max_ad_height: int = -1
|
||||||
|
|
||||||
|
## Minimum portion of the banner’s area (as a percentage) that must be clipped before the banner is considered hidden.
|
||||||
|
@export_range(MINIMUM_CLIP_THRESHOLD, MAXIMUM_CLIP_THRESHOLD, 0.05) var clip_threshold: float = 0.1:
|
||||||
|
set(a_value):
|
||||||
|
clip_threshold = clampf(a_value, MINIMUM_CLIP_THRESHOLD, MAXIMUM_CLIP_THRESHOLD)
|
||||||
|
|
||||||
|
## Width-change threshold (in pixels) that triggers a banner reload when the ad container is resized by this amount
|
||||||
|
## or more.
|
||||||
|
@export_range(MINIMUM_RESIZE_THRESHOLD, MAXIMUM_RESIZE_THRESHOLD, 5.0) var resize_threshold: float = 50.0:
|
||||||
|
set(a_value):
|
||||||
|
resize_threshold = clampf(a_value, MINIMUM_RESIZE_THRESHOLD, MAXIMUM_RESIZE_THRESHOLD)
|
||||||
|
|
||||||
|
## Path to Admob node.
|
||||||
|
@export_node_path("Admob") var admob_path: NodePath
|
||||||
|
|
||||||
|
## Whether the inline-adaptive banner ad should be automatically loaded.
|
||||||
|
@export var auto_load: bool = false
|
||||||
|
|
||||||
|
@export_category("Ad Unit Identifier")
|
||||||
|
## Use ad unit identifier value defined in the Admob node.
|
||||||
|
@export var inherit_from_admob_node: bool = false
|
||||||
|
|
||||||
|
@export_group("Android", "ad_unit_id_android")
|
||||||
|
## Ad unit ID of the inline adaptive banner ad to be loaded on the Android platform.
|
||||||
|
@export var ad_unit_id_android_debug: String = "ca-app-pub-3940256099942544/2014213617"
|
||||||
|
|
||||||
|
## Ad unit ID of the inline adaptive banner ad to be loaded on the Android platform.
|
||||||
|
@export var ad_unit_id_android_real: String = ""
|
||||||
|
|
||||||
|
@export_group("iOS", "ad_unit_id_ios")
|
||||||
|
## Ad unit ID of the inline adaptive banner ad to be loaded on the iOS platform.
|
||||||
|
@export var ad_unit_id_ios_debug: String = "ca-app-pub-3940256099942544/2934735716"
|
||||||
|
|
||||||
|
## Ad unit ID of the inline adaptive banner ad to be loaded on the iOS platform.
|
||||||
|
@export var ad_unit_id_ios_real: String = ""
|
||||||
|
|
||||||
|
var admob: Admob
|
||||||
|
var _ad_unit_id: String
|
||||||
|
var ad_id: String = ""
|
||||||
|
|
||||||
|
var last_width: float
|
||||||
|
|
||||||
|
var pending_load: bool = false
|
||||||
|
var is_position_dirty: bool = false
|
||||||
|
var is_banner_visible: bool = false
|
||||||
|
var do_ignore_resize: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
var __admob: Admob
|
||||||
|
if admob_path:
|
||||||
|
__admob = get_node(admob_path)
|
||||||
|
|
||||||
|
if __admob:
|
||||||
|
initialize(__admob)
|
||||||
|
else:
|
||||||
|
push_warning("Admob node not found")
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
if ad_id != "":
|
||||||
|
remove_ad()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
return
|
||||||
|
|
||||||
|
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
|
||||||
|
last_width = size.x
|
||||||
|
resized.connect(_on_resized)
|
||||||
|
|
||||||
|
|
||||||
|
func initialize(a_admob_node: Admob) -> void:
|
||||||
|
admob = a_admob_node
|
||||||
|
|
||||||
|
visibility_changed.connect(_on_visibility_changed)
|
||||||
|
admob.banner_ad_loaded.connect(_on_banner_ad_loaded)
|
||||||
|
admob.banner_ad_failed_to_load.connect(_on_banner_ad_failed_to_load)
|
||||||
|
admob.banner_ad_refreshed.connect(_on_banner_ad_refreshed)
|
||||||
|
admob.banner_ad_size_measured.connect(_on_banner_ad_size_measured)
|
||||||
|
|
||||||
|
if admob.is_initialization_completed:
|
||||||
|
if auto_load:
|
||||||
|
call_deferred("_deferred_load_ad")
|
||||||
|
else:
|
||||||
|
admob.initialization_completed.connect(_on_Admob_initialization_completed)
|
||||||
|
|
||||||
|
if inherit_from_admob_node:
|
||||||
|
_ad_unit_id = admob._banner_id
|
||||||
|
else:
|
||||||
|
if OS.has_feature("ios"):
|
||||||
|
if admob.is_real:
|
||||||
|
_ad_unit_id = ad_unit_id_ios_real
|
||||||
|
else:
|
||||||
|
_ad_unit_id = ad_unit_id_ios_debug
|
||||||
|
else:
|
||||||
|
if admob.is_real:
|
||||||
|
_ad_unit_id = ad_unit_id_android_real
|
||||||
|
else:
|
||||||
|
_ad_unit_id = ad_unit_id_android_debug
|
||||||
|
|
||||||
|
|
||||||
|
func _deferred_load_ad() -> void:
|
||||||
|
load_ad()
|
||||||
|
_mark_position_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# AD MANIPULATION
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
func load_ad() -> void:
|
||||||
|
if _ad_unit_id == "":
|
||||||
|
return
|
||||||
|
|
||||||
|
var req := LoadAdRequest.new()
|
||||||
|
req.set_ad_unit_id(_ad_unit_id)
|
||||||
|
req.set_ad_size(LoadAdRequest.RequestedAdSize.INLINE_ADAPTIVE)
|
||||||
|
|
||||||
|
if custom_minimum_size.x < MINIMUM_VALID_WIDTH:
|
||||||
|
GmpLogger.log_warn("Can't load ad. Invalid inline adaptive banner width: %.1f" % custom_minimum_size.x)
|
||||||
|
return
|
||||||
|
|
||||||
|
req.set_adaptive_width(round(custom_minimum_size.x))
|
||||||
|
|
||||||
|
if max_ad_height != -1:
|
||||||
|
req.set_adaptive_max_height(max_ad_height)
|
||||||
|
|
||||||
|
req.set_ad_position(LoadAdRequest.AdPosition.CUSTOM)
|
||||||
|
|
||||||
|
pending_load = true
|
||||||
|
admob.load_banner_ad(req)
|
||||||
|
|
||||||
|
|
||||||
|
func show_ad() -> void:
|
||||||
|
if not is_banner_visible:
|
||||||
|
admob.show_banner_ad(ad_id)
|
||||||
|
is_banner_visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func hide_ad() -> void:
|
||||||
|
if is_banner_visible:
|
||||||
|
admob.hide_banner_ad(ad_id)
|
||||||
|
is_banner_visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func move_ad(a_physical_position: Vector2) -> void:
|
||||||
|
admob.move_banner_ad(ad_id, a_physical_position.x, a_physical_position.y)
|
||||||
|
|
||||||
|
|
||||||
|
func remove_ad() -> void:
|
||||||
|
admob.remove_banner_ad(ad_id)
|
||||||
|
is_banner_visible = false
|
||||||
|
ad_id = ""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# POSITIONING
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if is_position_dirty:
|
||||||
|
_deferred_update_position()
|
||||||
|
is_position_dirty = false
|
||||||
|
else:
|
||||||
|
update_banner_position()
|
||||||
|
|
||||||
|
|
||||||
|
func _mark_position_dirty() -> void:
|
||||||
|
is_position_dirty = true
|
||||||
|
call_deferred("_deferred_update_position")
|
||||||
|
|
||||||
|
|
||||||
|
func _deferred_update_position() -> void:
|
||||||
|
update_banner_position()
|
||||||
|
|
||||||
|
|
||||||
|
func update_banner_position() -> void:
|
||||||
|
if ad_id == "":
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check basic tree visibility (Tabs, hidden parents)
|
||||||
|
if not is_visible_in_tree():
|
||||||
|
hide_ad()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Smart clipping (visibility logic)
|
||||||
|
var is_clipped_out: bool = false
|
||||||
|
|
||||||
|
if size.x > 1.0 and size.y > 1.0:
|
||||||
|
var virtual_rect := get_global_rect()
|
||||||
|
var original_area := virtual_rect.get_area()
|
||||||
|
var visible_region := virtual_rect
|
||||||
|
var parent = get_parent()
|
||||||
|
|
||||||
|
# Walk up the tree to find ScrollContainers or other clippers
|
||||||
|
while parent is Control:
|
||||||
|
if parent.clip_contents:
|
||||||
|
var parent_rect: Rect2 = parent.get_global_rect()
|
||||||
|
var intersection := visible_region.intersection(parent_rect)
|
||||||
|
|
||||||
|
# No intersection -> fully clipped (100%)
|
||||||
|
if intersection.get_area() <= 0:
|
||||||
|
is_clipped_out = true
|
||||||
|
break
|
||||||
|
|
||||||
|
visible_region = intersection
|
||||||
|
parent = parent.get_parent()
|
||||||
|
|
||||||
|
# Final check
|
||||||
|
if not get_viewport_rect().intersects(virtual_rect):
|
||||||
|
is_clipped_out = true
|
||||||
|
else:
|
||||||
|
# Calculate the percentage clipped
|
||||||
|
var visible_area := visible_region.get_area()
|
||||||
|
var clipped_ratio := 1.0 - (visible_area / original_area)
|
||||||
|
|
||||||
|
if clipped_ratio >= clip_threshold:
|
||||||
|
is_clipped_out = true
|
||||||
|
|
||||||
|
if is_clipped_out:
|
||||||
|
if is_banner_visible:
|
||||||
|
hide_ad()
|
||||||
|
else:
|
||||||
|
if not is_banner_visible:
|
||||||
|
show_ad()
|
||||||
|
|
||||||
|
move_ad(_godot_to_physical(global_position))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# SIGNAL HANDLERS
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
func _on_Admob_initialization_completed(_status: InitializationStatus) -> void:
|
||||||
|
if auto_load:
|
||||||
|
call_deferred("_deferred_load_ad")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_resized() -> void:
|
||||||
|
if ad_id == "":
|
||||||
|
return
|
||||||
|
|
||||||
|
if do_ignore_resize:
|
||||||
|
do_ignore_resize = false
|
||||||
|
return
|
||||||
|
|
||||||
|
if abs(size.x - last_width) < resize_threshold:
|
||||||
|
return
|
||||||
|
|
||||||
|
last_width = size.x
|
||||||
|
remove_ad()
|
||||||
|
call_deferred("_deferred_load_ad")
|
||||||
|
|
||||||
|
_mark_position_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_visibility_changed() -> void:
|
||||||
|
if ad_id != "":
|
||||||
|
if is_visible_in_tree():
|
||||||
|
show_ad()
|
||||||
|
else:
|
||||||
|
hide_ad()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_banner_ad_loaded(loaded_ad_info: AdInfo, _response: ResponseInfo) -> void:
|
||||||
|
if !pending_load:
|
||||||
|
return
|
||||||
|
|
||||||
|
if loaded_ad_info.get_load_ad_request().get_ad_size() != LoadAdRequest.RequestedAdSize.INLINE_ADAPTIVE:
|
||||||
|
return
|
||||||
|
|
||||||
|
if loaded_ad_info.get_load_ad_request().get_ad_unit_id().casecmp_to(_ad_unit_id) != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
pending_load = false
|
||||||
|
ad_id = loaded_ad_info.get_ad_id()
|
||||||
|
|
||||||
|
_apply_loaded_size(Vector2(loaded_ad_info.get_measured_width(), loaded_ad_info.get_measured_height()))
|
||||||
|
_mark_position_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_banner_ad_failed_to_load(failed_ad_info: AdInfo, _err: LoadAdError) -> void:
|
||||||
|
if failed_ad_info.get_load_ad_request().get_ad_size() != LoadAdRequest.RequestedAdSize.INLINE_ADAPTIVE:
|
||||||
|
return
|
||||||
|
|
||||||
|
if failed_ad_info.get_load_ad_request().get_ad_unit_id().casecmp_to(_ad_unit_id) != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
if pending_load:
|
||||||
|
pending_load = false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_banner_ad_size_measured(ad_info: AdInfo) -> void:
|
||||||
|
if ad_info.get_ad_id().casecmp_to(ad_id) == 0:
|
||||||
|
_apply_loaded_size(Vector2(ad_info.get_measured_width(), ad_info.get_measured_height()))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_banner_ad_refreshed(refreshed_ad_info: AdInfo, _response: ResponseInfo) -> void:
|
||||||
|
if refreshed_ad_info.get_ad_id().casecmp_to(ad_id) == 0:
|
||||||
|
hide_ad() # Prevent ad from briefly displaying out of position
|
||||||
|
_apply_loaded_size(Vector2(refreshed_ad_info.get_measured_width(), refreshed_ad_info.get_measured_height()))
|
||||||
|
_mark_position_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# UTILITY
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_loaded_size(a_size: Vector2) -> void:
|
||||||
|
do_ignore_resize = true
|
||||||
|
|
||||||
|
custom_minimum_size = a_size
|
||||||
|
size = a_size
|
||||||
|
|
||||||
|
|
||||||
|
func _calculate_scale_factor() -> Vector2:
|
||||||
|
var screen_size: Vector2 = DisplayServer.window_get_size() as Vector2
|
||||||
|
var virtual_size: Vector2 = get_viewport().get_visible_rect().size
|
||||||
|
return screen_size / virtual_size
|
||||||
|
|
||||||
|
|
||||||
|
func _physical_to_godot(a_physical_size: Vector2) -> Vector2:
|
||||||
|
return a_physical_size / _calculate_scale_factor()
|
||||||
|
|
||||||
|
|
||||||
|
func _godot_to_physical(a_godot_size: Vector2) -> Vector2:
|
||||||
|
return a_godot_size * _calculate_scale_factor()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dq4pb7uer2kjh
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[General]
|
||||||
|
is_real=false
|
||||||
|
|
||||||
|
[Debug]
|
||||||
|
app_id="ca-app-pub-3940256099942544~3347511713"
|
||||||
|
|
||||||
|
[Release]
|
||||||
|
app_id=""
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
|||||||
|
class_name AdCache
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var limit: int
|
||||||
|
var label: String
|
||||||
|
var remove_ad_callable: Callable
|
||||||
|
|
||||||
|
var _ads: Dictionary[String, Object]
|
||||||
|
var _ad_queue: Array[String]
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_limit: int, a_label: String, a_callable: Callable) -> void:
|
||||||
|
self.limit = a_limit
|
||||||
|
self.label = a_label
|
||||||
|
self.remove_ad_callable = a_callable
|
||||||
|
|
||||||
|
_ads = {}
|
||||||
|
_ad_queue = []
|
||||||
|
|
||||||
|
|
||||||
|
func is_empty() -> bool:
|
||||||
|
return _ads.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func has_key(a_key: String) -> bool:
|
||||||
|
return _ads.has(a_key)
|
||||||
|
|
||||||
|
|
||||||
|
func last_key() -> String:
|
||||||
|
return "" if _ad_queue.is_empty() else _ad_queue[0]
|
||||||
|
|
||||||
|
|
||||||
|
func all_keys() -> Array[String]:
|
||||||
|
return _ads.keys()
|
||||||
|
|
||||||
|
|
||||||
|
func cache(a_ad_id: String, a_ad_data: Object) -> void:
|
||||||
|
_ads[a_ad_id] = a_ad_data
|
||||||
|
_ad_queue.push_front(a_ad_id)
|
||||||
|
while _ads.size() > limit:
|
||||||
|
(
|
||||||
|
GmpLogger
|
||||||
|
. log_warn(
|
||||||
|
(
|
||||||
|
"%s: %s cache size (%d) has exceeded maximum (%d)"
|
||||||
|
% [
|
||||||
|
Admob.PLUGIN_SINGLETON_NAME,
|
||||||
|
label,
|
||||||
|
_ads.size(),
|
||||||
|
limit,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
remove_ad_callable.call(_erase_oldest())
|
||||||
|
|
||||||
|
|
||||||
|
func erase(a_ad_id: String) -> void:
|
||||||
|
_ad_queue.erase(a_ad_id)
|
||||||
|
_ads.erase(a_ad_id)
|
||||||
|
|
||||||
|
|
||||||
|
func erase_last() -> String:
|
||||||
|
var __removed_ad_id: String = _ad_queue.pop_front()
|
||||||
|
_ads.erase(__removed_ad_id)
|
||||||
|
return __removed_ad_id
|
||||||
|
|
||||||
|
|
||||||
|
func _erase_oldest() -> String:
|
||||||
|
var __removed_ad_id: String = _ad_queue.pop_back()
|
||||||
|
_ads.erase(__removed_ad_id)
|
||||||
|
return __removed_ad_id
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8k2i5b2rg7qx
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdmobAndroidExportConfig
|
||||||
|
extends AdmobExportConfig
|
||||||
|
|
||||||
|
const ANDROID_CONFIG_FILE_PATH: String = "res://addons/" + PLUGIN_NAME + "/android_export.cfg"
|
||||||
|
|
||||||
|
|
||||||
|
func get_config_file_path() -> String:
|
||||||
|
return ANDROID_CONFIG_FILE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
func load_platform_specific_export_config_from_node(a_node: Admob) -> Error:
|
||||||
|
debug_application_id = a_node.android_debug_application_id
|
||||||
|
real_application_id = a_node.android_real_application_id
|
||||||
|
|
||||||
|
return Error.OK
|
||||||
|
|
||||||
|
|
||||||
|
func print_loaded_config() -> void:
|
||||||
|
super.print_loaded_config()
|
||||||
|
GmpLogger.log_info("... debug_application_id: %s" % debug_application_id)
|
||||||
|
GmpLogger.log_info("... real_application_id: %s" % real_application_id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bd7jn56dvb8v4
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdmobExportConfig
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const PLUGIN_NODE_TYPE_NAME = "Admob"
|
||||||
|
const PLUGIN_NAME: String = "AdmobPlugin"
|
||||||
|
|
||||||
|
const CONFIG_FILE_SECTION_GENERAL: String = "General"
|
||||||
|
const CONFIG_FILE_SECTION_DEBUG: String = "Debug"
|
||||||
|
const CONFIG_FILE_SECTION_RELEASE: String = "Release"
|
||||||
|
const CONFIG_FILE_SECTION_MEDIATION: String = "Mediation"
|
||||||
|
|
||||||
|
const CONFIG_FILE_KEY_IS_REAL: String = "is_real"
|
||||||
|
const CONFIG_FILE_KEY_APP_ID: String = "app_id"
|
||||||
|
const CONFIG_FILE_KEY_ENABLED_NETWORKS: String = "enabled_networks"
|
||||||
|
|
||||||
|
var is_real: bool
|
||||||
|
var debug_application_id: String
|
||||||
|
var real_application_id: String
|
||||||
|
var enabled_mediation_networks: Array[MediationNetwork] = []
|
||||||
|
|
||||||
|
|
||||||
|
func get_config_file_path() -> String:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func export_config_file_exists() -> bool:
|
||||||
|
return FileAccess.file_exists(get_config_file_path())
|
||||||
|
|
||||||
|
|
||||||
|
func load_export_config_from_file() -> Error:
|
||||||
|
GmpLogger.log_info("Loading export config from file!")
|
||||||
|
|
||||||
|
var __result = Error.OK
|
||||||
|
|
||||||
|
var __config_file_path = get_config_file_path()
|
||||||
|
var __config_file = ConfigFile.new()
|
||||||
|
|
||||||
|
var __load_result = __config_file.load(__config_file_path)
|
||||||
|
if __load_result == Error.OK:
|
||||||
|
is_real = __config_file.get_value(CONFIG_FILE_SECTION_GENERAL, CONFIG_FILE_KEY_IS_REAL)
|
||||||
|
debug_application_id = __config_file.get_value(CONFIG_FILE_SECTION_DEBUG, CONFIG_FILE_KEY_APP_ID)
|
||||||
|
real_application_id = __config_file.get_value(CONFIG_FILE_SECTION_RELEASE, CONFIG_FILE_KEY_APP_ID)
|
||||||
|
|
||||||
|
if __config_file.has_section(CONFIG_FILE_SECTION_MEDIATION):
|
||||||
|
if __config_file.has_section_key(CONFIG_FILE_SECTION_MEDIATION, CONFIG_FILE_KEY_ENABLED_NETWORKS):
|
||||||
|
var __network_array := (
|
||||||
|
Array(
|
||||||
|
__config_file.get_value(CONFIG_FILE_SECTION_MEDIATION, CONFIG_FILE_KEY_ENABLED_NETWORKS, []),
|
||||||
|
TYPE_STRING,
|
||||||
|
&"",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
as Array[String]
|
||||||
|
)
|
||||||
|
|
||||||
|
for __network in __network_array:
|
||||||
|
if MediationNetwork.is_valid_tag(__network):
|
||||||
|
enabled_mediation_networks.append(MediationNetwork.get_by_tag(__network))
|
||||||
|
else:
|
||||||
|
GmpLogger.log_error("Invalid network tag '%s' in file %s!" % [__network, __config_file_path])
|
||||||
|
else:
|
||||||
|
(
|
||||||
|
GmpLogger
|
||||||
|
. log_error(
|
||||||
|
(
|
||||||
|
"Missing key %s in section %s of %s!"
|
||||||
|
% [CONFIG_FILE_KEY_ENABLED_NETWORKS, CONFIG_FILE_SECTION_MEDIATION, __config_file_path]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_real == null or debug_application_id == null or real_application_id == null:
|
||||||
|
__result = Error.ERR_INVALID_DATA
|
||||||
|
else:
|
||||||
|
__result = load_platform_specific_export_config_from_file(__config_file)
|
||||||
|
|
||||||
|
if __result != Error.OK:
|
||||||
|
GmpLogger.log_error("Invalid export config file %s!" % __config_file_path)
|
||||||
|
else:
|
||||||
|
__result = Error.ERR_CANT_OPEN
|
||||||
|
GmpLogger.log_error("Failed to open export config file %s!" % __config_file_path)
|
||||||
|
|
||||||
|
if __result == Error.OK:
|
||||||
|
print_loaded_config()
|
||||||
|
|
||||||
|
return __result
|
||||||
|
|
||||||
|
|
||||||
|
func load_platform_specific_export_config_from_file(a_config_file: ConfigFile) -> Error:
|
||||||
|
return Error.OK
|
||||||
|
|
||||||
|
|
||||||
|
func load_export_config_from_node() -> Error:
|
||||||
|
GmpLogger.log_info("Loading export config from node!")
|
||||||
|
|
||||||
|
var __result = Error.OK
|
||||||
|
|
||||||
|
var __admob_node: Admob
|
||||||
|
|
||||||
|
GmpLogger.log_info("Searching edited scene for %s node..." % PLUGIN_NODE_TYPE_NAME)
|
||||||
|
var __edited_scene_root: Node = EditorInterface.get_edited_scene_root()
|
||||||
|
if __edited_scene_root:
|
||||||
|
__admob_node = get_plugin_node(__edited_scene_root)
|
||||||
|
else:
|
||||||
|
GmpLogger.log_info("No edited scene found")
|
||||||
|
|
||||||
|
if not __admob_node:
|
||||||
|
GmpLogger.log_info("Searching main scene for %s node..." % PLUGIN_NODE_TYPE_NAME)
|
||||||
|
var __main_scene_path: String = ProjectSettings.get_setting("application/run/main_scene")
|
||||||
|
if __main_scene_path and __main_scene_path != "":
|
||||||
|
var __packed_main: PackedScene = load(__main_scene_path)
|
||||||
|
if __packed_main:
|
||||||
|
var __main_scene = __packed_main.instantiate()
|
||||||
|
__admob_node = get_plugin_node(__main_scene)
|
||||||
|
else:
|
||||||
|
GmpLogger.log_info("Main scene path not defined in the project settings")
|
||||||
|
|
||||||
|
if not __admob_node:
|
||||||
|
GmpLogger.log_info("Searching all project scenes for %s node..." % PLUGIN_NODE_TYPE_NAME)
|
||||||
|
|
||||||
|
var collect_scene_paths: Callable = func(
|
||||||
|
a_dir_path: String,
|
||||||
|
a_collected_paths: Array[String],
|
||||||
|
a_recursive_func: Callable,
|
||||||
|
) -> void:
|
||||||
|
for __file in DirAccess.get_files_at(a_dir_path):
|
||||||
|
if __file.ends_with(".tscn") or __file.ends_with(".scn"):
|
||||||
|
a_collected_paths.append(a_dir_path.path_join(__file))
|
||||||
|
|
||||||
|
for __dir in DirAccess.get_directories_at(a_dir_path):
|
||||||
|
a_recursive_func.call(a_dir_path.path_join(__dir), a_collected_paths, a_recursive_func)
|
||||||
|
|
||||||
|
var __scene_paths: Array[String] = []
|
||||||
|
collect_scene_paths.call("res://", __scene_paths, collect_scene_paths)
|
||||||
|
|
||||||
|
for __scene_path in __scene_paths:
|
||||||
|
var packed: PackedScene = load(__scene_path)
|
||||||
|
if packed:
|
||||||
|
var __instance = packed.instantiate()
|
||||||
|
var __found_node: Admob = get_plugin_node(__instance)
|
||||||
|
if __found_node:
|
||||||
|
GmpLogger.log_info("Found %s node in scene: %s" % [PLUGIN_NODE_TYPE_NAME, __scene_path])
|
||||||
|
__admob_node = __found_node
|
||||||
|
break
|
||||||
|
|
||||||
|
# Load configuration if node found
|
||||||
|
if __admob_node:
|
||||||
|
is_real = __admob_node.is_real
|
||||||
|
enabled_mediation_networks = MediationNetwork.get_all_enabled(__admob_node.enabled_networks)
|
||||||
|
|
||||||
|
__result = load_platform_specific_export_config_from_node(__admob_node)
|
||||||
|
if __result == Error.OK:
|
||||||
|
print_loaded_config()
|
||||||
|
else:
|
||||||
|
GmpLogger.log_error("Invalid %s node for %s!" % [PLUGIN_NODE_TYPE_NAME, PLUGIN_NAME])
|
||||||
|
else:
|
||||||
|
GmpLogger.log_error("%s failed to find %s node!" % [PLUGIN_NAME, PLUGIN_NODE_TYPE_NAME])
|
||||||
|
__result = Error.ERR_UNCONFIGURED
|
||||||
|
|
||||||
|
return __result
|
||||||
|
|
||||||
|
|
||||||
|
func load_platform_specific_export_config_from_node(a_node: Admob) -> Error:
|
||||||
|
return Error.OK
|
||||||
|
|
||||||
|
|
||||||
|
func print_loaded_config() -> void:
|
||||||
|
GmpLogger.log_info("Loaded export configuration settings:")
|
||||||
|
GmpLogger.log_info(" is_real: %s" % ("true" if is_real else "false"))
|
||||||
|
GmpLogger.log_info(
|
||||||
|
" enabled_mediation_networks: %s" % MediationNetwork.generate_tag_list(enabled_mediation_networks)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func get_plugin_node(a_node: Node) -> Admob:
|
||||||
|
var __result: Admob
|
||||||
|
|
||||||
|
if a_node is Admob:
|
||||||
|
__result = a_node
|
||||||
|
elif a_node.get_child_count() > 0:
|
||||||
|
for __child in a_node.get_children():
|
||||||
|
var __child_result = get_plugin_node(__child)
|
||||||
|
if __child_result is Admob:
|
||||||
|
__result = __child_result
|
||||||
|
break
|
||||||
|
|
||||||
|
return __result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://byw0f6m12b2s2
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdmobIosExportConfig
|
||||||
|
extends AdmobExportConfig
|
||||||
|
|
||||||
|
const IOS_CONFIG_FILE_PATH: String = "res://addons/" + PLUGIN_NAME + "/ios_export.cfg"
|
||||||
|
|
||||||
|
const CONFIG_FILE_SECTION_ATT: String = "ATT"
|
||||||
|
|
||||||
|
const CONFIG_FILE_KEY_ATT_ENABLED: String = "att_enabled"
|
||||||
|
const CONFIG_FILE_KEY_ATT_TEXT: String = "att_text"
|
||||||
|
|
||||||
|
var att_enabled: bool
|
||||||
|
var att_text: String
|
||||||
|
|
||||||
|
|
||||||
|
func get_config_file_path() -> String:
|
||||||
|
return IOS_CONFIG_FILE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
func load_platform_specific_export_config_from_file(a_config_file: ConfigFile) -> Error:
|
||||||
|
att_enabled = a_config_file.get_value(CONFIG_FILE_SECTION_ATT, CONFIG_FILE_KEY_ATT_ENABLED, false)
|
||||||
|
att_text = a_config_file.get_value(CONFIG_FILE_SECTION_ATT, CONFIG_FILE_KEY_ATT_TEXT)
|
||||||
|
|
||||||
|
return Error.OK
|
||||||
|
|
||||||
|
|
||||||
|
func load_platform_specific_export_config_from_node(a_node: Admob) -> Error:
|
||||||
|
debug_application_id = a_node.ios_debug_application_id
|
||||||
|
real_application_id = a_node.ios_real_application_id
|
||||||
|
att_enabled = a_node.att_enabled
|
||||||
|
att_text = a_node.att_text
|
||||||
|
|
||||||
|
return Error.OK
|
||||||
|
|
||||||
|
|
||||||
|
func print_loaded_config() -> void:
|
||||||
|
super.print_loaded_config()
|
||||||
|
GmpLogger.log_info("... debug_application_id: %s" % debug_application_id)
|
||||||
|
GmpLogger.log_info("... real_application_id: %s" % real_application_id)
|
||||||
|
GmpLogger.log_info("... att_enabled: %s" % ("true" if att_enabled else "false"))
|
||||||
|
GmpLogger.log_info("... att_text: %s" % att_text)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://hamb6dlvl4bc
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cmkpgeo3anvuw"
|
||||||
|
path="res://.godot/imported/icon.png-928f79d906c5a286de384358dfb1ba26.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://addons/AdmobPlugin/icon.png"
|
||||||
|
dest_files=["res://.godot/imported/icon.png-928f79d906c5a286de384358dfb1ba26.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
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=false
|
||||||
|
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=1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[General]
|
||||||
|
is_real=false
|
||||||
|
|
||||||
|
[Debug]
|
||||||
|
app_id="ca-app-pub-3940256099942544~1458002511"
|
||||||
|
|
||||||
|
[Release]
|
||||||
|
app_id=""
|
||||||
|
|
||||||
|
[ATT]
|
||||||
|
att_enabled=false
|
||||||
|
att_text="Deine Erlaubnis hilft uns, Werbung zu messen und das Spiel zu verbessern."
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdError
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const CODE_PROPERTY := &"code"
|
||||||
|
const DOMAIN_PROPERTY := &"domain"
|
||||||
|
const MESSAGE_PROPERTY := &"message"
|
||||||
|
const CAUSE_PROPERTY := &"cause"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_code() -> int:
|
||||||
|
return _data[CODE_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_domain() -> String:
|
||||||
|
return _data[DOMAIN_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_message() -> String:
|
||||||
|
return _data[MESSAGE_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_cause() -> AdError:
|
||||||
|
return AdError.new(_data[CAUSE_PROPERTY]) if _data.has(CAUSE_PROPERTY) else null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://0att8tjqjm8t
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdInfo
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const AD_ID_PROPERTY := &"ad_id"
|
||||||
|
const MEASURED_WIDTH_PROPERTY := &"measured_width"
|
||||||
|
const MEASURED_HEIGHT_PROPERTY := &"measured_height"
|
||||||
|
const IS_COLLAPSIBLE_PROPERTY := &"is_collapsible"
|
||||||
|
const LOAD_AD_REQUEST_PROPERTY := &"load_ad_request"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_id() -> String:
|
||||||
|
return _data[AD_ID_PROPERTY] if _data.has(AD_ID_PROPERTY) else ""
|
||||||
|
|
||||||
|
|
||||||
|
func get_measured_width() -> int:
|
||||||
|
return _data[MEASURED_WIDTH_PROPERTY] if _data.has(MEASURED_WIDTH_PROPERTY) else 0
|
||||||
|
|
||||||
|
|
||||||
|
func get_measured_height() -> int:
|
||||||
|
return _data[MEASURED_HEIGHT_PROPERTY] if _data.has(MEASURED_HEIGHT_PROPERTY) else 0
|
||||||
|
|
||||||
|
|
||||||
|
func get_is_collapsible() -> bool:
|
||||||
|
return _data[IS_COLLAPSIBLE_PROPERTY] if _data.has(IS_COLLAPSIBLE_PROPERTY) else false
|
||||||
|
|
||||||
|
|
||||||
|
func get_load_ad_request() -> LoadAdRequest:
|
||||||
|
return LoadAdRequest.new(_data[LOAD_AD_REQUEST_PROPERTY]) if _data.has(LOAD_AD_REQUEST_PROPERTY) else null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://pm7kx5541j4
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdSize
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const WIDTH_PROPERTY := &"width"
|
||||||
|
const HEIGHT_PROPERTY := &"height"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data if a_data else {}
|
||||||
|
|
||||||
|
|
||||||
|
func get_width() -> int:
|
||||||
|
return _data[WIDTH_PROPERTY] if _data.has(WIDTH_PROPERTY) else 0
|
||||||
|
|
||||||
|
|
||||||
|
func get_height() -> int:
|
||||||
|
return _data[HEIGHT_PROPERTY] if _data.has(HEIGHT_PROPERTY) else 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://1y453yingj1w
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdapterResponseInfo
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const AD_ERROR_PROPERTY := &"ad_error"
|
||||||
|
const AD_SOURCE_ID_PROPERTY := &"ad_source_id"
|
||||||
|
const AD_SOURCE_INSTANCE_ID_PROPERTY := &"ad_source_instance_id"
|
||||||
|
const AD_SOURCE_INSTANCE_NAME_PROPERTY := &"ad_source_instance_name"
|
||||||
|
const AD_SOURCE_NAME_PROPERTY := &"ad_source_name"
|
||||||
|
const ADAPTER_CLASS_NAME_PROPERTY := &"adapter_class_name"
|
||||||
|
const NETWORK_TAG_PROPERTY := &"network_tag"
|
||||||
|
const LATENCY_PROPERTY := &"latency"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_error() -> AdError:
|
||||||
|
return AdError.new(_data[AD_ERROR_PROPERTY]) if _data.has(AD_ERROR_PROPERTY) else null
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_source_id() -> String:
|
||||||
|
return _data[AD_SOURCE_ID_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_source_instance_id() -> String:
|
||||||
|
return _data[AD_SOURCE_INSTANCE_ID_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_source_instance_name() -> String:
|
||||||
|
return _data[AD_SOURCE_INSTANCE_NAME_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_source_name() -> String:
|
||||||
|
return _data[AD_SOURCE_NAME_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_adapter_class_name() -> String:
|
||||||
|
return _data[ADAPTER_CLASS_NAME_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_network_tag() -> String:
|
||||||
|
return _data[NETWORK_TAG_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_latency() -> int:
|
||||||
|
return _data[LATENCY_PROPERTY]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://kwsy2dkniyn8
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdapterStatus
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const ADAPTER_CLASS_PROPERTY := &"adapter_class"
|
||||||
|
const LATENCY_PROPERTY := &"latency"
|
||||||
|
const INITIALIZATION_STATE_PROPERTY := &"initialization_state"
|
||||||
|
const DESCRIPTION_PROPERTY := &"description"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_adapter_class() -> String:
|
||||||
|
return _data[ADAPTER_CLASS_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_latency() -> int:
|
||||||
|
return _data[LATENCY_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_initialization_state() -> String:
|
||||||
|
return _data[INITIALIZATION_STATE_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_description() -> String:
|
||||||
|
return _data[DESCRIPTION_PROPERTY]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://scnfdeiyquod
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdmobConfig
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
enum ContentRating {
|
||||||
|
G, ## G means "General Audiences" and is suitable for all ages, containing no objectionable material
|
||||||
|
## (minimal violence, no nudity or sex scenes, and no drug use).
|
||||||
|
PG, ## PG stands for "Parental Guidance Suggested," meaning that some material may not be suitable for young
|
||||||
|
## children (may include themes, language, or mild violence).
|
||||||
|
T, ## T means Teen, and indicates content suitable for ages 13 and older (can include violence, suggestive themes,
|
||||||
|
## crude humor, simulated gambling, and/or infrequent strong language).
|
||||||
|
MA, ## MA stands for "Mature Audience", signifying that the material is intended for adults (may include graphic
|
||||||
|
## violence, strong language, or explicit sexual activity).
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TagForChildDirectedTreatment {
|
||||||
|
UNSPECIFIED = -1, ## Means that you have not provided a specific instruction for your app's content to be treated
|
||||||
|
## as child-directed for COPPA purposes.
|
||||||
|
FALSE = 0, ## Means you are indicating that your app's content is not child-directed for the purposes of the
|
||||||
|
## Children's Online Privacy Protection Act (COPPA).
|
||||||
|
TRUE = 1, ## Tells ad networks to treat ad requests as if the content is directed towards children, which is
|
||||||
|
## important for complying with regulations like COPPA.
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TagForUnderAgeOfConsent {
|
||||||
|
UNSPECIFIED = -1, ## Means you have not provided any information on whether the user is under the age of consent,
|
||||||
|
## so the system does not apply any specific treatment for these users based on this tag alone.
|
||||||
|
FALSE = 0, ## Indicates that you do not want the specific ad request to receive the special handling suitable for
|
||||||
|
## users under the age of consent in the EEA, the UK, and Switzerland.
|
||||||
|
TRUE = 1, ## Indicates that you want the ad request to be handled in a manner suitable for users under the age of
|
||||||
|
## consent within the EEA, the UK, and Switzerland.
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PersonalizationState {
|
||||||
|
DEFAULT = 0, ## By default, the SDK will attempt to serve personalized ads based on the user's past behavior and
|
||||||
|
## interests.
|
||||||
|
ENABLED = 1, ## Indicates that the Google Mobile Ads SDK is authorized to serve ads that are tailored to the user
|
||||||
|
## based on past activity and collected data.
|
||||||
|
DISABLED = 2, ## Means that ad requests are set to serve non-personalized ads (NPA) only.
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_KEY_IS_REAL := &"is_real"
|
||||||
|
const DATA_KEY_MAX_AD_CONTENT_RATING := &"max_ad_content_rating"
|
||||||
|
const DATA_KEY_CHILD_DIRECTED_TREATMENT := &"tag_for_child_directed_treatment"
|
||||||
|
const DATA_KEY_UNDER_AGE_OF_CONSENT := &"tag_for_under_age_of_consent"
|
||||||
|
const DATA_KEY_FIRST_PARTY_ID_ENABLED := &"first_party_id_enabled"
|
||||||
|
const DATA_KEY_PERSONALIZATION_STATE := &"personalization_state"
|
||||||
|
const DATA_KEY_TEST_DEVICE_IDS := &"test_device_ids"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init():
|
||||||
|
_data = {
|
||||||
|
DATA_KEY_TEST_DEVICE_IDS: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func set_is_real(a_value: bool) -> AdmobConfig:
|
||||||
|
_data[DATA_KEY_IS_REAL] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_max_ad_content_rating(a_value: ContentRating) -> AdmobConfig:
|
||||||
|
_data[DATA_KEY_MAX_AD_CONTENT_RATING] = ContentRating.keys()[a_value]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_child_directed_treatment(a_value: TagForChildDirectedTreatment) -> AdmobConfig:
|
||||||
|
_data[DATA_KEY_CHILD_DIRECTED_TREATMENT] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_under_age_of_consent(a_value: TagForUnderAgeOfConsent) -> AdmobConfig:
|
||||||
|
_data[DATA_KEY_UNDER_AGE_OF_CONSENT] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_first_party_id_enabled(a_value: bool) -> AdmobConfig:
|
||||||
|
_data[DATA_KEY_FIRST_PARTY_ID_ENABLED] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_personalization_state(a_value: PersonalizationState) -> AdmobConfig:
|
||||||
|
_data[DATA_KEY_PERSONALIZATION_STATE] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_test_device_ids(a_value: Array) -> AdmobConfig:
|
||||||
|
if a_value == null:
|
||||||
|
_data[DATA_KEY_TEST_DEVICE_IDS] = []
|
||||||
|
else:
|
||||||
|
_data[DATA_KEY_TEST_DEVICE_IDS] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func add_test_device_id(a_value: String) -> AdmobConfig:
|
||||||
|
_data[DATA_KEY_TEST_DEVICE_IDS].append(a_value)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_raw_data() -> Dictionary:
|
||||||
|
return _data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ihpl6qb5lkw2
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#
|
||||||
|
# © 2026-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name AdmobSettings
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const DATA_KEY_AD_VOLUME := &"ad_volume"
|
||||||
|
const DATA_KEY_ADS_MUTED := &"ads_muted"
|
||||||
|
const DATA_KEY_APPLY_AT_STARTUP := &"apply_at_startup"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary = {}) -> void:
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func set_ad_volume(a_value: float) -> AdmobSettings:
|
||||||
|
_data[DATA_KEY_AD_VOLUME] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_volume() -> float:
|
||||||
|
return _data[DATA_KEY_AD_VOLUME]
|
||||||
|
|
||||||
|
|
||||||
|
func set_ads_muted(a_value: bool) -> AdmobSettings:
|
||||||
|
_data[DATA_KEY_ADS_MUTED] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func are_ads_muted() -> bool:
|
||||||
|
return _data[DATA_KEY_ADS_MUTED]
|
||||||
|
|
||||||
|
|
||||||
|
func set_apply_at_startup(a_value: bool) -> AdmobSettings:
|
||||||
|
_data[DATA_KEY_APPLY_AT_STARTUP] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_apply_at_startup() -> bool:
|
||||||
|
return _data[DATA_KEY_APPLY_AT_STARTUP]
|
||||||
|
|
||||||
|
|
||||||
|
func get_raw_data() -> Dictionary:
|
||||||
|
return _data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://u1bg67vtlhd8
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name ConsentRequestParameters
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
enum DebugGeography {
|
||||||
|
NOT_SET = -1, ## Don't specify location. (Actual location will be used.)
|
||||||
|
DISABLED = 0, ## Use actual location.
|
||||||
|
EEA = 1, ## Use European Economic Area.
|
||||||
|
NOT_EEA = 2, ## Deprecated.
|
||||||
|
REGULATED_US_STATE = 3, ## Use a regulated state of USA. (ie. California)
|
||||||
|
OTHER = 4, ## Use any non-regulated location.
|
||||||
|
}
|
||||||
|
|
||||||
|
const IS_REAL_PROPERTY := &"is_real"
|
||||||
|
const TAG_FOR_UNDER_AGE_OF_CONSENT_PROPERTY := &"tag_for_under_age_of_consent"
|
||||||
|
const DEBUG_GEOGRAPHY_PROPERTY := &"debug_geography"
|
||||||
|
const TEST_DEVICE_HASHED_IDS_PROPERTY := &"test_device_hashed_ids"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init():
|
||||||
|
_data = {
|
||||||
|
TEST_DEVICE_HASHED_IDS_PROPERTY: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func set_is_real(a_value: bool) -> ConsentRequestParameters:
|
||||||
|
_data[IS_REAL_PROPERTY] = a_value
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_tag_for_under_age_of_consent(a_value: AdmobConfig.TagForUnderAgeOfConsent) -> ConsentRequestParameters:
|
||||||
|
match a_value:
|
||||||
|
AdmobConfig.TagForChildDirectedTreatment.TRUE:
|
||||||
|
_data[TAG_FOR_UNDER_AGE_OF_CONSENT_PROPERTY] = true
|
||||||
|
AdmobConfig.TagForChildDirectedTreatment.FALSE:
|
||||||
|
_data[TAG_FOR_UNDER_AGE_OF_CONSENT_PROPERTY] = false
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_debug_geography(a_value: DebugGeography) -> ConsentRequestParameters:
|
||||||
|
_data[DEBUG_GEOGRAPHY_PROPERTY] = a_value
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func add_test_device_hashed_id(a_value: String) -> ConsentRequestParameters:
|
||||||
|
_data[TEST_DEVICE_HASHED_IDS_PROPERTY].append(a_value)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_device_ids() -> Array:
|
||||||
|
return _data[TEST_DEVICE_HASHED_IDS_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_raw_data() -> Dictionary:
|
||||||
|
return _data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://5nuqawjs80oi
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name FormError
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const CODE_PROPERTY := &"code"
|
||||||
|
const MESSAGE_PROPERTY := &"message"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data if a_data else {}
|
||||||
|
|
||||||
|
|
||||||
|
func get_code() -> int:
|
||||||
|
return _data[CODE_PROPERTY] if _data.has(CODE_PROPERTY) else 0
|
||||||
|
|
||||||
|
|
||||||
|
func get_message() -> String:
|
||||||
|
return _data[MESSAGE_PROPERTY] if _data.has(MESSAGE_PROPERTY) else ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://f4ywykxwjpal
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name InitializationStatus
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
if a_data == null:
|
||||||
|
_data = {}
|
||||||
|
else:
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_network_tags() -> Array:
|
||||||
|
return _data.keys()
|
||||||
|
|
||||||
|
|
||||||
|
func get_adapter_status(a_network_tag: String) -> AdapterStatus:
|
||||||
|
return AdapterStatus.new(_data[a_network_tag]) if _data.has(a_network_tag) else null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b0oie3ngw68ha
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name LoadAdError
|
||||||
|
extends AdError
|
||||||
|
|
||||||
|
const RESPONSE_INFO_PROPERTY := &"response_info"
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
super(a_data)
|
||||||
|
|
||||||
|
|
||||||
|
func get_response_info() -> ResponseInfo:
|
||||||
|
return ResponseInfo.new(_data[RESPONSE_INFO_PROPERTY]) if _data.has(RESPONSE_INFO_PROPERTY) else null
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ddnpjk4ksplb5
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name LoadAdRequest
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
enum AdPosition {
|
||||||
|
TOP, ## Ad will be anchored at the top of the screen.
|
||||||
|
BOTTOM, ## Ad will be anchored at the bottom of the screen.
|
||||||
|
LEFT, ## Ad will be anchored to the left of the screen.
|
||||||
|
RIGHT, ## Ad will be anchored to the right of the screen.
|
||||||
|
TOP_LEFT, ## Ad will be anchored at the top-left of the screen.
|
||||||
|
TOP_RIGHT, ## Ad will be anchored at the top-right of the screen.
|
||||||
|
BOTTOM_LEFT, ## Ad will be anchored at the bottom-left of the screen.
|
||||||
|
BOTTOM_RIGHT, ## Ad will be anchored at the bottom-right of the screen.
|
||||||
|
CENTER, ## Ad will be anchored at the center of the screen.
|
||||||
|
CUSTOM, ## Ad position will be set dynamically.
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RequestedAdSize {
|
||||||
|
BANNER, ## A standard ad size that refers to a rectangular ad unit, most commonly 320x50 density-independent pixels
|
||||||
|
## (dp).
|
||||||
|
LARGE_BANNER, ## A fixed-size banner with dimensions of 320x100 density-independent pixels (dp).
|
||||||
|
MEDIUM_RECTANGLE, ## A standard 300x250 pixel ad, often referred to as the Interactive Advertising Bureau (IAB)
|
||||||
|
## medium rectangle.
|
||||||
|
FULL_BANNER, ## An ad size constant for an Interactive Advertising Bureau (IAB) full-size banner, which has a fixed
|
||||||
|
## size of 468x60 density-independent pixels (dp).
|
||||||
|
LEADERBOARD, ## A banner ad with dimensions of 728 pixels wide by 90 pixels tall. This is a standard display ad
|
||||||
|
## size that is designed for tablet screens.
|
||||||
|
ADAPTIVE, ## An ad that automatically adjusts its width and height to fit the device's available screen space,
|
||||||
|
## providing the best-performing banner size for that specific layout.
|
||||||
|
INLINE_ADAPTIVE, ## An ad that dynamically optimizes its height for the available inline content width, ensuring a
|
||||||
|
## smoothly integrated banner that fits naturally within scrollable or in-feed layouts.
|
||||||
|
SKYSCRAPER, ## Deprecated
|
||||||
|
FLUID, ## Deprecated
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CollapsiblePosition {
|
||||||
|
DISABLED, ## The banner ad will not be collapsible.
|
||||||
|
TOP, ## The banner ad will be collapsible from bottom to top.
|
||||||
|
BOTTOM, ## The banner ad will be collapsible from top to bottom.
|
||||||
|
}
|
||||||
|
|
||||||
|
## Controls which aspect ratios the SDK should prefer when requesting native ad media.
|
||||||
|
## Maps to NativeAdOptions.NATIVE_MEDIA_ASPECT_RATIO_* constants.
|
||||||
|
enum NativeMediaAspectRatio {
|
||||||
|
UNKNOWN = 0, ## No preference for aspect ratio (SDK default).
|
||||||
|
ANY = 1, ## Any aspect ratio is acceptable.
|
||||||
|
LANDSCAPE = 2, ## Prefer landscape (wider than tall) media.
|
||||||
|
PORTRAIT = 3, ## Prefer portrait (taller than wide) media.
|
||||||
|
SQUARE = 4, ## Prefer square (1:1) media.
|
||||||
|
}
|
||||||
|
|
||||||
|
## Controls where the AdChoices icon is placed within the native ad view.
|
||||||
|
## Maps to NativeAdOptions.ADCHOICES_* constants.
|
||||||
|
enum NativeAdChoicesPlacement {
|
||||||
|
TOP_LEFT = 0, ## AdChoices icon in the top-left corner.
|
||||||
|
TOP_RIGHT = 1, ## AdChoices icon in the top-right corner (SDK default).
|
||||||
|
BOTTOM_RIGHT = 2, ## AdChoices icon in the bottom-right corner.
|
||||||
|
BOTTOM_LEFT = 3, ## AdChoices icon in the bottom-left corner.
|
||||||
|
}
|
||||||
|
|
||||||
|
## Controls how icon/image assets are scaled inside their ImageView.
|
||||||
|
## Maps directly to Android's ImageView.ScaleType enum.
|
||||||
|
enum NativeImageScaleType {
|
||||||
|
MATRIX = 0, ## Scale using the image Matrix.
|
||||||
|
FIT_XY = 1, ## Scale to fill the view bounds, ignoring aspect ratio.
|
||||||
|
FIT_START = 2, ## Scale to fit within bounds, aligned to top-left.
|
||||||
|
FIT_CENTER = 3, ## Scale to fit within bounds, centered (Android default).
|
||||||
|
FIT_END = 4, ## Scale to fit within bounds, aligned to bottom-right.
|
||||||
|
CENTER = 5, ## Center image without scaling.
|
||||||
|
CENTER_CROP = 6, ## Scale so shorter dimension fits; crop the longer one.
|
||||||
|
CENTER_INSIDE = 7, ## Scale to fit within bounds if larger; center otherwise.
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLAPSIBLE_POSITION_NAMES: Dictionary = {
|
||||||
|
CollapsiblePosition.TOP: "top",
|
||||||
|
CollapsiblePosition.BOTTOM: "bottom",
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATA_KEY_AD_UNIT_ID := &"ad_unit_id"
|
||||||
|
const DATA_KEY_REQUEST_AGENT := &"request_agent"
|
||||||
|
const DATA_KEY_AD_SIZE := &"ad_size"
|
||||||
|
const DATA_KEY_ADAPTIVE_WIDTH := &"adaptive_width"
|
||||||
|
const DATA_KEY_ADAPTIVE_MAX_HEIGHT := &"adaptive_max_height"
|
||||||
|
const DATA_KEY_AD_POSITION := &"ad_position"
|
||||||
|
const DATA_KEY_COLLAPSIBLE_POSITION := &"collapsible_position"
|
||||||
|
const DATA_KEY_ANCHOR_TO_SAFE_AREA := &"anchor_to_safe_area"
|
||||||
|
const DATA_KEY_KEYWORDS := &"keywords"
|
||||||
|
const DATA_KEY_USER_ID := &"user_id"
|
||||||
|
const DATA_KEY_CUSTOM_DATA := &"custom_data"
|
||||||
|
const DATA_KEY_NETWORK_EXTRAS := &"network_extras"
|
||||||
|
|
||||||
|
# Native ad option keys — sent to the Java layer inside the ad-request dictionary.
|
||||||
|
const DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO := &"native_media_aspect_ratio"
|
||||||
|
const DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS := &"native_return_urls_for_image_assets"
|
||||||
|
const DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES := &"native_request_multiple_images"
|
||||||
|
const DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT := &"native_ad_choices_placement"
|
||||||
|
const DATA_KEY_NATIVE_IMAGE_SCALE_TYPE := &"native_image_scale_type"
|
||||||
|
const DATA_KEY_NATIVE_DISABLE_VALIDATOR := &"native_disable_validator"
|
||||||
|
|
||||||
|
const DEFAULT_DATA: Dictionary = {
|
||||||
|
DATA_KEY_KEYWORDS: [],
|
||||||
|
DATA_KEY_NETWORK_EXTRAS: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary = DEFAULT_DATA.duplicate()) -> void:
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func set_ad_unit_id(a_value: String) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_AD_UNIT_ID] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_unit_id() -> String:
|
||||||
|
return _data[DATA_KEY_AD_UNIT_ID]
|
||||||
|
|
||||||
|
|
||||||
|
func set_request_agent(a_value: String) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_REQUEST_AGENT] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_request_agent() -> String:
|
||||||
|
return _data[DATA_KEY_REQUEST_AGENT]
|
||||||
|
|
||||||
|
|
||||||
|
func set_ad_size(a_value: RequestedAdSize) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_AD_SIZE] = RequestedAdSize.keys()[a_value]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_size() -> RequestedAdSize:
|
||||||
|
return RequestedAdSize[_data[DATA_KEY_AD_SIZE]] if _data.has(DATA_KEY_AD_SIZE) else RequestedAdSize.BANNER
|
||||||
|
|
||||||
|
|
||||||
|
func set_adaptive_width(a_value: int) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_ADAPTIVE_WIDTH] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_adaptive_width() -> int:
|
||||||
|
return _data[DATA_KEY_ADAPTIVE_WIDTH]
|
||||||
|
|
||||||
|
|
||||||
|
func set_adaptive_max_height(a_value: int) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_ADAPTIVE_MAX_HEIGHT] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_adaptive_max_height() -> int:
|
||||||
|
return _data[DATA_KEY_ADAPTIVE_MAX_HEIGHT]
|
||||||
|
|
||||||
|
|
||||||
|
func set_ad_position(a_value: AdPosition) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_AD_POSITION] = AdPosition.keys()[a_value]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_ad_position() -> AdPosition:
|
||||||
|
return AdPosition[_data[DATA_KEY_AD_POSITION]] if _data.has(DATA_KEY_AD_POSITION) else AdPosition.CUSTOM
|
||||||
|
|
||||||
|
|
||||||
|
func set_collapsible_position(a_value: CollapsiblePosition) -> LoadAdRequest:
|
||||||
|
if a_value != CollapsiblePosition.DISABLED:
|
||||||
|
_data[DATA_KEY_COLLAPSIBLE_POSITION] = COLLAPSIBLE_POSITION_NAMES[a_value]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_collapsible_position() -> CollapsiblePosition:
|
||||||
|
return (
|
||||||
|
CollapsiblePosition[_data[DATA_KEY_COLLAPSIBLE_POSITION]]
|
||||||
|
if _data.has(DATA_KEY_COLLAPSIBLE_POSITION)
|
||||||
|
else CollapsiblePosition.DISABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func set_anchor_to_safe_area(a_value: bool) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_ANCHOR_TO_SAFE_AREA] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_anchor_to_safe_area() -> bool:
|
||||||
|
return _data[DATA_KEY_ANCHOR_TO_SAFE_AREA] if _data.has(DATA_KEY_ANCHOR_TO_SAFE_AREA) else false
|
||||||
|
|
||||||
|
|
||||||
|
func set_keywords(a_value: Array) -> LoadAdRequest:
|
||||||
|
if a_value == null:
|
||||||
|
_data[DATA_KEY_KEYWORDS] = []
|
||||||
|
else:
|
||||||
|
_data[DATA_KEY_KEYWORDS] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func add_keyword(a_value: String) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_KEYWORDS].append(a_value)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_keywords() -> Array:
|
||||||
|
return _data[DATA_KEY_KEYWORDS] if _data.has(DATA_KEY_KEYWORDS) else []
|
||||||
|
|
||||||
|
|
||||||
|
func set_user_id(a_value: String) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_USER_ID] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_user_id() -> String:
|
||||||
|
return _data[DATA_KEY_USER_ID] if _data.has(DATA_KEY_USER_ID) else ""
|
||||||
|
|
||||||
|
|
||||||
|
func set_custom_data(a_value: String) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_CUSTOM_DATA] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_custom_data() -> String:
|
||||||
|
return _data[DATA_KEY_CUSTOM_DATA] if _data.has(DATA_KEY_CUSTOM_DATA) else ""
|
||||||
|
|
||||||
|
|
||||||
|
func set_network_extras(a_value: Array) -> LoadAdRequest:
|
||||||
|
if a_value == null:
|
||||||
|
_data[DATA_KEY_NETWORK_EXTRAS] = []
|
||||||
|
else:
|
||||||
|
_data[DATA_KEY_NETWORK_EXTRAS] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_network_extras() -> Array:
|
||||||
|
return _data[DATA_KEY_NETWORK_EXTRAS] if _data.has(DATA_KEY_NETWORK_EXTRAS) else []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Native Ad Options
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
## Sets a preferred media aspect ratio for native ads.
|
||||||
|
## Has no effect when used for non-native ad types.
|
||||||
|
func set_native_media_aspect_ratio(a_value: NativeMediaAspectRatio) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO] = NativeMediaAspectRatio.keys()[a_value]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_native_media_aspect_ratio() -> NativeMediaAspectRatio:
|
||||||
|
return (
|
||||||
|
NativeMediaAspectRatio[_data[DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO]]
|
||||||
|
if _data.has(DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO)
|
||||||
|
else NativeMediaAspectRatio.UNKNOWN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## When true the SDK returns image asset URLs instead of pre-fetched Drawable
|
||||||
|
## objects, allowing the app to download and manage images itself.
|
||||||
|
## Has no effect when used for non-native ad types.
|
||||||
|
func set_native_return_urls_for_image_assets(a_value: bool) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_native_return_urls_for_image_assets() -> bool:
|
||||||
|
return (
|
||||||
|
_data[DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS]
|
||||||
|
if _data.has(DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS)
|
||||||
|
else false
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## When true the SDK may return ads that contain multiple images for a single
|
||||||
|
## asset slot. By default only one image per slot is returned.
|
||||||
|
## Has no effect when used for non-native ad types.
|
||||||
|
func set_native_request_multiple_images(a_value: bool) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_native_request_multiple_images() -> bool:
|
||||||
|
return (
|
||||||
|
_data[DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES] if _data.has(DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES) else false
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## Sets where the AdChoices icon is placed inside the native ad view.
|
||||||
|
## Has no effect when used for non-native ad types.
|
||||||
|
func set_native_ad_choices_placement(a_value: NativeAdChoicesPlacement) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT] = NativeAdChoicesPlacement.keys()[a_value]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_native_ad_choices_placement() -> NativeAdChoicesPlacement:
|
||||||
|
return (
|
||||||
|
NativeAdChoicesPlacement[_data[DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT]]
|
||||||
|
if _data.has(DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT)
|
||||||
|
else NativeAdChoicesPlacement.TOP_RIGHT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## Sets how icon and image assets are scaled inside their Android ImageView.
|
||||||
|
## Has no effect when used for non-native ad types.
|
||||||
|
func set_native_image_scale_type(a_value: NativeImageScaleType) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_NATIVE_IMAGE_SCALE_TYPE] = NativeImageScaleType.keys()[a_value]
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_native_image_scale_type() -> NativeImageScaleType:
|
||||||
|
return (
|
||||||
|
NativeImageScaleType[_data[DATA_KEY_NATIVE_IMAGE_SCALE_TYPE]]
|
||||||
|
if _data.has(DATA_KEY_NATIVE_IMAGE_SCALE_TYPE)
|
||||||
|
else NativeImageScaleType.FIT_CENTER
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## When true the SDK's native ad validator is disabled. The validator normally
|
||||||
|
## logs warnings when required view bindings are missing; disabling it suppresses
|
||||||
|
## those warnings in test / custom-layout scenarios.
|
||||||
|
## Has no effect when used for non-native ad types.
|
||||||
|
func set_native_disable_validator(a_value: bool) -> LoadAdRequest:
|
||||||
|
_data[DATA_KEY_NATIVE_DISABLE_VALIDATOR] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_native_disable_validator() -> bool:
|
||||||
|
return _data[DATA_KEY_NATIVE_DISABLE_VALIDATOR] if _data.has(DATA_KEY_NATIVE_DISABLE_VALIDATOR) else false
|
||||||
|
|
||||||
|
|
||||||
|
func get_raw_data() -> Dictionary:
|
||||||
|
return _data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c86msp23213bn
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name MediationNetwork
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
enum Flag {
|
||||||
|
APPLOVIN = 1 << 0, ## AppLovin
|
||||||
|
CHARTBOOST = 1 << 1, ## Chartboost
|
||||||
|
DTEXCHANGE = 1 << 2, ## DT Exchange (previously Fyber)
|
||||||
|
IMOBILE = 1 << 3, ## i-mobile
|
||||||
|
INMOBI = 1 << 4, ## InMobi
|
||||||
|
IRONSOURCE = 1 << 5, ## ironSource
|
||||||
|
LIFTOFF = 1 << 6, ## Liftoff Monetize (previously Vungle)
|
||||||
|
LINE = 1 << 7, ## LINE Ads Network
|
||||||
|
MAIO = 1 << 8, ## maio
|
||||||
|
META = 1 << 9, ## Meta Audience Network (previously Facebook)
|
||||||
|
MINTEGRAL = 1 << 10, ## Mintegral
|
||||||
|
MOLOCO = 1 << 11, ## Moloco
|
||||||
|
MYTARGET = 1 << 12, ## myTarget
|
||||||
|
PANGLE = 1 << 13, ## Pangle
|
||||||
|
UNITY = 1 << 14, ## Unity Ads
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLAG_PROPERTY := &"flag"
|
||||||
|
const TAG_PROPERTY := &"tag"
|
||||||
|
const DEPENDENCIES_PROPERTY := &"dependencies"
|
||||||
|
const MAVEN_REPO_PROPERTY := &"maven_repo"
|
||||||
|
const PACKAGE_PROPERTY := &"package"
|
||||||
|
const PACKAGE_URL_PROPERTY := &"package_url"
|
||||||
|
const PACKAGE_VERSION_PROPERTY := &"package_version"
|
||||||
|
const SK_AD_NETWORK_IDS_PROPERTY := &"sk_ad_network_ids"
|
||||||
|
const GOOGLE_SK_AD_NETWORK_IDS = [ "cstr6suwn9", "4fzdc2evr5", "2fnua5tdw4", "ydx93a7ass", "p78axxw29g", "v72qych5uu", "ludvb6z3bs", "cp8zw746q7", "3sh42y64q3", "c6k4g5qg8m", "s39g8k73mm", "3qy4746246", "f38h382jlk", "hs6bdukanm", "mlmmfzh3r3", "v4nxqhlyqp", "wzmmz9fp6w", "su67r6k2v3", "yclnxrl5pm", "t38b2kh725", "7ug5zh24hu", "gta9lk7p23", "vutu7akeur", "y5ghdn5j9k", "v9wttpbfk9", "n38lu8286q", "47vhws6wlr", "kbd757ywx3", "9t245vhmpl", "a2p9lx4jpn", "22mmun2rn5", "44jx6755aq", "k674qkevps", "4468km3ulz", "2u9pt9hc89", "8s468mfl3y", "klf5c3l5u5", "ppxm28t8ap", "kbmxgpxpgc", "uw77j35x4d", "578prtvx9j", "4dzt52r2t5", "tl55sbb4fm", "c3frkrj4fj", "e5fvkxwrpn", "8c4e2ghe7u", "3rd42ekr43", "97r2b46745", "3qcr597p9d" ]
|
||||||
|
|
||||||
|
const SK_AD_NETWORK_ITEM_LIST_FORMAT: String = """
|
||||||
|
<key>SKAdNetworkItems</key>
|
||||||
|
<array>
|
||||||
|
%s
|
||||||
|
</array>
|
||||||
|
"""
|
||||||
|
|
||||||
|
const SK_AD_NETWORK_ITEM_FORMAT: String = """
|
||||||
|
<dict>
|
||||||
|
<key>SKAdNetworkIdentifier</key>
|
||||||
|
<string>%s.skadnetwork</string>
|
||||||
|
</dict>
|
||||||
|
"""
|
||||||
|
|
||||||
|
const MEDIATION_NETWORKS: Dictionary = {
|
||||||
|
Flag.APPLOVIN: {
|
||||||
|
FLAG_PROPERTY: Flag.APPLOVIN,
|
||||||
|
TAG_PROPERTY: "applovin",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:applovin:13.4.0.1"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "AppLovinAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-applovin.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "13.4.0",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["22mmun2rn5", "238da6jt44", "24t9a8vw3c", "24zw6aqk47", "252b5q8x7y", "275upjj5gd", "294l99pt4k", "2fnua5tdw4", "2u9pt9hc89", "32z4fx6l9h", "3l6bd9hu43", "3qcr597p9d", "3qy4746246", "3rd42ekr43", "3sh42y64q3", "424m5254lk", "4468km3ulz", "44jx6755aq", "44n7hlldy6", "47vhws6wlr", "488r3q3dtq", "4dzt52r2t5", "4fzdc2evr5", "4mn522wn87", "4pfyvq9l8r", "4w7y6s5ca2", "523jb4fst2", "52fl2v3hgk", "54nzkqm89y", "578prtvx9j", "5a6flpkh64", "5l3tpt7t6e", "5lm9lj6jb7", "5tjdwbrq8w", "6964rsfnh4", "6g9af3uyq4", "6p4ks3rnbw", "6v7lgmsu45", "6xzpu9s2p8", "737z793b9f", "74b6s63p6l", "79pbpufp6p", "7fmhfwg9en", "7rz58n8ntl", "7ug5zh24hu", "84993kbrcf", "89z7zv988g", "8c4e2ghe7u", "8m87ys6875", "8r8llnkz5a", "8s468mfl3y", "97r2b46745", "9b89h5y424", "9nlqeag3gk", "9rd848q2bz", "9t245vhmpl", "9vvzujtq5s", "9yg77x724h", "a2p9lx4jpn", "a7xqa6mtl2", "a8cz6cu7e5", "av6w8kgt66", "b9bk5wbcq9", "bxvub5ada5", "c3frkrj4fj", "c6k4g5qg8m", "cg4yq2srnc", "cj5566h2ga", "cp8zw746q7", "cs644xg564", "cstr6suwn9", "dbu4b84rxf", "dkc879ngq3", "dzg6xy7pwj", "e5fvkxwrpn", "ecpz2srf59", "eh6m2bh4zr", "ejvt5qm6ak", "f38h382jlk", "f73kdq92p3", "f7s53z58qe", "feyaarzu9v", "g28c52eehv", "g2y4y55b64", "g6gcrrvk4p", "ggvn48r87g", "glqzh8vgby", "gta8lk7p23", "gta9lk7p23", "hb56zgv37p", "hdw39hrw9y", "hs6bdukanm", "k674qkevps", "kbd757ywx3", "kbmxgpxpgc", "klf5c3l5u5", "krvm3zuq6h", "lr83yxwka7", "ludvb6z3bs", "m297p6643m", "m5mvw97r93", "m8dbw4sv7c", "mj797d8u6f", "mlmmfzh3r3", "mls7yz5dvl", "mp6xlyr22a", "mqn7fxpca7", "mtkv5xtk9e", "n38lu8286q", "n66cz3y3bx", "n6fk4nfna4", "n9x2a789qt", "nzq8sh4pbs", "p78axxw29g", "ppxm28t8ap", "prcb7njmu6", "pwa73g5rt2", "pwdxu55a5a", "qqp299437r", "qu637u8glc", "r45fhb6rf7", "rvh3l7un93", "rx5hdcabgc", "s39g8k73mm", "s69wq72ugq", "su67r6k2v3", "t38b2kh725", "tl55sbb4fm", "u679fj5vs4", "uw77j35x4d", "v4nxqhlyqp", "v72qych5uu", "v79kvwwj4g", "v9wttpbfk9", "vcra2ehyfk", "vhf287vqwu", "vutu7akeur", "w9q455wk68", "wg4vff78zm", "wzmmz9fp6w", "x44k69ngh6", "x5l83yy675", "x8jxxk4ff5", "x8uqf25wch", "xga6mpmplv", "xy9t38ct57", "y45688jllp", "y5ghdn5j9k", "yclnxrl5pm", "ydx93a7ass", "zmvfpc5aq8", "zq492l623r"],
|
||||||
|
},
|
||||||
|
Flag.CHARTBOOST: {
|
||||||
|
FLAG_PROPERTY: Flag.CHARTBOOST,
|
||||||
|
TAG_PROPERTY: "chartboost",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:chartboost:9.10.0.1"],
|
||||||
|
MAVEN_REPO_PROPERTY: "https://cboost.jfrog.io/artifactory/chartboost-ads/",
|
||||||
|
PACKAGE_PROPERTY: "ChartboostAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-chartboost.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "9.11.001",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["f38h382jlk"],
|
||||||
|
},
|
||||||
|
Flag.DTEXCHANGE: {
|
||||||
|
FLAG_PROPERTY: Flag.DTEXCHANGE,
|
||||||
|
TAG_PROPERTY: "dtexchange",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:fyber:8.4.0.0"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "DTExchangeAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-dtexchange.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "8.4.600",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["ydx93a7ass", "t6d3zquu66", "4fzdc2evr5", "a8cz6cu7e5", "9nlqeag3gk", "a2p9lx4jpn", "k674qkevps", "c3frkrj4fj", "n6fk4nfna4", "mtkv5xtk9e", "p78axxw29g", "n9x2a789qt", "kbmxgpxpgc", "s39g8k73mm", "krvm3zuq6h", "294l99pt4k", "3rd42ekr43", "ggvn48r87g", "4pfyvq9l8r", "cg4yq2srnc", "5a6flpkh64", "v72qych5uu", "x8uqf25wch", "c6k4g5qg8m", "wg4vff78zm", "rx5hdcabgc", "32z4fx6l9h", "3qy4746246", "252b5q8x7y", "f38h382jlk", "24t9a8vw3c", "hs6bdukanm", "9rd848q2bz", "prcb7njmu6", "m8dbw4sv7c", "9g2aggbj52", "wzmmz9fp6w", "yclnxrl5pm", "t38b2kh725", "7ug5zh24hu", "5lm9lj6jb7", "ejvt5qm6ak", "7rz58n8ntl", "kbd757ywx3", "9t245vhmpl", "44jx6755aq", "tl55sbb4fm", "8s468mfl3y", "4468km3ulz", "2u9pt9hc89", "av6w8kgt66", "klf5c3l5u5", "hdw39hrw9y", "y45688jllp", "dzg6xy7pwj", "3sh42y64q3", "ppxm28t8ap", "f73kdq92p3", "5l3tpt7t6e", "uw77j35x4d", "mlmmfzh3r3", "6g9af3uyq4", "su67r6k2v3", "g28c52eehv", "pwa73g5rt2", "zq492l623r", "22mmun2rn5", "8r8llnkz5a", "47vhws6wlr", "5tjdwbrq8w", "h65wbv5k3f", "2fnua5tdw4", "7fmhfwg9en", "tvvz7th9br", "x44k69ngh6", "vhf287vqwu", "4dzt52r2t5", "x8yj322td6", "tskbem2b5g", "cp8zw746q7", "cj5566h2ga", "578prtvx9j", "e5fvkxwrpn", "r45fhb6rf7", "mqn7fxpca7", "g6gcrrvk4p", "xga6mpmplv", "qu637u8glc", "w9q455wk68", "275upjj5gd", "97r2b46745", "cstr6suwn9"],
|
||||||
|
},
|
||||||
|
Flag.IMOBILE: {
|
||||||
|
FLAG_PROPERTY: Flag.IMOBILE,
|
||||||
|
TAG_PROPERTY: "imobile",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:imobile:2.3.2.1"],
|
||||||
|
MAVEN_REPO_PROPERTY: "https://imobile.github.io/adnw-sdk-android",
|
||||||
|
PACKAGE_PROPERTY: "IMobileAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-imobile.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "2.3.402",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["v4nxqhlyqp"],
|
||||||
|
},
|
||||||
|
Flag.INMOBI: {
|
||||||
|
FLAG_PROPERTY: Flag.INMOBI,
|
||||||
|
TAG_PROPERTY: "inmobi",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:inmobi:10.8.8.1"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "InMobiAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-inmobi.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "10.8.600",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["uw77j35x4d", "7ug5zh24hu", "hs6bdukanm", "4fzdc2evr5", "ggvn48r87g", "5lm9lj6jb7", "9rd848q2bz", "c6k4g5qg8m", "wzmmz9fp6w", "3sh42y64q3", "yclnxrl5pm", "kbd757ywx3", "f73kdq92p3", "ydx93a7ass", "w9q455wk68", "prcb7njmu6", "wg4vff78zm", "mlmmfzh3r3", "tl55sbb4fm", "4pfyvq9l8r", "t38b2kh725", "5l3tpt7t6e", "7rz58n8ntl", "klf5c3l5u5", "cg4yq2srnc", "av6w8kgt66", "9t245vhmpl", "v72qych5uu", "2u9pt9hc89", "44jx6755aq", "8s468mfl3y", "p78axxw29g", "ppxm28t8ap", "424m5254lk", "5a6flpkh64", "pwa73g5rt2", "e5fvkxwrpn", "9nlqeag3gk", "k674qkevps", "cj5566h2ga", "mtkv5xtk9e", "3rd42ekr43", "g28c52eehv", "2fnua5tdw4", "4468km3ulz", "97r2b46745", "glqzh8vgby", "3qcr597p9d", "578prtvx9j", "n6fk4nfna4", "b9bk5wbcq9", "kbmxgpxpgc", "294l99pt4k", "523jb4fst2", "mls7yz5dvl", "74b6s63p6l", "22mmun2rn5", "52fl2v3hgk", "6g9af3uyq4", "x5l83yy675", "mqn7fxpca7", "g6gcrrvk4p", "r45fhb6rf7", "c3frkrj4fj", "ejvt5qm6ak", "f38h382jlk", "488r3q3dtq", "55644vm79v", "5tjdwbrq8w", "nzq8sh4pbs", "m8dbw4sv7c", "7fmhfwg9en", "6v7lgmsu45", "s39g8k73mm", "6yxyv74ff7", "55y65gfgn7", "mqn7fxpca7", "qqp299437r", "32z4fx6l9h", "feyaarzu9v", "cp8zw746q7", "252b5q8x7y", "qu637u8glc", "vhf287vqwu", "a8cz6cu7e5", "cwn433xbcr", "fq6vru337s", "87u5trcl3r", "mj797d8u6f", "dbu4b84rxf", "238da6jt44", "4dzt52r2t5", "a2p9lx4jpn", "t6d3zquu66", "zq492l623r", "cstr6suwn9", "xga6mpmplv", "tmhh9296z4", "6xzpu9s2p8", "ludvb6z3bs", "v9wttpbfk9", "n38lu8286q", "fz2k2k5tej", "g2y4y55b64", "su67r6k2v3", "v79kvwwj4g", "275upjj5gd", "24zw6aqk47", "44n7hlldy6", "ecpz2srf59", "bvpn9ufa9b", "gta9lk7p23"],
|
||||||
|
},
|
||||||
|
Flag.IRONSOURCE: {
|
||||||
|
FLAG_PROPERTY: Flag.IRONSOURCE,
|
||||||
|
TAG_PROPERTY: "ironsource",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:ironsource:9.0.0.1"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "IronSourceAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-ironsource.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "8.10.0",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["su67r6k2v3"],
|
||||||
|
},
|
||||||
|
Flag.LIFTOFF: {
|
||||||
|
FLAG_PROPERTY: Flag.LIFTOFF,
|
||||||
|
TAG_PROPERTY: "liftoff",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:vungle:7.6.0.0"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "LiftoffMonetizeAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-liftoffmonetize.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "7.6.0",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["22mmun2rn5", "238da6jt44", "24t9a8vw3c", "24zw6aqk47", "275upjj5gd", "294l99pt4k", "2fnua5tdw4", "2u9pt9hc89", "32z4fx6l9h", "3l6bd9hu43", "3qcr597p9d", "3qy4746246", "3rd42ekr43", "3sh42y64q3", "424m5254lk", "4468km3ulz", "44jx6755aq", "44n7hlldy6", "488r3q3dtq", "4dzt52r2t5", "4fzdc2evr5", "4pfyvq9l8r", "4w7y6s5ca2", "523jb4fst2", "52fl2v3hgk", "54nzkqm89y", "578prtvx9j", "5a6flpkh64", "5l3tpt7t6e", "5lm9lj6jb7", "5tjdwbrq8w", "6964rsfnh4", "6g9af3uyq4", "6lz2ygh3q6", "6p4ks3rnbw", "6rd35atwn8", "6v7lgmsu45", "6xzpu9s2p8", "6yxyv74ff7", "737z793b9f", "74b6s63p6l", "79pbpufp6p", "7fmhfwg9en", "7rz58n8ntl", "7ug5zh24hu", "84993kbrcf", "87u5trcl3r", "89z7zv988g", "8m87ys6875", "8s468mfl3y", "97r2b46745", "9b89h5y424", "9nlqeag3gk", "9rd848q2bz", "9t245vhmpl", "9vvzujtq5s", "a2p9lx4jpn", "a7xqa6mtl2", "a8cz6cu7e5", "apzhy3va96", "av6w8kgt66", "b9bk5wbcq9", "bvpn9ufa9b", "bxvub5ada5", "c3frkrj4fj", "c6k4g5qg8m", "cg4yq2srnc", "cj5566h2ga", "cp8zw746q7", "cs644xg564", "cstr6suwn9", "cwn433xbcr", "dbu4b84rxf", "dkc879ngq3", "dzg6xy7pwj", "e5fvkxwrpn", "ecpz2srf59", "ejvt5qm6ak", "f2zub97jtl", "f38h382jlk", "f73kdq92p3", "f7s53z58qe", "feyaarzu9v", "fq6vru337s", "fz2k2k5tej", "g28c52eehv", "g2y4y55b64", "g6gcrrvk4p", "ggvn48r87g", "glqzh8vgby", "gta9lk7p23", "hb56zgv37p", "hdw39hrw9y", "hs6bdukanm", "k674qkevps", "k6y4y55b64", "kbd757ywx3", "kbmxgpxpgc", "klf5c3l5u5", "krvm3zuq6h", "ln5gz23vtd", "lr83yxwka7", "ludvb6z3bs", "m297p6643m", "m2jqnlggk3", "m5mvw97r93", "m8dbw4sv7c", "mj797d8u6f", "mlmmfzh3r3", "mls7yz5dvl", "mp6xlyr22a", "mqn7fxpca7", "mtkv5xtk9e", "n38lu8286q", "n6fk4nfna4", "n9x2a789qt", "ns5j362hk7", "p78axxw29g", "pg7ctvrt6f", "ppxm28t8ap", "prcb7njmu6", "pwa73g5rt2", "pwdxu55a5a", "qqp299437r", "qu637u8glc", "qwpu75vrh2", "r45fhb6rf7", "raa6f494kr", "rvh3l7un93", "rx5hdcabgc", "s39g8k73mm", "sczv5946wb", "su67r6k2v3", "t38b2kh725", "t6d3zquu66", "thzdn4h5nc", "tl55sbb4fm", "tmhh9296z4", "u679fj5vs4", "uw77j35x4d", "v72qych5uu", "v79kvwwj4g", "v9wttpbfk9", "vcra2ehyfk", "vhf287vqwu", "w9q455wk68", "wg4vff78zm", "wzmmz9fp6w", "x44k69ngh6", "x5l83yy675", "x8uqf25wch", "xga6mpmplv", "xy9t38ct57", "y45688jllp", "y5ghdn5j9k", "yclnxrl5pm", "ydx93a7ass", "z959bm4gru", "zmvfpc5aq8", "zq492l623r"],
|
||||||
|
},
|
||||||
|
Flag.LINE: {
|
||||||
|
FLAG_PROPERTY: Flag.LINE,
|
||||||
|
TAG_PROPERTY: "line",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:line:2.9.20250924.1"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "LineAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-line.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "2.9.2025111900",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["vutu7akeur", "eh6m2bh4zr", "cstr6suwn9", "578prtvx9j", "9t245vhmpl", "v72qych5uu", "x8uqf25wch", "7ug5zh24hu", "hs6bdukanm", "dbu4b84rxf", "8c4e2ghe7u"],
|
||||||
|
},
|
||||||
|
Flag.MAIO: {
|
||||||
|
FLAG_PROPERTY: Flag.MAIO,
|
||||||
|
TAG_PROPERTY: "maio",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:maio:2.0.6.0"],
|
||||||
|
MAVEN_REPO_PROPERTY: "https://imobile-maio.github.io/maven",
|
||||||
|
PACKAGE_PROPERTY: "MaioAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-maio.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "2.2.0",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["v4nxqhlyqp"],
|
||||||
|
},
|
||||||
|
Flag.META: {
|
||||||
|
FLAG_PROPERTY: Flag.META,
|
||||||
|
TAG_PROPERTY: "meta",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:facebook:6.20.0.2"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "MetaAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-meta.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "6.20.100",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["v9wttpbfk9", "n38lu8286q"],
|
||||||
|
},
|
||||||
|
Flag.MINTEGRAL: {
|
||||||
|
FLAG_PROPERTY: Flag.MINTEGRAL,
|
||||||
|
TAG_PROPERTY: "mintegral",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:mintegral:16.9.91.2"],
|
||||||
|
MAVEN_REPO_PROPERTY: "https://dl-maven-android.mintegral.com/repository/mbridge_android_sdk_oversea",
|
||||||
|
PACKAGE_PROPERTY: "MintegralAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-mintegral.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "7.7.901",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["kbd757ywx3"],
|
||||||
|
},
|
||||||
|
Flag.MOLOCO: {
|
||||||
|
FLAG_PROPERTY: Flag.MOLOCO,
|
||||||
|
TAG_PROPERTY: "moloco",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:moloco:4.2.0.0"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "MolocoAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-moloco.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "4.1.0",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["9t245vhmpl"],
|
||||||
|
},
|
||||||
|
Flag.MYTARGET: {
|
||||||
|
FLAG_PROPERTY: Flag.MYTARGET,
|
||||||
|
TAG_PROPERTY: "mytarget",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:mytarget:5.27.3.0"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "MyTargetAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-mytarget.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "5.36.0",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["n9x2a789qt", "r26jy69rpl"],
|
||||||
|
},
|
||||||
|
Flag.PANGLE: {
|
||||||
|
FLAG_PROPERTY: Flag.PANGLE,
|
||||||
|
TAG_PROPERTY: "pangle",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:pangle:7.6.0.5.0"],
|
||||||
|
MAVEN_REPO_PROPERTY: "https://artifact.bytedance.com/repository/pangle/",
|
||||||
|
PACKAGE_PROPERTY: "PangleAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-pangle.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "7.6.600",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["22mmun2rn5"],
|
||||||
|
},
|
||||||
|
Flag.UNITY: {
|
||||||
|
FLAG_PROPERTY: Flag.UNITY,
|
||||||
|
TAG_PROPERTY: "unity",
|
||||||
|
DEPENDENCIES_PROPERTY: ["com.unity3d.ads:unity-ads:4.16.2", "com.google.ads.mediation:unity:4.16.3.0"],
|
||||||
|
MAVEN_REPO_PROPERTY: "",
|
||||||
|
PACKAGE_PROPERTY: "UnityAdapterTarget",
|
||||||
|
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-unity.git",
|
||||||
|
PACKAGE_VERSION_PROPERTY: "4.16.4",
|
||||||
|
SK_AD_NETWORK_IDS_PROPERTY: ["9nlqeag3gk", "hs6bdukanm", "9rd848q2bz", "22mmun2rn5", "f73kdq92p3", "tl55sbb4fm", "prcb7njmu6", "n9x2a789qt", "97r2b46745", "zmvfpc5aq8", "av6w8kgt66", "uw77j35x4d", "pwa73g5rt2", "e5fvkxwrpn", "klf5c3l5u5", "xga6mpmplv", "wg4vff78zm", "lr83yxwka7", "5tjdwbrq8w", "7ug5zh24hu", "glqzh8vgby", "feyaarzu9v", "6yxyv74ff7", "mqn7fxpca7", "vhf287vqwu", "g6gcrrvk4p", "v79kvwwj4g", "32z4fx6l9h", "2fnua5tdw4", "a8cz6cu7e5", "v72qych5uu", "a2p9lx4jpn", "5f5u5tfb26", "4fzdc2evr5", "5lm9lj6jb7", "4w7y6s5ca2", "3sh42y64q3", "4468km3ulz", "mj797d8u6f", "488r3q3dtq", "mlmmfzh3r3", "9t245vhmpl", "x44k69ngh6", "kbd757ywx3", "4dzt52r2t5", "k674qkevps", "294l99pt4k", "m8dbw4sv7c", "2u9pt9hc89", "p78axxw29g", "424m5254lk", "238da6jt44", "ydx93a7ass", "s39g8k73mm", "v9wttpbfk9", "44jx6755aq", "578prtvx9j", "yclnxrl5pm", "k6y4y55b64", "5a6flpkh64", "3rd42ekr43", "f38h382jlk", "cstr6suwn9", "ppxm28t8ap", "f7s53z58qe", "zq492l623r", "t38b2kh725", "mp6xlyr22a", "3qy4746246", "5l3tpt7t6e", "c6k4g5qg8m", "w9q455wk68", "4pfyvq9l8r", "wzmmz9fp6w", "8s468mfl3y"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDIATION_NETWORK_TAGS: Dictionary = {
|
||||||
|
"applovin": Flag.APPLOVIN,
|
||||||
|
"chartboost": Flag.CHARTBOOST,
|
||||||
|
"dtexchange": Flag.DTEXCHANGE,
|
||||||
|
"imobile": Flag.IMOBILE,
|
||||||
|
"inmobi": Flag.INMOBI,
|
||||||
|
"ironsource": Flag.IRONSOURCE,
|
||||||
|
"liftoff": Flag.LIFTOFF,
|
||||||
|
"line": Flag.LINE,
|
||||||
|
"maio": Flag.MAIO,
|
||||||
|
"meta": Flag.META,
|
||||||
|
"mintegral": Flag.MINTEGRAL,
|
||||||
|
"moloco": Flag.MOLOCO,
|
||||||
|
"mytarget": Flag.MYTARGET,
|
||||||
|
"pangle": Flag.PANGLE,
|
||||||
|
"unity": Flag.UNITY,
|
||||||
|
}
|
||||||
|
|
||||||
|
var flag: Flag
|
||||||
|
var tag: String
|
||||||
|
var android_dependencies: Array
|
||||||
|
var android_custom_maven_repo: String
|
||||||
|
var swift_package: String
|
||||||
|
var swift_package_url: String
|
||||||
|
var swift_package_version: String
|
||||||
|
var sk_ad_network_ids: PackedStringArray
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary) -> void:
|
||||||
|
flag = a_data[FLAG_PROPERTY]
|
||||||
|
tag = a_data[TAG_PROPERTY]
|
||||||
|
android_dependencies = a_data[DEPENDENCIES_PROPERTY]
|
||||||
|
android_custom_maven_repo = a_data[MAVEN_REPO_PROPERTY]
|
||||||
|
swift_package = a_data[PACKAGE_PROPERTY]
|
||||||
|
swift_package_url = a_data[PACKAGE_URL_PROPERTY]
|
||||||
|
swift_package_version = a_data[PACKAGE_VERSION_PROPERTY]
|
||||||
|
sk_ad_network_ids = a_data[SK_AD_NETWORK_IDS_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_spm_dependency() -> SpmDependency:
|
||||||
|
return SpmDependency.new({
|
||||||
|
SpmDependency.URL_PROPERTY: swift_package_url,
|
||||||
|
SpmDependency.VERSION_PROPERTY: swift_package_version,
|
||||||
|
SpmDependency.PRODUCTS_PROPERTY: [swift_package],
|
||||||
|
})
|
||||||
|
|
||||||
|
static func is_flag_enabled(a_value: int, a_flag: Flag) -> bool:
|
||||||
|
return a_value & a_flag
|
||||||
|
|
||||||
|
|
||||||
|
static func is_valid_tag(a_tag: String) -> bool:
|
||||||
|
return MEDIATION_NETWORK_TAGS.has(a_tag)
|
||||||
|
|
||||||
|
|
||||||
|
static func get_by_flag(a_flag: Flag) -> MediationNetwork:
|
||||||
|
return MediationNetwork.new(MEDIATION_NETWORKS[a_flag])
|
||||||
|
|
||||||
|
|
||||||
|
static func get_by_tag(a_tag: String) -> MediationNetwork:
|
||||||
|
return get_by_flag(MEDIATION_NETWORK_TAGS[a_tag])
|
||||||
|
|
||||||
|
|
||||||
|
static func get_all_enabled(a_value: int) -> Array[MediationNetwork]:
|
||||||
|
var __enabled_networks: Array[MediationNetwork] = []
|
||||||
|
|
||||||
|
for __flag in Flag.values():
|
||||||
|
if is_flag_enabled(a_value, __flag):
|
||||||
|
__enabled_networks.append(get_by_flag(__flag))
|
||||||
|
|
||||||
|
return __enabled_networks
|
||||||
|
|
||||||
|
|
||||||
|
static func get_all_enabled_tags(a_value: int) -> Array[String]:
|
||||||
|
var __enabled_network_tags: Array[String] = []
|
||||||
|
|
||||||
|
for __flag in Flag.values():
|
||||||
|
if is_flag_enabled(a_value, __flag):
|
||||||
|
var __network: MediationNetwork = get_by_flag(__flag)
|
||||||
|
__enabled_network_tags.append(__network.tag)
|
||||||
|
|
||||||
|
return __enabled_network_tags
|
||||||
|
|
||||||
|
|
||||||
|
static func generate_sk_ad_network_plist(a_networks: Array[MediationNetwork]) -> String:
|
||||||
|
var __sk_ad_ids_plist_content: String
|
||||||
|
|
||||||
|
var __unique_sk_network_ad_ids: Dictionary = { }
|
||||||
|
|
||||||
|
# Add Google-required SK Ad Network IDs
|
||||||
|
for __network_id in GOOGLE_SK_AD_NETWORK_IDS:
|
||||||
|
if not __unique_sk_network_ad_ids.has(__network_id):
|
||||||
|
__unique_sk_network_ad_ids.set(__network_id, null)
|
||||||
|
|
||||||
|
# Add SK Ad Network IDs required by other networks
|
||||||
|
for __network in a_networks:
|
||||||
|
for __network_id in __network.sk_ad_network_ids:
|
||||||
|
if not __unique_sk_network_ad_ids.has(__network_id):
|
||||||
|
__unique_sk_network_ad_ids.set(__network_id, null)
|
||||||
|
|
||||||
|
for __network_id in __unique_sk_network_ad_ids.keys():
|
||||||
|
__sk_ad_ids_plist_content += SK_AD_NETWORK_ITEM_FORMAT % __network_id
|
||||||
|
|
||||||
|
return SK_AD_NETWORK_ITEM_LIST_FORMAT % __sk_ad_ids_plist_content
|
||||||
|
|
||||||
|
|
||||||
|
static func get_spm_dependencies(a_networks: Array[MediationNetwork]) -> Array[SpmDependency]:
|
||||||
|
var __spm_dependencies: Array[SpmDependency] = []
|
||||||
|
|
||||||
|
for __network in a_networks:
|
||||||
|
__spm_dependencies.append(__network.get_spm_dependency())
|
||||||
|
|
||||||
|
return __spm_dependencies
|
||||||
|
|
||||||
|
|
||||||
|
static func generate_tag_list(a_networks: Array[MediationNetwork]) -> String:
|
||||||
|
var __enabled_network_tags: PackedStringArray = []
|
||||||
|
|
||||||
|
for __network in a_networks:
|
||||||
|
__enabled_network_tags.append(__network.tag)
|
||||||
|
|
||||||
|
return ",".join(__enabled_network_tags)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://devoemkicbhtc
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name NetworkExtras
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
const NETWORK_TAG_PROPERTY := &"network_tag"
|
||||||
|
const EXTRAS_PROPERTY := &"extras"
|
||||||
|
|
||||||
|
## Identifies the target ad network for the extras.
|
||||||
|
@export var network_flag: MediationNetwork.Flag = MediationNetwork.Flag.APPLOVIN
|
||||||
|
|
||||||
|
## Dictionary holding one or more extras. Only keys of type String are allowed. Values can be String, int, or bool.
|
||||||
|
@export var extras: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func get_raw_data() -> Dictionary:
|
||||||
|
var __network: MediationNetwork = MediationNetwork.get_by_flag(network_flag)
|
||||||
|
var __raw_data: Dictionary
|
||||||
|
|
||||||
|
__raw_data = {NETWORK_TAG_PROPERTY: __network.tag, EXTRAS_PROPERTY: extras}
|
||||||
|
|
||||||
|
return __raw_data
|
||||||
|
|
||||||
|
|
||||||
|
static func build_raw_data_array(a_network_extras: Array[NetworkExtras]) -> Array:
|
||||||
|
var __raw_data_array: Array[Dictionary] = []
|
||||||
|
|
||||||
|
for __network_extras in a_network_extras:
|
||||||
|
__raw_data_array.append(__network_extras.get_raw_data())
|
||||||
|
|
||||||
|
return __raw_data_array
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cxw2idjnk2d72
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name NetworkPrivacySettings
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const HAS_GDPR_CONSENT_PROPERTY := &"has_gdpr_consent"
|
||||||
|
const IS_AGE_RESTRICTED_USER_PROPERTY := &"is_age_restricted_user"
|
||||||
|
const HAS_CCPA_SALE_CONSENT_PROPERTY := &"has_ccpa_sale_consent"
|
||||||
|
const ENABLED_NETWORKS_PROPERTY := &"enabled_networks"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_data = {
|
||||||
|
ENABLED_NETWORKS_PROPERTY: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func set_has_gdpr_consent(a_value: bool) -> NetworkPrivacySettings:
|
||||||
|
_data[HAS_GDPR_CONSENT_PROPERTY] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_is_age_restricted_user(a_value: bool) -> NetworkPrivacySettings:
|
||||||
|
_data[IS_AGE_RESTRICTED_USER_PROPERTY] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_has_ccpa_sale_consent(a_value: bool) -> NetworkPrivacySettings:
|
||||||
|
_data[HAS_CCPA_SALE_CONSENT_PROPERTY] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func set_enabled_networks(a_value: Array[String]) -> NetworkPrivacySettings:
|
||||||
|
if a_value == null:
|
||||||
|
_data[ENABLED_NETWORKS_PROPERTY] = []
|
||||||
|
else:
|
||||||
|
_data[ENABLED_NETWORKS_PROPERTY] = a_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
func get_raw_data() -> Dictionary:
|
||||||
|
return _data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b42fjtxon0ynl
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name ResponseInfo
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const ADAPTER_RESPONSES_PROPERTY := &"adapter_responses"
|
||||||
|
const LOADED_ADAPTER_RESPONSE_PROPERTY := &"loaded_adapter_response"
|
||||||
|
const ADAPTER_CLASS_NAME_PROPERTY := &"adapter_class_name"
|
||||||
|
const NETWORK_TAG_PROPERTY := &"network_tag"
|
||||||
|
const RESPONSE_ID_PROPERTY := &"response_id"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_adapter_responses() -> Array[AdapterResponseInfo]:
|
||||||
|
var __responses: Array[AdapterResponseInfo] = []
|
||||||
|
|
||||||
|
for __response_dict in _data[ADAPTER_RESPONSES_PROPERTY]:
|
||||||
|
__responses.append(AdapterResponseInfo.new(__response_dict))
|
||||||
|
|
||||||
|
return __responses
|
||||||
|
|
||||||
|
|
||||||
|
func get_loaded_adapter_response() -> AdapterResponseInfo:
|
||||||
|
return (
|
||||||
|
AdapterResponseInfo.new(_data[LOADED_ADAPTER_RESPONSE_PROPERTY])
|
||||||
|
if _data.has(LOADED_ADAPTER_RESPONSE_PROPERTY)
|
||||||
|
else null
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func get_adapter_class_name() -> String:
|
||||||
|
return _data[ADAPTER_CLASS_NAME_PROPERTY] if _data.has(ADAPTER_CLASS_NAME_PROPERTY) else ""
|
||||||
|
|
||||||
|
|
||||||
|
func get_network_tag() -> String:
|
||||||
|
return _data[NETWORK_TAG_PROPERTY] if _data.has(NETWORK_TAG_PROPERTY) else ""
|
||||||
|
|
||||||
|
|
||||||
|
func get_response_id() -> String:
|
||||||
|
return _data[RESPONSE_ID_PROPERTY] if _data.has(RESPONSE_ID_PROPERTY) else ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b3jib8n1rbnyv
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name RewardItem
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const AMOUNT_PROPERTY := &"amount"
|
||||||
|
const TYPE_PROPERTY := &"type"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary):
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_amount() -> int:
|
||||||
|
return _data[AMOUNT_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func get_type() -> String:
|
||||||
|
return _data[TYPE_PROPERTY]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cf0fkl05bgokb
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#
|
||||||
|
# © 2024-present https://github.com/cengiz-pz
|
||||||
|
#
|
||||||
|
class_name UserConsent
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
UNKNOWN,
|
||||||
|
NOT_REQUIRED,
|
||||||
|
REQUIRED,
|
||||||
|
OBTAINED,
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: Status
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_status_string: String = "") -> void:
|
||||||
|
if a_status_string == "":
|
||||||
|
status = Status.UNKNOWN
|
||||||
|
else:
|
||||||
|
status = string_to_status(a_status_string)
|
||||||
|
|
||||||
|
|
||||||
|
func to_status_string() -> String:
|
||||||
|
return Status.keys()[status]
|
||||||
|
|
||||||
|
|
||||||
|
static func status_to_string(a_status: Status) -> String:
|
||||||
|
return Status.keys()[a_status]
|
||||||
|
|
||||||
|
|
||||||
|
static func string_to_status(a_string: String) -> Status:
|
||||||
|
return Status.get(a_string)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dcbs2sidgjetb
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
;
|
||||||
|
; © 2024-present https://github.com/cengiz-pz
|
||||||
|
;
|
||||||
|
[plugin]
|
||||||
|
|
||||||
|
name="Admob"
|
||||||
|
description="Allows displaying of AdMob ads."
|
||||||
|
author="Cengiz"
|
||||||
|
version="7.0"
|
||||||
|
script="AdmobPlugin.gd"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#
|
||||||
|
# © 2026-present https://github.com/<<GitHubUsername>>
|
||||||
|
#
|
||||||
|
|
||||||
|
class_name GmpLogger extends Object
|
||||||
|
|
||||||
|
|
||||||
|
static func log_error(a_description: String) -> void:
|
||||||
|
push_error("%s" % [a_description])
|
||||||
|
|
||||||
|
|
||||||
|
static func log_warn(a_description: String) -> void:
|
||||||
|
push_warning("%s" % [a_description])
|
||||||
|
|
||||||
|
|
||||||
|
static func log_info(a_description: String) -> void:
|
||||||
|
print_rich("[color=lime]INFO: %s[/color]" % [a_description])
|
||||||
|
|
||||||
|
|
||||||
|
static func error(a_plugin_name: String, a_description: String) -> void:
|
||||||
|
log_error("%s: %s" % [a_plugin_name, a_description])
|
||||||
|
|
||||||
|
|
||||||
|
static func warn(a_plugin_name: String, a_description: String) -> void:
|
||||||
|
log_warn("%s: %s" % [a_plugin_name, a_description])
|
||||||
|
|
||||||
|
|
||||||
|
static func info(a_plugin_name: String, a_description: String) -> void:
|
||||||
|
log_info("%s: %s" % [a_plugin_name, a_description])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://78axiycs2dmy
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#
|
||||||
|
# © 2026-present https://github.com/<<GitHubUsername>>
|
||||||
|
#
|
||||||
|
|
||||||
|
class_name SpmDependency extends RefCounted
|
||||||
|
|
||||||
|
const URL_PROPERTY := &"url"
|
||||||
|
const VERSION_PROPERTY := &"version"
|
||||||
|
const PRODUCTS_PROPERTY := &"products"
|
||||||
|
|
||||||
|
var _data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(a_data: Dictionary = {}):
|
||||||
|
_data = a_data
|
||||||
|
|
||||||
|
|
||||||
|
func get_url() -> String:
|
||||||
|
return _data[URL_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func set_url(a_url: String) -> void:
|
||||||
|
_data[URL_PROPERTY] = a_url
|
||||||
|
|
||||||
|
|
||||||
|
func get_version() -> String:
|
||||||
|
return _data[VERSION_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func set_version(a_version: String) -> void:
|
||||||
|
_data[VERSION_PROPERTY] = a_version
|
||||||
|
|
||||||
|
|
||||||
|
func get_products() -> Array:
|
||||||
|
return _data[PRODUCTS_PROPERTY]
|
||||||
|
|
||||||
|
|
||||||
|
func set_products(a_products: Array) -> void:
|
||||||
|
_data[PRODUCTS_PROPERTY] = a_products
|
||||||
|
|
||||||
|
|
||||||
|
func format_to_string() -> String:
|
||||||
|
var __result: String = ""
|
||||||
|
|
||||||
|
for __product in get_products():
|
||||||
|
__result += "%s@%s (%s) " % [__product, get_version(), get_url()]
|
||||||
|
|
||||||
|
return __result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxpvam6fnixki
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Blake Bukowsky
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Agent Tools
|
||||||
|
|
||||||
|
Godot editor plugin that exposes **43 tools across 12 namespaces** (scene edits, signal wiring, resource creation, reference validation, animation, headless test runs, etc.) over MCP so coding agents can operate through the editor's real APIs instead of hand-editing `.tscn` / `.tres` / `project.godot` as text.
|
||||||
|
|
||||||
|
Works with any MCP-capable client — Claude Code, Claude Desktop, Cursor, Cline, Windsurf, Continue, Zed.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Enable this plugin: *Project → Project Settings → Plugins* → tick **Agent Tools**.
|
||||||
|
2. Confirm the Output panel shows `[agent_tools] listening on 127.0.0.1:9920`.
|
||||||
|
3. Configure your MCP client to run the bridge — see the [full setup guide](https://github.com/BlakeBukowsky/GodotTools#configure-your-agent) for per-client config paths.
|
||||||
|
|
||||||
|
Simplest user-scoped config (for Claude Code, Cursor, Windsurf, etc. — different config file per client):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"godot-agent-tools": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "godot-agent-tools-mcp"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Full documentation, troubleshooting, and tool catalog: **https://github.com/BlakeBukowsky/GodotTools**
|
||||||
|
|
||||||
|
## Raw TCP protocol (if you want to skip MCP)
|
||||||
|
|
||||||
|
Line-delimited JSON-RPC on `127.0.0.1:9920`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": 1, "method": "scene.current", "params": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{"id": 1, "result": {"open": false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Auto-run driver for run.scene_headless (input simulation, multi-screenshot,
|
||||||
|
# state dump, deterministic seed). Reads a per-call config from
|
||||||
|
# res://.godot/agent_tools/headless/config.json, instances the target scene,
|
||||||
|
# injects scripted input per frame, captures screenshots at specified frames,
|
||||||
|
# optionally writes a final-state JSON dump, then quits.
|
||||||
|
|
||||||
|
@tool
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const CONFIG_PATH := "res://.godot/agent_tools/headless/config.json"
|
||||||
|
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
|
||||||
|
|
||||||
|
var _input_script: Array = []
|
||||||
|
var _screenshots: Array = [] # [{frame, path}, ...]
|
||||||
|
var _state_dump: bool = false
|
||||||
|
var _state_dump_path: String = ""
|
||||||
|
var _quit_frame: int = 120
|
||||||
|
var _frame: int = 0
|
||||||
|
var _target_inst: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var f := FileAccess.open(CONFIG_PATH, FileAccess.READ)
|
||||||
|
if f == null:
|
||||||
|
push_error("[headless_driver] config not found at %s" % CONFIG_PATH)
|
||||||
|
get_tree().quit(2)
|
||||||
|
return
|
||||||
|
var parsed = JSON.parse_string(f.get_as_text())
|
||||||
|
f.close()
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
push_error("[headless_driver] malformed config")
|
||||||
|
get_tree().quit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Seed the global RNG before instancing so the target scene's randomness
|
||||||
|
# (randi/randf in _ready, etc.) is deterministic per-seed.
|
||||||
|
if parsed.has("seed"):
|
||||||
|
seed(int(parsed.seed))
|
||||||
|
|
||||||
|
_input_script = parsed.get("input_script", [])
|
||||||
|
_screenshots = parsed.get("screenshots", [])
|
||||||
|
_state_dump = bool(parsed.get("state_dump", false))
|
||||||
|
_state_dump_path = str(parsed.get("state_dump_path", ""))
|
||||||
|
_quit_frame = int(parsed.get("quit_frame", 120))
|
||||||
|
var target_scene_path: String = parsed.get("target_scene", "")
|
||||||
|
|
||||||
|
if target_scene_path == "":
|
||||||
|
push_error("[headless_driver] config missing 'target_scene'")
|
||||||
|
get_tree().quit(2)
|
||||||
|
return
|
||||||
|
var packed = load(target_scene_path)
|
||||||
|
if packed == null:
|
||||||
|
push_error("[headless_driver] failed to load target %s" % target_scene_path)
|
||||||
|
get_tree().quit(2)
|
||||||
|
return
|
||||||
|
_target_inst = packed.instantiate()
|
||||||
|
add_child(_target_inst)
|
||||||
|
print("[headless_driver] driving %s for %d frames, %d events, %d screenshots%s" %
|
||||||
|
[target_scene_path, _quit_frame, _input_script.size(), _screenshots.size(),
|
||||||
|
", state_dump=on" if _state_dump else ""])
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
# 1. Inject input events scheduled for this frame.
|
||||||
|
for spec in _input_script:
|
||||||
|
if int(spec.get("frame", -1)) == _frame:
|
||||||
|
_inject(spec)
|
||||||
|
|
||||||
|
# 2. Capture screenshots scheduled for this frame.
|
||||||
|
for shot in _screenshots:
|
||||||
|
if int(shot.get("frame", -1)) == _frame:
|
||||||
|
_capture(str(shot.get("path", "")))
|
||||||
|
|
||||||
|
if _frame >= _quit_frame:
|
||||||
|
if _state_dump and _state_dump_path != "":
|
||||||
|
_write_state_dump()
|
||||||
|
print("[headless_driver] done at frame %d" % _frame)
|
||||||
|
get_tree().quit(0)
|
||||||
|
return
|
||||||
|
_frame += 1
|
||||||
|
|
||||||
|
|
||||||
|
func _inject(spec: Dictionary) -> void:
|
||||||
|
match spec.get("type", ""):
|
||||||
|
"action_press":
|
||||||
|
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
|
||||||
|
"action_release":
|
||||||
|
Input.action_release(spec.get("action", ""))
|
||||||
|
"action_tap":
|
||||||
|
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
|
||||||
|
Input.action_release(spec.get("action", ""))
|
||||||
|
"key":
|
||||||
|
var ek := InputEventKey.new()
|
||||||
|
var kc = spec.get("keycode", 0)
|
||||||
|
if kc is String:
|
||||||
|
ek.physical_keycode = OS.find_keycode_from_string(kc)
|
||||||
|
else:
|
||||||
|
ek.physical_keycode = int(kc)
|
||||||
|
ek.pressed = bool(spec.get("pressed", true))
|
||||||
|
Input.parse_input_event(ek)
|
||||||
|
"mouse_click":
|
||||||
|
var pos = spec.get("position", [0, 0])
|
||||||
|
var v := Vector2(pos[0], pos[1])
|
||||||
|
var button := int(spec.get("button", 1))
|
||||||
|
var down := InputEventMouseButton.new()
|
||||||
|
down.button_index = button
|
||||||
|
down.pressed = true
|
||||||
|
down.position = v
|
||||||
|
down.global_position = v
|
||||||
|
Input.parse_input_event(down)
|
||||||
|
var up := InputEventMouseButton.new()
|
||||||
|
up.button_index = button
|
||||||
|
up.pressed = false
|
||||||
|
up.position = v
|
||||||
|
up.global_position = v
|
||||||
|
Input.parse_input_event(up)
|
||||||
|
"mouse_motion":
|
||||||
|
var pos = spec.get("position", [0, 0])
|
||||||
|
var em := InputEventMouseMotion.new()
|
||||||
|
em.position = Vector2(pos[0], pos[1])
|
||||||
|
em.global_position = em.position
|
||||||
|
Input.parse_input_event(em)
|
||||||
|
_:
|
||||||
|
push_warning("[headless_driver] unknown event type: %s" % spec.get("type", "<missing>"))
|
||||||
|
|
||||||
|
|
||||||
|
func _capture(path: String) -> void:
|
||||||
|
if path == "":
|
||||||
|
return
|
||||||
|
var vp := get_tree().root.get_viewport()
|
||||||
|
if vp == null:
|
||||||
|
push_warning("[headless_driver] no root viewport")
|
||||||
|
return
|
||||||
|
var tex := vp.get_texture()
|
||||||
|
if tex == null:
|
||||||
|
push_warning("[headless_driver] viewport has no texture (running under dummy renderer?)")
|
||||||
|
return
|
||||||
|
var img := tex.get_image()
|
||||||
|
if img == null or img.is_empty():
|
||||||
|
push_warning("[headless_driver] failed to capture image at frame %d" % _frame)
|
||||||
|
return
|
||||||
|
var dir := path.get_base_dir()
|
||||||
|
if not DirAccess.dir_exists_absolute(dir):
|
||||||
|
DirAccess.make_dir_recursive_absolute(dir)
|
||||||
|
var err := img.save_png(path)
|
||||||
|
if err != OK:
|
||||||
|
push_warning("[headless_driver] save_png failed: %d" % err)
|
||||||
|
else:
|
||||||
|
print("[headless_driver] frame %d -> %s" % [_frame, path])
|
||||||
|
|
||||||
|
|
||||||
|
func _write_state_dump() -> void:
|
||||||
|
if _target_inst == null:
|
||||||
|
return
|
||||||
|
var snapshot := _node_snapshot(_target_inst, _target_inst)
|
||||||
|
var f := FileAccess.open(_state_dump_path, FileAccess.WRITE)
|
||||||
|
if f == null:
|
||||||
|
push_warning("[headless_driver] failed to open state dump path: %s" % _state_dump_path)
|
||||||
|
return
|
||||||
|
f.store_string(JSON.stringify(snapshot, " "))
|
||||||
|
f.close()
|
||||||
|
print("[headless_driver] state dump -> %s" % _state_dump_path)
|
||||||
|
|
||||||
|
|
||||||
|
# Mirrors scene.inspect's shape, plus a small selection of commonly-useful
|
||||||
|
# properties. Kept narrow on purpose — full get_property_list() dumps balloon.
|
||||||
|
static func _node_snapshot(node: Node, scene_root: Node) -> Dictionary:
|
||||||
|
var d := {
|
||||||
|
"name": String(node.name),
|
||||||
|
"class": node.get_class(),
|
||||||
|
"node_path": "." if node == scene_root else String(scene_root.get_path_to(node)),
|
||||||
|
"script": "",
|
||||||
|
"properties": {},
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
var scr := node.get_script() as Script
|
||||||
|
if scr:
|
||||||
|
d.script = scr.resource_path
|
||||||
|
# Common props worth verifying, if present on this node type.
|
||||||
|
for prop_name in ["visible", "position", "global_position", "size", "text",
|
||||||
|
"value", "modulate", "color", "rotation", "scale", "disabled", "pressed"]:
|
||||||
|
if prop_name in node:
|
||||||
|
d.properties[prop_name] = Coerce.to_json(node.get(prop_name))
|
||||||
|
for child in node.get_children():
|
||||||
|
if child.owner == scene_root or child == scene_root:
|
||||||
|
d.children.append(_node_snapshot(child, scene_root))
|
||||||
|
return d
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://iko41fhoo3mm
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://addons/agent_tools/headless/driver.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="HeadlessDriver" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://d2wklut1fj5ed"
|
||||||
|
path="res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://addons/agent_tools/icon.png"
|
||||||
|
dest_files=["res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
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=false
|
||||||
|
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=1
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[plugin]
|
||||||
|
|
||||||
|
name="Agent Tools"
|
||||||
|
description="70 tools + 6 MCP Resources for coding agents to edit scenes, wire signals, create resources, and more through Godot's real APIs instead of hand-editing .tscn/.tres files. If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) per the README: https://github.com/BlakeBukowsky/GodotTools"
|
||||||
|
author="Blake Bukowsky"
|
||||||
|
version="0.3.1"
|
||||||
|
license="MIT"
|
||||||
|
script="plugin.gd"
|
||||||
|
icon="icon.png"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
@tool
|
||||||
|
extends EditorPlugin
|
||||||
|
|
||||||
|
const Server := preload("res://addons/agent_tools/server.gd")
|
||||||
|
const Registry := preload("res://addons/agent_tools/registry.gd")
|
||||||
|
|
||||||
|
const DEFAULT_PORT := 9920
|
||||||
|
const PORT_RANGE_END := 9929 # scan 9920..9929 inclusive
|
||||||
|
const PORT_SETTING := "agent_tools/port"
|
||||||
|
const BRIDGE_AUTOLOAD_NAME := "_MCPGameBridge"
|
||||||
|
const BRIDGE_AUTOLOAD_PATH := "res://addons/agent_tools/runtime/game_bridge.gd"
|
||||||
|
|
||||||
|
const DEFAULT_INTERFACE := "127.0.0.1"
|
||||||
|
const INTERFACE_SETTING := "agent_tools/interface"
|
||||||
|
|
||||||
|
var _server: Server
|
||||||
|
var _registry: Registry
|
||||||
|
var _bound_port: int = -1
|
||||||
|
var _session_file_path: String = ""
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
_registry = Registry.new()
|
||||||
|
_server = Server.new(_registry)
|
||||||
|
add_child(_server)
|
||||||
|
|
||||||
|
# Interface resolution
|
||||||
|
# Defaults to 127.0.0.1, override with project.godot agent_tools/interface
|
||||||
|
var forced_interface := DEFAULT_INTERFACE
|
||||||
|
if ProjectSettings.has_setting(INTERFACE_SETTING):
|
||||||
|
forced_interface = ProjectSettings.get_setting(INTERFACE_SETTING)
|
||||||
|
|
||||||
|
# Port resolution:
|
||||||
|
# 1. If 'agent_tools/port' is set in project.godot, honor it strictly (fail if busy).
|
||||||
|
# 2. Otherwise try DEFAULT_PORT..PORT_RANGE_END and use the first free one.
|
||||||
|
# This lets multiple Godot editors coexist: first-to-start takes 9920, second takes 9921, etc.
|
||||||
|
var forced_port := -1
|
||||||
|
if ProjectSettings.has_setting(PORT_SETTING):
|
||||||
|
forced_port = int(ProjectSettings.get_setting(PORT_SETTING))
|
||||||
|
|
||||||
|
if forced_port != -1:
|
||||||
|
if _server.start(forced_port, forced_interface):
|
||||||
|
_bound_port = forced_port
|
||||||
|
else:
|
||||||
|
push_error("[agent_tools] configured port %s:%d is busy. Either close the other process or change 'agent_tools/port' in project.godot." % [forced_interface, forced_port])
|
||||||
|
else:
|
||||||
|
for p in range(DEFAULT_PORT, PORT_RANGE_END + 1):
|
||||||
|
if _server.start(p, forced_interface):
|
||||||
|
_bound_port = p
|
||||||
|
break
|
||||||
|
if _bound_port == -1:
|
||||||
|
push_error("[agent_tools] all ports %d-%d are busy on %s. Close other Godot editors running agent_tools, or set 'agent_tools/port' in project.godot." % [DEFAULT_PORT, PORT_RANGE_END, forced_interface])
|
||||||
|
|
||||||
|
if _bound_port != -1:
|
||||||
|
print("[agent_tools] listening on %s:%d" % [forced_interface, _bound_port])
|
||||||
|
print("[agent_tools] If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) to use these tools.")
|
||||||
|
print("[agent_tools] Setup guide: https://github.com/BlakeBukowsky/GodotTools#configure-your-agent")
|
||||||
|
_write_session_registry()
|
||||||
|
|
||||||
|
# Register the runtime bridge autoload so editor.game_screenshot / logs.read
|
||||||
|
# can reach the running game. add_autoload_singleton() writes an entry to
|
||||||
|
# project.godot; remove_autoload_singleton() in _exit_tree cleans it up.
|
||||||
|
if not ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
|
||||||
|
add_autoload_singleton(BRIDGE_AUTOLOAD_NAME, BRIDGE_AUTOLOAD_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
_remove_session_registry()
|
||||||
|
if _server:
|
||||||
|
_server.stop()
|
||||||
|
_server.queue_free()
|
||||||
|
_server = null
|
||||||
|
_registry = null
|
||||||
|
_bound_port = -1
|
||||||
|
if ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
|
||||||
|
remove_autoload_singleton(BRIDGE_AUTOLOAD_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
# Session registry is a per-PID JSON file under a cross-project, user-scoped
|
||||||
|
# directory. The MCP shim reads these on startup so `session.list` can discover
|
||||||
|
# every running Godot editor + which port to talk to each.
|
||||||
|
func _write_session_registry() -> void:
|
||||||
|
var dir_path := _session_registry_dir()
|
||||||
|
if dir_path == "":
|
||||||
|
return
|
||||||
|
if not DirAccess.dir_exists_absolute(dir_path):
|
||||||
|
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||||
|
if derr != OK:
|
||||||
|
push_warning("[agent_tools] could not create session registry dir: %s" % dir_path)
|
||||||
|
return
|
||||||
|
var pid := OS.get_process_id()
|
||||||
|
_session_file_path = dir_path.path_join("%d.json" % pid)
|
||||||
|
|
||||||
|
var project_name := ""
|
||||||
|
if ProjectSettings.has_setting("application/config/name"):
|
||||||
|
project_name = str(ProjectSettings.get_setting("application/config/name"))
|
||||||
|
var version := Engine.get_version_info()
|
||||||
|
|
||||||
|
var entry := {
|
||||||
|
"pid": pid,
|
||||||
|
"port": _bound_port,
|
||||||
|
"project_path": ProjectSettings.globalize_path("res://"),
|
||||||
|
"project_name": project_name,
|
||||||
|
"godot_version": "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status],
|
||||||
|
"started_at_unix": int(Time.get_unix_time_from_system()),
|
||||||
|
}
|
||||||
|
var f := FileAccess.open(_session_file_path, FileAccess.WRITE)
|
||||||
|
if f == null:
|
||||||
|
push_warning("[agent_tools] could not write session file: %s" % _session_file_path)
|
||||||
|
return
|
||||||
|
f.store_string(JSON.stringify(entry, " "))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
func _remove_session_registry() -> void:
|
||||||
|
if _session_file_path == "":
|
||||||
|
return
|
||||||
|
if FileAccess.file_exists(_session_file_path):
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.remove_absolute(_session_file_path)
|
||||||
|
_session_file_path = ""
|
||||||
|
|
||||||
|
|
||||||
|
# Cross-project location: <user home>/.godot-agent-tools/sessions/. Chosen to
|
||||||
|
# sit outside any single project's res:// or user:// tree.
|
||||||
|
func _session_registry_dir() -> String:
|
||||||
|
var home := OS.get_environment("USERPROFILE")
|
||||||
|
if home == "":
|
||||||
|
home = OS.get_environment("HOME")
|
||||||
|
if home == "":
|
||||||
|
return ""
|
||||||
|
return home.path_join(".godot-agent-tools").path_join("sessions")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bkvb64lq51xyg
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const SceneTools := preload("res://addons/agent_tools/tools/scene_tools.gd")
|
||||||
|
const SignalTools := preload("res://addons/agent_tools/tools/signal_tools.gd")
|
||||||
|
const ScriptTools := preload("res://addons/agent_tools/tools/script_tools.gd")
|
||||||
|
const ResourceTools := preload("res://addons/agent_tools/tools/resource_tools.gd")
|
||||||
|
const RefsTools := preload("res://addons/agent_tools/tools/refs_tools.gd")
|
||||||
|
const ProjectTools := preload("res://addons/agent_tools/tools/project_tools.gd")
|
||||||
|
const EditorTools := preload("res://addons/agent_tools/tools/editor_tools.gd")
|
||||||
|
const DocsTools := preload("res://addons/agent_tools/tools/docs_tools.gd")
|
||||||
|
const InputTools := preload("res://addons/agent_tools/tools/input_tools.gd")
|
||||||
|
const RunTools := preload("res://addons/agent_tools/tools/run_tools.gd")
|
||||||
|
const FsTools := preload("res://addons/agent_tools/tools/fs_tools.gd")
|
||||||
|
const AnimationTools := preload("res://addons/agent_tools/tools/animation_tools.gd")
|
||||||
|
const UserFsTools := preload("res://addons/agent_tools/tools/user_fs_tools.gd")
|
||||||
|
const PerformanceTools := preload("res://addons/agent_tools/tools/performance_tools.gd")
|
||||||
|
const TestTools := preload("res://addons/agent_tools/tools/test_tools.gd")
|
||||||
|
const ClientTools := preload("res://addons/agent_tools/tools/client_tools.gd")
|
||||||
|
const PhysicsTools := preload("res://addons/agent_tools/tools/physics_tools.gd")
|
||||||
|
const ThemeTools := preload("res://addons/agent_tools/tools/theme_tools.gd")
|
||||||
|
|
||||||
|
|
||||||
|
# Returns {"data": <any>} on success, {"error": {"code": int, "message": str}} on failure.
|
||||||
|
func dispatch(method: String, params: Dictionary) -> Dictionary:
|
||||||
|
match method:
|
||||||
|
"scene.new": return SceneTools.new_scene(params)
|
||||||
|
"scene.add_node": return SceneTools.add_node(params)
|
||||||
|
"scene.instance_packed": return SceneTools.instance_packed(params)
|
||||||
|
"scene.duplicate_node": return SceneTools.duplicate_node(params)
|
||||||
|
"scene.remove_node": return SceneTools.remove_node(params)
|
||||||
|
"scene.reparent": return SceneTools.reparent(params)
|
||||||
|
"scene.set_property": return SceneTools.set_property(params)
|
||||||
|
"scene.get_property": return SceneTools.get_property(params)
|
||||||
|
"scene.call_method": return SceneTools.call_method(params)
|
||||||
|
"scene.build_tree": return SceneTools.build_tree(params)
|
||||||
|
"scene.open": return SceneTools.open_scene(params)
|
||||||
|
"scene.save": return SceneTools.save_scene(params)
|
||||||
|
"scene.current": return SceneTools.current(params)
|
||||||
|
"scene.inspect": return SceneTools.inspect(params)
|
||||||
|
"scene.capture_screenshot": return SceneTools.capture_screenshot(params)
|
||||||
|
"signal.connect": return SignalTools.connect_signal(params)
|
||||||
|
"signal.disconnect": return SignalTools.disconnect_signal(params)
|
||||||
|
"signal.list": return SignalTools.list_signals(params)
|
||||||
|
"script.create": return ScriptTools.create(params)
|
||||||
|
"script.attach": return ScriptTools.attach(params)
|
||||||
|
"script.patch": return ScriptTools.patch(params)
|
||||||
|
"resource.create": return ResourceTools.create(params)
|
||||||
|
"resource.set_property": return ResourceTools.set_property(params)
|
||||||
|
"resource.call_method": return ResourceTools.call_method(params)
|
||||||
|
"refs.validate_project": return RefsTools.validate_project(params)
|
||||||
|
"refs.find_usages": return RefsTools.find_usages(params)
|
||||||
|
"refs.rename": return RefsTools.rename(params)
|
||||||
|
"refs.rename_class": return RefsTools.rename_class(params)
|
||||||
|
"project.get_setting": return ProjectTools.get_setting(params)
|
||||||
|
"project.set_setting": return ProjectTools.set_setting(params)
|
||||||
|
"autoload.add": return ProjectTools.autoload_add(params)
|
||||||
|
"autoload.remove": return ProjectTools.autoload_remove(params)
|
||||||
|
"autoload.list": return ProjectTools.autoload_list(params)
|
||||||
|
"editor.reload_filesystem": return EditorTools.reload_filesystem(params)
|
||||||
|
"editor.save_all_scenes": return EditorTools.save_all_scenes(params)
|
||||||
|
"editor.state": return EditorTools.state(params)
|
||||||
|
"editor.selection_get": return EditorTools.selection_get(params)
|
||||||
|
"editor.selection_set": return EditorTools.selection_set(params)
|
||||||
|
"editor.game_screenshot": return EditorTools.game_screenshot(params)
|
||||||
|
"logs.read": return EditorTools.logs_read(params)
|
||||||
|
"logs.clear": return EditorTools.logs_clear(params)
|
||||||
|
"docs.class_ref": return DocsTools.class_ref(params)
|
||||||
|
"input_map.add_action": return InputTools.add_action(params)
|
||||||
|
"input_map.add_event": return InputTools.add_event(params)
|
||||||
|
"input_map.list": return InputTools.list_actions(params)
|
||||||
|
"input_map.remove_action": return InputTools.remove_action(params)
|
||||||
|
"input_map.remove_event": return InputTools.remove_event(params)
|
||||||
|
"run.scene_headless": return RunTools.scene_headless(params)
|
||||||
|
"fs.list": return FsTools.list(params)
|
||||||
|
"fs.read_text": return FsTools.read_text(params)
|
||||||
|
"fs.write_text": return FsTools.write_text(params)
|
||||||
|
"user_fs.read": return UserFsTools.read(params)
|
||||||
|
"user_fs.list": return UserFsTools.list(params)
|
||||||
|
"animation.list": return AnimationTools.list_animations(params)
|
||||||
|
"animation.add_animation": return AnimationTools.add_animation(params)
|
||||||
|
"animation.remove_animation": return AnimationTools.remove_animation(params)
|
||||||
|
"animation.add_value_track": return AnimationTools.add_value_track(params)
|
||||||
|
"performance.monitors": return PerformanceTools.monitors(params)
|
||||||
|
"test.run": return TestTools.run(params)
|
||||||
|
"client.list": return ClientTools.list_clients(params)
|
||||||
|
"client.configure": return ClientTools.configure(params)
|
||||||
|
"client.remove": return ClientTools.remove(params)
|
||||||
|
"physics.autofit_collision_shape_2d": return PhysicsTools.autofit_collision_shape_2d(params)
|
||||||
|
"theme.set_color": return ThemeTools.set_color(params)
|
||||||
|
"theme.set_constant": return ThemeTools.set_constant(params)
|
||||||
|
"theme.set_font_size": return ThemeTools.set_font_size(params)
|
||||||
|
"theme.set_stylebox_flat": return ThemeTools.set_stylebox_flat(params)
|
||||||
|
"batch.execute": return batch_execute(params)
|
||||||
|
_:
|
||||||
|
return {"error": {"code": -32601, "message": "method not found: %s" % method}}
|
||||||
|
|
||||||
|
|
||||||
|
# batch.execute — run multiple tool calls in one round trip.
|
||||||
|
# Params: {calls: [{method, params?}, ...], stop_on_error?: false}
|
||||||
|
# Returns: {results: [{method, ok, data|error, index}, ...]}
|
||||||
|
func batch_execute(params: Dictionary) -> Dictionary:
|
||||||
|
var calls: Array = params.get("calls", [])
|
||||||
|
var stop_on_error: bool = params.get("stop_on_error", false)
|
||||||
|
if calls.is_empty():
|
||||||
|
return {"error": {"code": -32602, "message": "missing 'calls' (array of {method, params})"}}
|
||||||
|
|
||||||
|
var results: Array = []
|
||||||
|
for i in calls.size():
|
||||||
|
var entry = calls[i]
|
||||||
|
if typeof(entry) != TYPE_DICTIONARY or not entry.has("method"):
|
||||||
|
results.append({"index": i, "ok": false, "error": "each call needs {method, params?}"})
|
||||||
|
if stop_on_error:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
var sub_method: String = entry.method
|
||||||
|
var sub_params: Dictionary = entry.get("params", {})
|
||||||
|
var sub_result: Dictionary = dispatch(sub_method, sub_params)
|
||||||
|
var row := {"index": i, "method": sub_method}
|
||||||
|
if sub_result.has("error"):
|
||||||
|
row["ok"] = false
|
||||||
|
row["error"] = sub_result.error
|
||||||
|
else:
|
||||||
|
row["ok"] = true
|
||||||
|
row["data"] = sub_result.get("data")
|
||||||
|
results.append(row)
|
||||||
|
if stop_on_error and not row.ok:
|
||||||
|
break
|
||||||
|
return {"data": {"results": results, "count": results.size(), "requested": calls.size()}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b5pe4omwjkjgo
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Custom Logger that forwards every captured message to the game bridge's
|
||||||
|
# log buffer. Has to live in its own top-level file — nested classes don't
|
||||||
|
# reliably register Logger's virtual overrides (_log_error / _log_message).
|
||||||
|
|
||||||
|
extends Logger
|
||||||
|
|
||||||
|
var _bridge: Node
|
||||||
|
|
||||||
|
|
||||||
|
func _init(bridge: Node) -> void:
|
||||||
|
_bridge = bridge
|
||||||
|
|
||||||
|
|
||||||
|
func _log_error(function: String, file: String, line: int, code: String, rationale: String, editor_notify: bool, error_type: int, script_backtraces: Array) -> void:
|
||||||
|
# error_type is Logger.ErrorType: 0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER.
|
||||||
|
var level: String
|
||||||
|
match error_type:
|
||||||
|
1: level = "WARNING"
|
||||||
|
2: level = "SCRIPT"
|
||||||
|
3: level = "SHADER"
|
||||||
|
_: level = "ERROR"
|
||||||
|
var msg: String = rationale if rationale != "" else code
|
||||||
|
if file != "" and line > 0:
|
||||||
|
msg += " (at %s:%d in %s)" % [file, line, function]
|
||||||
|
if _bridge and _bridge.has_method("log_entry"):
|
||||||
|
_bridge.log_entry(level, msg)
|
||||||
|
|
||||||
|
|
||||||
|
func _log_message(message: String, error: bool) -> void:
|
||||||
|
if _bridge and _bridge.has_method("log_entry"):
|
||||||
|
_bridge.log_entry("ERROR" if error else "INFO", message.strip_edges())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://wosqs8dlh1vx
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# MCPGameBridge — runtime autoload registered by the Agent Tools plugin.
|
||||||
|
#
|
||||||
|
# Bridges the editor-side agent tools to the *running* game via a file-based
|
||||||
|
# request/response channel under res://.godot/agent_tools/bridge/. Currently:
|
||||||
|
# - screenshot: capture the running viewport, save to a path
|
||||||
|
# - logs: append print/push_error/push_warning output to a buffer
|
||||||
|
#
|
||||||
|
# This script ONLY runs in the game process (checks Engine.is_editor_hint()
|
||||||
|
# on startup and disables itself in the editor). The editor plugin registers
|
||||||
|
# it via add_autoload_singleton() on _enter_tree and removes it on _exit_tree.
|
||||||
|
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const BridgeLogger := preload("res://addons/agent_tools/runtime/bridge_logger.gd")
|
||||||
|
const BRIDGE_DIR := "res://.godot/agent_tools/bridge"
|
||||||
|
const REQUEST_PATH := BRIDGE_DIR + "/request.json"
|
||||||
|
const RESPONSE_PATH := BRIDGE_DIR + "/response.json"
|
||||||
|
const LOGS_PATH := BRIDGE_DIR + "/logs.json"
|
||||||
|
const POLL_INTERVAL_SEC := 0.05 # 20 Hz — cheap enough, responsive enough
|
||||||
|
const LOG_BUFFER_MAX := 500
|
||||||
|
|
||||||
|
var _timer: Timer
|
||||||
|
var _log_buffer: Array = []
|
||||||
|
var _initialized := false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
# Tool mode — don't do anything inside the editor. The bridge only
|
||||||
|
# makes sense in the running game.
|
||||||
|
return
|
||||||
|
_initialized = true
|
||||||
|
_ensure_bridge_dir()
|
||||||
|
# Poll for editor requests on a timer so we don't eat a _process callback.
|
||||||
|
_timer = Timer.new()
|
||||||
|
_timer.wait_time = POLL_INTERVAL_SEC
|
||||||
|
_timer.one_shot = false
|
||||||
|
_timer.autostart = true
|
||||||
|
_timer.timeout.connect(_poll_request)
|
||||||
|
add_child(_timer)
|
||||||
|
# Attach our custom logger to capture print / push_error / push_warning
|
||||||
|
# from the game and mirror them to the log buffer file so logs.read can
|
||||||
|
# pick them up from the editor side.
|
||||||
|
var logger := BridgeLogger.new(self)
|
||||||
|
OS.add_logger(logger)
|
||||||
|
|
||||||
|
|
||||||
|
func _ensure_bridge_dir() -> void:
|
||||||
|
if not DirAccess.dir_exists_absolute(BRIDGE_DIR):
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.make_dir_recursive_absolute(BRIDGE_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
# Called by MCPBridgeLogger whenever the game writes to stdout/stderr.
|
||||||
|
func log_entry(level: String, message: String) -> void:
|
||||||
|
_log_buffer.append({
|
||||||
|
"level": level,
|
||||||
|
"message": message,
|
||||||
|
"time_ms": Time.get_ticks_msec(),
|
||||||
|
})
|
||||||
|
if _log_buffer.size() > LOG_BUFFER_MAX:
|
||||||
|
_log_buffer = _log_buffer.slice(_log_buffer.size() - LOG_BUFFER_MAX)
|
||||||
|
# Flush to disk so editor-side logs.read sees it. Debounced by poll interval
|
||||||
|
# in practice because each log call flushes but writes are cheap.
|
||||||
|
_flush_logs()
|
||||||
|
|
||||||
|
|
||||||
|
func _flush_logs() -> void:
|
||||||
|
var f := FileAccess.open(LOGS_PATH, FileAccess.WRITE)
|
||||||
|
if f == null:
|
||||||
|
return
|
||||||
|
f.store_string(JSON.stringify(_log_buffer))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
func _poll_request() -> void:
|
||||||
|
if not FileAccess.file_exists(REQUEST_PATH):
|
||||||
|
return
|
||||||
|
var f := FileAccess.open(REQUEST_PATH, FileAccess.READ)
|
||||||
|
if f == null:
|
||||||
|
return
|
||||||
|
var body := f.get_as_text()
|
||||||
|
f.close()
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.remove_absolute(REQUEST_PATH)
|
||||||
|
|
||||||
|
var parsed = JSON.parse_string(body)
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
_write_response({"id": "", "error": "malformed request"})
|
||||||
|
return
|
||||||
|
var req_id: String = parsed.get("id", "")
|
||||||
|
match parsed.get("type", ""):
|
||||||
|
"screenshot":
|
||||||
|
_handle_screenshot(req_id, parsed)
|
||||||
|
_:
|
||||||
|
_write_response({"id": req_id, "error": "unknown request type: %s" % parsed.get("type", "<missing>")})
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_screenshot(req_id: String, req: Dictionary) -> void:
|
||||||
|
var output_path: String = req.get("output", "")
|
||||||
|
if output_path == "":
|
||||||
|
_write_response({"id": req_id, "error": "missing 'output'"})
|
||||||
|
return
|
||||||
|
var vp := get_viewport()
|
||||||
|
if vp == null:
|
||||||
|
_write_response({"id": req_id, "error": "no viewport available"})
|
||||||
|
return
|
||||||
|
var tex := vp.get_texture()
|
||||||
|
if tex == null:
|
||||||
|
_write_response({"id": req_id, "error": "viewport has no texture"})
|
||||||
|
return
|
||||||
|
var img := tex.get_image()
|
||||||
|
if img == null or img.is_empty():
|
||||||
|
_write_response({"id": req_id, "error": "failed to capture image"})
|
||||||
|
return
|
||||||
|
var dir := output_path.get_base_dir()
|
||||||
|
if not DirAccess.dir_exists_absolute(dir):
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.make_dir_recursive_absolute(dir)
|
||||||
|
var save_err := img.save_png(output_path)
|
||||||
|
if save_err != OK:
|
||||||
|
_write_response({"id": req_id, "error": "save_png failed: %d" % save_err})
|
||||||
|
return
|
||||||
|
_write_response({"id": req_id, "path": output_path})
|
||||||
|
|
||||||
|
|
||||||
|
func _write_response(payload: Dictionary) -> void:
|
||||||
|
var f := FileAccess.open(RESPONSE_PATH, FileAccess.WRITE)
|
||||||
|
if f == null:
|
||||||
|
return
|
||||||
|
f.store_string(JSON.stringify(payload))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cds186n8uv4f0
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
@tool
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Line-delimited JSON-RPC over TCP on localhost.
|
||||||
|
# One request per line: {"id": <any>, "method": "<name>", "params": {...}}
|
||||||
|
# One response per line: {"id": <echoed>, "result": <data>} OR {"id": ..., "error": {"code": int, "message": str, "data"?: any}}
|
||||||
|
|
||||||
|
const MAX_MESSAGE_BYTES := 1_048_576 # 1 MiB per line — safety cap
|
||||||
|
|
||||||
|
var _tcp: TCPServer
|
||||||
|
var _clients: Array = [] # Array of {peer: StreamPeerTCP, buf: PackedByteArray}
|
||||||
|
var _registry
|
||||||
|
|
||||||
|
|
||||||
|
func _init(registry) -> void:
|
||||||
|
_registry = registry
|
||||||
|
|
||||||
|
|
||||||
|
func start(port: int, interface: String = "127.0.0.1") -> bool:
|
||||||
|
_tcp = TCPServer.new()
|
||||||
|
var err := _tcp.listen(port, interface)
|
||||||
|
if err != OK:
|
||||||
|
_tcp = null
|
||||||
|
return false
|
||||||
|
set_process(true)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func stop() -> void:
|
||||||
|
set_process(false)
|
||||||
|
for c in _clients:
|
||||||
|
(c.peer as StreamPeerTCP).disconnect_from_host()
|
||||||
|
_clients.clear()
|
||||||
|
if _tcp:
|
||||||
|
_tcp.stop()
|
||||||
|
_tcp = null
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if _tcp == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
while _tcp.is_connection_available():
|
||||||
|
var peer := _tcp.take_connection()
|
||||||
|
_clients.append({"peer": peer, "buf": PackedByteArray()})
|
||||||
|
|
||||||
|
var still_alive: Array = []
|
||||||
|
for c in _clients:
|
||||||
|
var peer: StreamPeerTCP = c.peer
|
||||||
|
peer.poll()
|
||||||
|
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||||
|
continue
|
||||||
|
var available := peer.get_available_bytes()
|
||||||
|
if available > 0:
|
||||||
|
var chunk := peer.get_data(available)
|
||||||
|
if chunk[0] == OK:
|
||||||
|
c.buf.append_array(chunk[1])
|
||||||
|
_drain_messages(c)
|
||||||
|
if (c.buf as PackedByteArray).size() > MAX_MESSAGE_BYTES:
|
||||||
|
_send_error(peer, null, -32700, "message exceeds %d bytes" % MAX_MESSAGE_BYTES)
|
||||||
|
peer.disconnect_from_host()
|
||||||
|
continue
|
||||||
|
still_alive.append(c)
|
||||||
|
_clients = still_alive
|
||||||
|
|
||||||
|
|
||||||
|
func _drain_messages(c: Dictionary) -> void:
|
||||||
|
while true:
|
||||||
|
var buf: PackedByteArray = c.buf
|
||||||
|
var nl := buf.find(0x0A) # \n
|
||||||
|
if nl < 0:
|
||||||
|
return
|
||||||
|
var line_bytes := buf.slice(0, nl)
|
||||||
|
c.buf = buf.slice(nl + 1)
|
||||||
|
var line := line_bytes.get_string_from_utf8().strip_edges()
|
||||||
|
if line == "":
|
||||||
|
continue
|
||||||
|
_handle_line(c.peer, line)
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_line(peer: StreamPeerTCP, line: String) -> void:
|
||||||
|
var parsed = JSON.parse_string(line)
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
_send_error(peer, null, -32700, "parse error — expected JSON object")
|
||||||
|
return
|
||||||
|
var id = parsed.get("id")
|
||||||
|
var method: String = parsed.get("method", "")
|
||||||
|
var params: Dictionary = parsed.get("params", {})
|
||||||
|
if method == "":
|
||||||
|
_send_error(peer, id, -32600, "missing 'method'")
|
||||||
|
return
|
||||||
|
|
||||||
|
var result: Dictionary = _registry.dispatch(method, params)
|
||||||
|
# Guard against silent tool-module failures: when a tool's .gd file has a parse
|
||||||
|
# error, preload() returns null, calls on it produce no output, and dispatch
|
||||||
|
# returns its default {}. Surface that instead of sending {"result": null}.
|
||||||
|
if result.is_empty():
|
||||||
|
_send_error(peer, id, -32000,
|
||||||
|
"tool returned empty response — likely a parse error in the tool module. Check Godot's Output panel for the actual error.",
|
||||||
|
{"method": method})
|
||||||
|
elif result.has("error"):
|
||||||
|
var e: Dictionary = result.error
|
||||||
|
_send_error(peer, id, e.get("code", -32000), e.get("message", ""), e.get("data"))
|
||||||
|
elif result.has("data"):
|
||||||
|
_send_result(peer, id, result.data)
|
||||||
|
else:
|
||||||
|
_send_error(peer, id, -32000,
|
||||||
|
"tool returned malformed response (missing both 'data' and 'error')",
|
||||||
|
{"method": method})
|
||||||
|
|
||||||
|
|
||||||
|
func _send_result(peer: StreamPeerTCP, id, data) -> void:
|
||||||
|
_write(peer, {"id": id, "result": data})
|
||||||
|
|
||||||
|
|
||||||
|
func _send_error(peer: StreamPeerTCP, id, code: int, msg: String, data = null) -> void:
|
||||||
|
var err := {"code": code, "message": msg}
|
||||||
|
if data != null:
|
||||||
|
err["data"] = data
|
||||||
|
_write(peer, {"id": id, "error": err})
|
||||||
|
|
||||||
|
|
||||||
|
func _write(peer: StreamPeerTCP, obj: Dictionary) -> void:
|
||||||
|
var line := JSON.stringify(obj) + "\n"
|
||||||
|
peer.put_data(line.to_utf8_buffer())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://pcfsf0ijlm8x
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Shared JSON-to-Godot type coercion for scene.set_property, resource.set_property,
|
||||||
|
# and any other tool that assigns a user-provided value into a typed property slot.
|
||||||
|
#
|
||||||
|
# Contract:
|
||||||
|
# coerce(value, target_type: int) -> Variant
|
||||||
|
#
|
||||||
|
# Returns the coerced value on success. On mismatch, returns a Dictionary with
|
||||||
|
# a single "_error" key describing the failure — callers check `is Dictionary`
|
||||||
|
# and forward the message as a tool-level error.
|
||||||
|
#
|
||||||
|
# New types go here. Don't re-add `_coerce` to individual tool files; route
|
||||||
|
# through Coerce.coerce() instead.
|
||||||
|
|
||||||
|
static func coerce(value, target_type: int):
|
||||||
|
match target_type:
|
||||||
|
TYPE_BOOL:
|
||||||
|
# Godot 4 has no `bool()` constructor (it errors at runtime). Convert via
|
||||||
|
# truthiness, but parse strings explicitly so "false"/"0" don't read as
|
||||||
|
# truthy the way any non-empty string otherwise would.
|
||||||
|
if value is String:
|
||||||
|
return value.strip_edges().to_lower() in ["true", "1", "yes", "on"]
|
||||||
|
return true if value else false
|
||||||
|
TYPE_INT:
|
||||||
|
return int(value)
|
||||||
|
TYPE_FLOAT:
|
||||||
|
return float(value)
|
||||||
|
TYPE_STRING, TYPE_STRING_NAME:
|
||||||
|
return String(value)
|
||||||
|
TYPE_NODE_PATH:
|
||||||
|
return NodePath(String(value))
|
||||||
|
TYPE_VECTOR2:
|
||||||
|
if value is Array and value.size() == 2:
|
||||||
|
return Vector2(value[0], value[1])
|
||||||
|
return {"_error": "Vector2 expects [x, y]"}
|
||||||
|
TYPE_VECTOR2I:
|
||||||
|
if value is Array and value.size() == 2:
|
||||||
|
return Vector2i(int(value[0]), int(value[1]))
|
||||||
|
return {"_error": "Vector2i expects [x, y]"}
|
||||||
|
TYPE_VECTOR3:
|
||||||
|
if value is Array and value.size() == 3:
|
||||||
|
return Vector3(value[0], value[1], value[2])
|
||||||
|
return {"_error": "Vector3 expects [x, y, z]"}
|
||||||
|
TYPE_VECTOR3I:
|
||||||
|
if value is Array and value.size() == 3:
|
||||||
|
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||||
|
return {"_error": "Vector3i expects [x, y, z]"}
|
||||||
|
TYPE_VECTOR4:
|
||||||
|
if value is Array and value.size() == 4:
|
||||||
|
return Vector4(value[0], value[1], value[2], value[3])
|
||||||
|
return {"_error": "Vector4 expects [x, y, z, w]"}
|
||||||
|
TYPE_VECTOR4I:
|
||||||
|
if value is Array and value.size() == 4:
|
||||||
|
return Vector4i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
|
||||||
|
return {"_error": "Vector4i expects [x, y, z, w]"}
|
||||||
|
TYPE_RECT2:
|
||||||
|
if value is Array and value.size() == 4:
|
||||||
|
return Rect2(value[0], value[1], value[2], value[3])
|
||||||
|
return {"_error": "Rect2 expects [x, y, width, height]"}
|
||||||
|
TYPE_RECT2I:
|
||||||
|
if value is Array and value.size() == 4:
|
||||||
|
return Rect2i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
|
||||||
|
return {"_error": "Rect2i expects [x, y, width, height]"}
|
||||||
|
TYPE_QUATERNION:
|
||||||
|
if value is Array and value.size() == 4:
|
||||||
|
return Quaternion(value[0], value[1], value[2], value[3])
|
||||||
|
return {"_error": "Quaternion expects [x, y, z, w]"}
|
||||||
|
TYPE_TRANSFORM2D:
|
||||||
|
# TRS form: {origin: [x,y], rotation: radians, scale: [x,y]} — all optional.
|
||||||
|
if value is Dictionary:
|
||||||
|
var origin = value.get("origin", [0, 0])
|
||||||
|
var rotation: float = float(value.get("rotation", 0.0))
|
||||||
|
var scale_v = value.get("scale", [1, 1])
|
||||||
|
var skew: float = float(value.get("skew", 0.0))
|
||||||
|
if not (origin is Array and origin.size() == 2 and scale_v is Array and scale_v.size() == 2):
|
||||||
|
return {"_error": "Transform2D origin/scale expect [x, y]"}
|
||||||
|
return Transform2D(
|
||||||
|
rotation,
|
||||||
|
Vector2(scale_v[0], scale_v[1]),
|
||||||
|
skew,
|
||||||
|
Vector2(origin[0], origin[1])
|
||||||
|
)
|
||||||
|
return {"_error": "Transform2D expects {origin: [x,y], rotation: radians, scale: [x,y], skew?}"}
|
||||||
|
TYPE_TRANSFORM3D:
|
||||||
|
# TRS form: {origin: [x,y,z], rotation: [x,y,z] euler radians, scale: [x,y,z]}.
|
||||||
|
if value is Dictionary:
|
||||||
|
var origin = value.get("origin", [0, 0, 0])
|
||||||
|
var rotation = value.get("rotation", [0, 0, 0])
|
||||||
|
var scale_v = value.get("scale", [1, 1, 1])
|
||||||
|
if not (origin is Array and origin.size() == 3
|
||||||
|
and rotation is Array and rotation.size() == 3
|
||||||
|
and scale_v is Array and scale_v.size() == 3):
|
||||||
|
return {"_error": "Transform3D origin/rotation/scale expect [x, y, z]"}
|
||||||
|
var basis := Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2]))
|
||||||
|
basis = basis.scaled(Vector3(scale_v[0], scale_v[1], scale_v[2]))
|
||||||
|
return Transform3D(basis, Vector3(origin[0], origin[1], origin[2]))
|
||||||
|
return {"_error": "Transform3D expects {origin: [x,y,z], rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
|
||||||
|
TYPE_BASIS:
|
||||||
|
if value is Dictionary:
|
||||||
|
var rotation = value.get("rotation", [0, 0, 0])
|
||||||
|
var scale_v = value.get("scale", [1, 1, 1])
|
||||||
|
if not (rotation is Array and rotation.size() == 3
|
||||||
|
and scale_v is Array and scale_v.size() == 3):
|
||||||
|
return {"_error": "Basis rotation/scale expect [x, y, z]"}
|
||||||
|
return Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2])).scaled(
|
||||||
|
Vector3(scale_v[0], scale_v[1], scale_v[2]))
|
||||||
|
return {"_error": "Basis expects {rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
|
||||||
|
TYPE_AABB:
|
||||||
|
if value is Dictionary:
|
||||||
|
var pos = value.get("position", [0, 0, 0])
|
||||||
|
var size = value.get("size", [0, 0, 0])
|
||||||
|
if not (pos is Array and pos.size() == 3 and size is Array and size.size() == 3):
|
||||||
|
return {"_error": "AABB position/size expect [x, y, z]"}
|
||||||
|
return AABB(Vector3(pos[0], pos[1], pos[2]), Vector3(size[0], size[1], size[2]))
|
||||||
|
return {"_error": "AABB expects {position: [x,y,z], size: [x,y,z]}"}
|
||||||
|
TYPE_PLANE:
|
||||||
|
if value is Dictionary:
|
||||||
|
var normal = value.get("normal", [0, 1, 0])
|
||||||
|
var d: float = float(value.get("d", 0.0))
|
||||||
|
if not (normal is Array and normal.size() == 3):
|
||||||
|
return {"_error": "Plane normal expects [x, y, z]"}
|
||||||
|
return Plane(Vector3(normal[0], normal[1], normal[2]), d)
|
||||||
|
return {"_error": "Plane expects {normal: [x,y,z], d: float}"}
|
||||||
|
TYPE_COLOR:
|
||||||
|
if value is Array and (value.size() == 3 or value.size() == 4):
|
||||||
|
var a := float(value[3]) if value.size() == 4 else 1.0
|
||||||
|
return Color(value[0], value[1], value[2], a)
|
||||||
|
if value is String:
|
||||||
|
return Color(value)
|
||||||
|
return {"_error": "Color expects [r,g,b(,a)] or '#rrggbb(aa)'"}
|
||||||
|
TYPE_PACKED_STRING_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedStringArray()
|
||||||
|
for s in value:
|
||||||
|
out.append(String(s))
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedStringArray expects an array of strings"}
|
||||||
|
TYPE_PACKED_INT32_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedInt32Array()
|
||||||
|
for i in value:
|
||||||
|
out.append(int(i))
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedInt32Array expects an array of numbers"}
|
||||||
|
TYPE_PACKED_INT64_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedInt64Array()
|
||||||
|
for i in value:
|
||||||
|
out.append(int(i))
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedInt64Array expects an array of numbers"}
|
||||||
|
TYPE_PACKED_FLOAT32_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedFloat32Array()
|
||||||
|
for f in value:
|
||||||
|
out.append(float(f))
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedFloat32Array expects an array of numbers"}
|
||||||
|
TYPE_PACKED_FLOAT64_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedFloat64Array()
|
||||||
|
for f in value:
|
||||||
|
out.append(float(f))
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedFloat64Array expects an array of numbers"}
|
||||||
|
TYPE_PACKED_VECTOR2_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedVector2Array()
|
||||||
|
for v in value:
|
||||||
|
if v is Array and v.size() == 2:
|
||||||
|
out.append(Vector2(v[0], v[1]))
|
||||||
|
else:
|
||||||
|
return {"_error": "PackedVector2Array expects [[x, y], ...]"}
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedVector2Array expects an array of [x, y] pairs"}
|
||||||
|
TYPE_PACKED_VECTOR3_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedVector3Array()
|
||||||
|
for v in value:
|
||||||
|
if v is Array and v.size() == 3:
|
||||||
|
out.append(Vector3(v[0], v[1], v[2]))
|
||||||
|
else:
|
||||||
|
return {"_error": "PackedVector3Array expects [[x, y, z], ...]"}
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedVector3Array expects an array of [x, y, z] triples"}
|
||||||
|
TYPE_PACKED_COLOR_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedColorArray()
|
||||||
|
for c in value:
|
||||||
|
if c is Array and (c.size() == 3 or c.size() == 4):
|
||||||
|
var a := float(c[3]) if c.size() == 4 else 1.0
|
||||||
|
out.append(Color(c[0], c[1], c[2], a))
|
||||||
|
elif c is String:
|
||||||
|
out.append(Color(c))
|
||||||
|
else:
|
||||||
|
return {"_error": "PackedColorArray expects [[r,g,b(,a)], ...] or ['#hex', ...]"}
|
||||||
|
return out
|
||||||
|
return {"_error": "PackedColorArray expects an array of colors"}
|
||||||
|
TYPE_OBJECT:
|
||||||
|
# Auto-load a Resource when the caller passes a "res://..." or "uid://..." path string.
|
||||||
|
# Without this, Godot silently drops the assignment and leaves the slot null.
|
||||||
|
if value is String and (value.begins_with("res://") or value.begins_with("uid://")):
|
||||||
|
var loaded := ResourceLoader.load(value)
|
||||||
|
if loaded == null:
|
||||||
|
return {"_error": "failed to load resource: %s" % value}
|
||||||
|
return loaded
|
||||||
|
return value
|
||||||
|
_:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Inverse of coerce(): convert a Godot value back to a JSON-native form so the
|
||||||
|
# response echo is unambiguous data rather than Godot's str() stringification
|
||||||
|
# (which turns Vector2 into "(x, y)", Color into "(r, g, b, a)", null Object
|
||||||
|
# into "<Object#null>", etc., all of which look like success strings).
|
||||||
|
static func to_json(value):
|
||||||
|
match typeof(value):
|
||||||
|
TYPE_VECTOR2:
|
||||||
|
return [value.x, value.y]
|
||||||
|
TYPE_VECTOR2I:
|
||||||
|
return [int(value.x), int(value.y)]
|
||||||
|
TYPE_VECTOR3:
|
||||||
|
return [value.x, value.y, value.z]
|
||||||
|
TYPE_VECTOR3I:
|
||||||
|
return [int(value.x), int(value.y), int(value.z)]
|
||||||
|
TYPE_VECTOR4:
|
||||||
|
return [value.x, value.y, value.z, value.w]
|
||||||
|
TYPE_VECTOR4I:
|
||||||
|
return [int(value.x), int(value.y), int(value.z), int(value.w)]
|
||||||
|
TYPE_RECT2:
|
||||||
|
return [value.position.x, value.position.y, value.size.x, value.size.y]
|
||||||
|
TYPE_RECT2I:
|
||||||
|
return [int(value.position.x), int(value.position.y), int(value.size.x), int(value.size.y)]
|
||||||
|
TYPE_COLOR:
|
||||||
|
return [value.r, value.g, value.b, value.a]
|
||||||
|
TYPE_QUATERNION:
|
||||||
|
return [value.x, value.y, value.z, value.w]
|
||||||
|
TYPE_NODE_PATH:
|
||||||
|
return String(value)
|
||||||
|
TYPE_STRING_NAME:
|
||||||
|
return String(value)
|
||||||
|
TYPE_TRANSFORM2D:
|
||||||
|
return {
|
||||||
|
"origin": [value.origin.x, value.origin.y],
|
||||||
|
"x": [value.x.x, value.x.y],
|
||||||
|
"y": [value.y.x, value.y.y],
|
||||||
|
}
|
||||||
|
TYPE_TRANSFORM3D:
|
||||||
|
return {
|
||||||
|
"origin": [value.origin.x, value.origin.y, value.origin.z],
|
||||||
|
"basis": to_json(value.basis),
|
||||||
|
}
|
||||||
|
TYPE_BASIS:
|
||||||
|
return [
|
||||||
|
[value.x.x, value.x.y, value.x.z],
|
||||||
|
[value.y.x, value.y.y, value.y.z],
|
||||||
|
[value.z.x, value.z.y, value.z.z],
|
||||||
|
]
|
||||||
|
TYPE_AABB:
|
||||||
|
return {
|
||||||
|
"position": [value.position.x, value.position.y, value.position.z],
|
||||||
|
"size": [value.size.x, value.size.y, value.size.z],
|
||||||
|
}
|
||||||
|
TYPE_PLANE:
|
||||||
|
return {
|
||||||
|
"normal": [value.normal.x, value.normal.y, value.normal.z],
|
||||||
|
"d": value.d,
|
||||||
|
}
|
||||||
|
TYPE_OBJECT:
|
||||||
|
if value == null:
|
||||||
|
return null
|
||||||
|
if value is Resource and value.resource_path != "":
|
||||||
|
return {"class": value.get_class(), "resource_path": value.resource_path}
|
||||||
|
return {"class": (value as Object).get_class()}
|
||||||
|
TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, \
|
||||||
|
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY:
|
||||||
|
return Array(value)
|
||||||
|
TYPE_PACKED_VECTOR2_ARRAY:
|
||||||
|
var out_v2: Array = []
|
||||||
|
for v in value:
|
||||||
|
out_v2.append([v.x, v.y])
|
||||||
|
return out_v2
|
||||||
|
TYPE_PACKED_VECTOR3_ARRAY:
|
||||||
|
var out_v3: Array = []
|
||||||
|
for v in value:
|
||||||
|
out_v3.append([v.x, v.y, v.z])
|
||||||
|
return out_v3
|
||||||
|
TYPE_PACKED_COLOR_ARRAY:
|
||||||
|
var out_c: Array = []
|
||||||
|
for c in value:
|
||||||
|
out_c.append([c.r, c.g, c.b, c.a])
|
||||||
|
return out_c
|
||||||
|
TYPE_ARRAY:
|
||||||
|
var out_a: Array = []
|
||||||
|
for item in value:
|
||||||
|
out_a.append(to_json(item))
|
||||||
|
return out_a
|
||||||
|
TYPE_DICTIONARY:
|
||||||
|
var out_d: Dictionary = {}
|
||||||
|
for k in value:
|
||||||
|
out_d[str(k)] = to_json(value[k])
|
||||||
|
return out_d
|
||||||
|
_:
|
||||||
|
return value
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cncjf8g7jufrc
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Tools for working with AnimationPlayer nodes in the currently-edited scene.
|
||||||
|
# Animations live in AnimationLibrary resources attached to the player; the "" library
|
||||||
|
# is the default, named libraries prefix animations as "lib/anim".
|
||||||
|
|
||||||
|
# animation.list — list animations on an AnimationPlayer with their tracks.
|
||||||
|
# Params: {node_path}
|
||||||
|
static func list_animations(params: Dictionary) -> Dictionary:
|
||||||
|
var node_path: String = params.get("node_path", "")
|
||||||
|
if node_path == "":
|
||||||
|
return _err(-32602, "missing 'node_path'")
|
||||||
|
var root := EditorInterface.get_edited_scene_root()
|
||||||
|
if root == null:
|
||||||
|
return _err(-32001, "no scene open")
|
||||||
|
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||||
|
if node == null:
|
||||||
|
return _err(-32001, "node not found: %s" % node_path)
|
||||||
|
if not (node is AnimationPlayer):
|
||||||
|
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
|
||||||
|
var ap: AnimationPlayer = node
|
||||||
|
|
||||||
|
var out: Array = []
|
||||||
|
for lib_name in ap.get_animation_library_list():
|
||||||
|
var lib: AnimationLibrary = ap.get_animation_library(lib_name)
|
||||||
|
for anim_name in lib.get_animation_list():
|
||||||
|
var anim: Animation = lib.get_animation(anim_name)
|
||||||
|
var full_name: String
|
||||||
|
if String(lib_name) == "":
|
||||||
|
full_name = String(anim_name)
|
||||||
|
else:
|
||||||
|
full_name = "%s/%s" % [lib_name, anim_name]
|
||||||
|
var tracks: Array = []
|
||||||
|
for i in anim.get_track_count():
|
||||||
|
tracks.append({
|
||||||
|
"index": i,
|
||||||
|
"type": _track_type_name(anim.track_get_type(i)),
|
||||||
|
"path": String(anim.track_get_path(i)),
|
||||||
|
"key_count": anim.track_get_key_count(i),
|
||||||
|
})
|
||||||
|
out.append({
|
||||||
|
"name": full_name,
|
||||||
|
"length": anim.length,
|
||||||
|
"loop_mode": anim.loop_mode,
|
||||||
|
"step": anim.step,
|
||||||
|
"tracks": tracks,
|
||||||
|
})
|
||||||
|
return _ok({"node_path": node_path, "animations": out})
|
||||||
|
|
||||||
|
|
||||||
|
# animation.add_animation — create an empty animation in the player's library.
|
||||||
|
# Params: {node_path, name, length?: 1.0, library?: ""}
|
||||||
|
static func add_animation(params: Dictionary) -> Dictionary:
|
||||||
|
var node_path: String = params.get("node_path", "")
|
||||||
|
var anim_name: String = params.get("name", "")
|
||||||
|
var length: float = float(params.get("length", 1.0))
|
||||||
|
var library_name: String = params.get("library", "")
|
||||||
|
if node_path == "" or anim_name == "":
|
||||||
|
return _err(-32602, "missing 'node_path' or 'name'")
|
||||||
|
|
||||||
|
var root := EditorInterface.get_edited_scene_root()
|
||||||
|
if root == null:
|
||||||
|
return _err(-32001, "no scene open")
|
||||||
|
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||||
|
if node == null:
|
||||||
|
return _err(-32001, "node not found: %s" % node_path)
|
||||||
|
if not (node is AnimationPlayer):
|
||||||
|
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
|
||||||
|
var ap: AnimationPlayer = node
|
||||||
|
|
||||||
|
var lib: AnimationLibrary
|
||||||
|
if ap.has_animation_library(library_name):
|
||||||
|
lib = ap.get_animation_library(library_name)
|
||||||
|
else:
|
||||||
|
lib = AnimationLibrary.new()
|
||||||
|
var add_err: int = ap.add_animation_library(library_name, lib)
|
||||||
|
if add_err != OK:
|
||||||
|
return _err(-32001, "add_animation_library failed: %d" % add_err)
|
||||||
|
|
||||||
|
if lib.has_animation(anim_name):
|
||||||
|
return _err(-32602, "animation already exists: %s" % anim_name)
|
||||||
|
|
||||||
|
var anim := Animation.new()
|
||||||
|
anim.length = length
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
lib.add_animation(anim_name, anim)
|
||||||
|
EditorInterface.mark_scene_as_unsaved()
|
||||||
|
return _ok({"name": anim_name, "library": library_name, "length": length})
|
||||||
|
|
||||||
|
|
||||||
|
# animation.remove_animation — delete an animation from the player.
|
||||||
|
# Params: {node_path, name, library?: ""}
|
||||||
|
static func remove_animation(params: Dictionary) -> Dictionary:
|
||||||
|
var node_path: String = params.get("node_path", "")
|
||||||
|
var anim_name: String = params.get("name", "")
|
||||||
|
var library_name: String = params.get("library", "")
|
||||||
|
if node_path == "" or anim_name == "":
|
||||||
|
return _err(-32602, "missing 'node_path' or 'name'")
|
||||||
|
|
||||||
|
var root := EditorInterface.get_edited_scene_root()
|
||||||
|
if root == null:
|
||||||
|
return _err(-32001, "no scene open")
|
||||||
|
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||||
|
if node == null:
|
||||||
|
return _err(-32001, "node not found: %s" % node_path)
|
||||||
|
if not (node is AnimationPlayer):
|
||||||
|
return _err(-32602, "node is not an AnimationPlayer")
|
||||||
|
var ap: AnimationPlayer = node
|
||||||
|
|
||||||
|
if not ap.has_animation_library(library_name):
|
||||||
|
return _err(-32001, "library not found: '%s'" % library_name)
|
||||||
|
var lib: AnimationLibrary = ap.get_animation_library(library_name)
|
||||||
|
if not lib.has_animation(anim_name):
|
||||||
|
return _err(-32001, "animation not found: %s" % anim_name)
|
||||||
|
lib.remove_animation(anim_name)
|
||||||
|
EditorInterface.mark_scene_as_unsaved()
|
||||||
|
return _ok({"removed": anim_name, "library": library_name})
|
||||||
|
|
||||||
|
|
||||||
|
# animation.add_value_track — add a value track that animates a property over time.
|
||||||
|
# target_node is resolved relative to the scene root (simpler than the
|
||||||
|
# AnimationPlayer.root_node-relative resolution, which is fiddly).
|
||||||
|
# Params: {node_path, animation, target_node, property, keyframes: [{time, value, easing?}]}
|
||||||
|
static func add_value_track(params: Dictionary) -> Dictionary:
|
||||||
|
var node_path: String = params.get("node_path", "")
|
||||||
|
var anim_name: String = params.get("animation", "")
|
||||||
|
var target_node: String = params.get("target_node", "")
|
||||||
|
var property_name: String = params.get("property", "")
|
||||||
|
var keyframes: Array = params.get("keyframes", [])
|
||||||
|
|
||||||
|
if node_path == "" or anim_name == "" or target_node == "" or property_name == "":
|
||||||
|
return _err(-32602, "missing 'node_path', 'animation', 'target_node', or 'property'")
|
||||||
|
if keyframes.is_empty():
|
||||||
|
return _err(-32602, "'keyframes' must contain at least one entry")
|
||||||
|
|
||||||
|
var root := EditorInterface.get_edited_scene_root()
|
||||||
|
if root == null:
|
||||||
|
return _err(-32001, "no scene open")
|
||||||
|
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||||
|
if node == null:
|
||||||
|
return _err(-32001, "node not found: %s" % node_path)
|
||||||
|
if not (node is AnimationPlayer):
|
||||||
|
return _err(-32602, "node is not an AnimationPlayer")
|
||||||
|
var ap: AnimationPlayer = node
|
||||||
|
|
||||||
|
if not ap.has_animation(anim_name):
|
||||||
|
return _err(-32001, "animation not found on this player: %s" % anim_name)
|
||||||
|
var anim: Animation = ap.get_animation(anim_name)
|
||||||
|
|
||||||
|
# Validate that the target resolves from the scene root (a simpler contract
|
||||||
|
# than AP.root_node-relative — callers think in scene-tree terms already).
|
||||||
|
if root.get_node_or_null(target_node) == null:
|
||||||
|
return _err(-32001, "target_node does not resolve from scene root: %s" % target_node)
|
||||||
|
|
||||||
|
var track_idx: int = anim.add_track(Animation.TYPE_VALUE)
|
||||||
|
anim.track_set_path(track_idx, NodePath("%s:%s" % [target_node, property_name]))
|
||||||
|
|
||||||
|
var max_time: float = 0.0
|
||||||
|
for kf in keyframes:
|
||||||
|
if typeof(kf) != TYPE_DICTIONARY or not kf.has("time") or not kf.has("value"):
|
||||||
|
return _err(-32602, "each keyframe needs {time, value}")
|
||||||
|
var t: float = float(kf.time)
|
||||||
|
var transition: float = float(kf.get("easing", 1.0))
|
||||||
|
anim.track_insert_key(track_idx, t, kf.value, transition)
|
||||||
|
if t > max_time:
|
||||||
|
max_time = t
|
||||||
|
|
||||||
|
if max_time > anim.length:
|
||||||
|
anim.length = max_time
|
||||||
|
|
||||||
|
EditorInterface.mark_scene_as_unsaved()
|
||||||
|
return _ok({
|
||||||
|
"animation": anim_name,
|
||||||
|
"track_index": track_idx,
|
||||||
|
"target": "%s:%s" % [target_node, property_name],
|
||||||
|
"keyframes_added": keyframes.size(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
static func _track_type_name(t: int) -> String:
|
||||||
|
match t:
|
||||||
|
Animation.TYPE_VALUE:
|
||||||
|
return "value"
|
||||||
|
Animation.TYPE_POSITION_3D:
|
||||||
|
return "position_3d"
|
||||||
|
Animation.TYPE_ROTATION_3D:
|
||||||
|
return "rotation_3d"
|
||||||
|
Animation.TYPE_SCALE_3D:
|
||||||
|
return "scale_3d"
|
||||||
|
Animation.TYPE_BLEND_SHAPE:
|
||||||
|
return "blend_shape"
|
||||||
|
Animation.TYPE_METHOD:
|
||||||
|
return "method"
|
||||||
|
Animation.TYPE_BEZIER:
|
||||||
|
return "bezier"
|
||||||
|
Animation.TYPE_AUDIO:
|
||||||
|
return "audio"
|
||||||
|
Animation.TYPE_ANIMATION:
|
||||||
|
return "animation"
|
||||||
|
_:
|
||||||
|
return "unknown(%d)" % t
|
||||||
|
|
||||||
|
|
||||||
|
static func _ok(data) -> Dictionary:
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
static func _err(code: int, msg: String) -> Dictionary:
|
||||||
|
return {"error": {"code": code, "message": msg}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://kurfdxg67ggk
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# client.* — manage MCP client config files so users don't have to hand-edit them.
|
||||||
|
# Each client has its own config schema; we handle three shapes:
|
||||||
|
# "mcp_servers" → {"mcpServers": {"<name>": {command, args}}}
|
||||||
|
# Used by Claude Code, Claude Desktop, Cursor, Windsurf.
|
||||||
|
# "vscode_servers" → {"servers": {"<name>": {command, args}}}
|
||||||
|
# VS Code's native MCP config.
|
||||||
|
# "continue_experimental" → {"experimental": {"modelContextProtocolServers":
|
||||||
|
# [{"transport": {"type": "stdio", command, args}}]}}
|
||||||
|
# Continue.dev's MCP config shape (array, not dict).
|
||||||
|
#
|
||||||
|
# Clients requiring JSONC (with comments) or non-file storage — Zed, VS Code
|
||||||
|
# user-scope settings.json, Cline — aren't supported yet. Use manual config
|
||||||
|
# for those; see the README per-client section.
|
||||||
|
|
||||||
|
const SERVER_NAME := "godot-agent-tools"
|
||||||
|
const SERVER_COMMAND := "npx"
|
||||||
|
const SERVER_ARGS := ["-y", "godot-agent-tools-mcp"]
|
||||||
|
|
||||||
|
|
||||||
|
static func list_clients(_params: Dictionary) -> Dictionary:
|
||||||
|
var out: Array = []
|
||||||
|
for client_id in _client_ids():
|
||||||
|
var info := _client_info(client_id)
|
||||||
|
if info.has("_error"):
|
||||||
|
out.append({"client": client_id, "error": info._error})
|
||||||
|
continue
|
||||||
|
var res := _read_config(info.path)
|
||||||
|
var cfg: Dictionary = res.cfg
|
||||||
|
var installed := _has_server_entry(cfg, info.format)
|
||||||
|
out.append({
|
||||||
|
"client": client_id,
|
||||||
|
"config_path": info.path,
|
||||||
|
"exists": _path_exists(info.path),
|
||||||
|
"configured": installed,
|
||||||
|
"unparseable": res.unparseable,
|
||||||
|
})
|
||||||
|
return _ok({"clients": out})
|
||||||
|
|
||||||
|
|
||||||
|
static func configure(params: Dictionary) -> Dictionary:
|
||||||
|
var client_id: String = params.get("client", "")
|
||||||
|
var overwrite: bool = params.get("overwrite", false)
|
||||||
|
if client_id == "":
|
||||||
|
return _err(-32602, "missing 'client' (supported: %s)" % ", ".join(_client_ids()))
|
||||||
|
var info := _client_info(client_id)
|
||||||
|
if info.has("_error"):
|
||||||
|
return _err(-32602, info._error)
|
||||||
|
|
||||||
|
var res := _read_config(info.path)
|
||||||
|
if res.unparseable:
|
||||||
|
return _err(-32001, "existing config is not valid JSON — refusing to overwrite (fix or remove it first): %s" % info.path)
|
||||||
|
var cfg: Dictionary = res.cfg
|
||||||
|
if _has_server_entry(cfg, info.format) and not overwrite:
|
||||||
|
return _ok({"client": client_id, "path": info.path, "status": "already_configured"})
|
||||||
|
|
||||||
|
_set_server_entry(cfg, info.format)
|
||||||
|
var write_err := _write_config(info.path, cfg)
|
||||||
|
if write_err != "":
|
||||||
|
return _err(-32001, write_err)
|
||||||
|
return _ok({"client": client_id, "path": info.path, "status": "configured"})
|
||||||
|
|
||||||
|
|
||||||
|
static func remove(params: Dictionary) -> Dictionary:
|
||||||
|
var client_id: String = params.get("client", "")
|
||||||
|
if client_id == "":
|
||||||
|
return _err(-32602, "missing 'client'")
|
||||||
|
var info := _client_info(client_id)
|
||||||
|
if info.has("_error"):
|
||||||
|
return _err(-32602, info._error)
|
||||||
|
|
||||||
|
var res := _read_config(info.path)
|
||||||
|
if res.unparseable:
|
||||||
|
return _err(-32001, "existing config is not valid JSON — refusing to modify (fix or remove it first): %s" % info.path)
|
||||||
|
var cfg: Dictionary = res.cfg
|
||||||
|
if not _has_server_entry(cfg, info.format):
|
||||||
|
return _ok({"client": client_id, "path": info.path, "status": "not_present"})
|
||||||
|
|
||||||
|
_clear_server_entry(cfg, info.format)
|
||||||
|
var write_err := _write_config(info.path, cfg)
|
||||||
|
if write_err != "":
|
||||||
|
return _err(-32001, write_err)
|
||||||
|
return _ok({"client": client_id, "path": info.path, "status": "removed"})
|
||||||
|
|
||||||
|
|
||||||
|
static func _client_ids() -> Array:
|
||||||
|
return [
|
||||||
|
"claude_code_project",
|
||||||
|
"claude_code_user",
|
||||||
|
"claude_desktop",
|
||||||
|
"cursor_project",
|
||||||
|
"cursor_user",
|
||||||
|
"windsurf_user",
|
||||||
|
"continue_user",
|
||||||
|
"vscode_project",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
static func _client_info(client_id: String) -> Dictionary:
|
||||||
|
var home := OS.get_environment("USERPROFILE")
|
||||||
|
if home == "":
|
||||||
|
home = OS.get_environment("HOME")
|
||||||
|
if home == "":
|
||||||
|
return {"_error": "cannot resolve home directory (neither USERPROFILE nor HOME set)"}
|
||||||
|
|
||||||
|
match client_id:
|
||||||
|
"claude_code_project":
|
||||||
|
return {"path": ProjectSettings.globalize_path("res://.mcp.json"), "format": "mcp_servers"}
|
||||||
|
"claude_code_user":
|
||||||
|
return {"path": home.path_join(".claude.json"), "format": "mcp_servers"}
|
||||||
|
"cursor_project":
|
||||||
|
return {"path": ProjectSettings.globalize_path("res://.cursor/mcp.json"), "format": "mcp_servers"}
|
||||||
|
"cursor_user":
|
||||||
|
return {"path": home.path_join(".cursor").path_join("mcp.json"), "format": "mcp_servers"}
|
||||||
|
"windsurf_user":
|
||||||
|
return {"path": home.path_join(".codeium").path_join("windsurf").path_join("mcp_config.json"), "format": "mcp_servers"}
|
||||||
|
"continue_user":
|
||||||
|
return {"path": home.path_join(".continue").path_join("config.json"), "format": "continue_experimental"}
|
||||||
|
"vscode_project":
|
||||||
|
return {"path": ProjectSettings.globalize_path("res://.vscode/mcp.json"), "format": "vscode_servers"}
|
||||||
|
"claude_desktop":
|
||||||
|
var os_name := OS.get_name()
|
||||||
|
var path: String
|
||||||
|
if os_name == "macOS":
|
||||||
|
path = home.path_join("Library").path_join("Application Support").path_join("Claude").path_join("claude_desktop_config.json")
|
||||||
|
elif os_name == "Windows":
|
||||||
|
var appdata := OS.get_environment("APPDATA")
|
||||||
|
if appdata == "":
|
||||||
|
return {"_error": "APPDATA env var not set on Windows"}
|
||||||
|
path = appdata.path_join("Claude").path_join("claude_desktop_config.json")
|
||||||
|
else:
|
||||||
|
path = home.path_join(".config").path_join("Claude").path_join("claude_desktop_config.json")
|
||||||
|
return {"path": path, "format": "mcp_servers"}
|
||||||
|
_:
|
||||||
|
return {"_error": "unknown client: %s (supported: %s)" % [client_id, ", ".join(_client_ids())]}
|
||||||
|
|
||||||
|
|
||||||
|
static func _path_exists(path: String) -> bool:
|
||||||
|
return FileAccess.file_exists(path)
|
||||||
|
|
||||||
|
|
||||||
|
# Returns {"cfg": Dictionary, "unparseable": bool}. unparseable=true means the
|
||||||
|
# file exists with non-empty content that is NOT a valid JSON object — callers
|
||||||
|
# that go on to WRITE must abort, since merging into {} would wipe the user's
|
||||||
|
# data (e.g. a large ~/.claude.json with a transient syntax error, or a JSONC
|
||||||
|
# file with comments that JSON.parse_string can't handle).
|
||||||
|
static func _read_config(path: String) -> Dictionary:
|
||||||
|
var content := ""
|
||||||
|
if _path_exists(path):
|
||||||
|
var f := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if f:
|
||||||
|
content = f.get_as_text()
|
||||||
|
f.close()
|
||||||
|
if content.strip_edges() == "":
|
||||||
|
return {"cfg": {}, "unparseable": false}
|
||||||
|
var parsed = JSON.parse_string(content)
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
return {"cfg": {}, "unparseable": true}
|
||||||
|
return {"cfg": parsed, "unparseable": false}
|
||||||
|
|
||||||
|
|
||||||
|
static func _write_config(path: String, cfg: Dictionary) -> String:
|
||||||
|
var dir_path := path.get_base_dir()
|
||||||
|
if not DirAccess.dir_exists_absolute(dir_path):
|
||||||
|
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||||
|
if derr != OK:
|
||||||
|
return "mkdir failed for %s (%d)" % [dir_path, derr]
|
||||||
|
# Back up any existing non-empty file before overwriting, so a mistaken merge
|
||||||
|
# or an interrupted write is recoverable.
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
var prev := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if prev:
|
||||||
|
var prev_content := prev.get_as_text()
|
||||||
|
prev.close()
|
||||||
|
if prev_content.strip_edges() != "":
|
||||||
|
var bak := FileAccess.open(path + ".bak", FileAccess.WRITE)
|
||||||
|
if bak:
|
||||||
|
bak.store_string(prev_content)
|
||||||
|
bak.close()
|
||||||
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if f == null:
|
||||||
|
return "could not open for write: %s (error %d)" % [path, FileAccess.get_open_error()]
|
||||||
|
f.store_string(JSON.stringify(cfg, " "))
|
||||||
|
f.close()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Format-specific readers/writers -----------------------------------------
|
||||||
|
|
||||||
|
static func _has_server_entry(cfg: Dictionary, format: String) -> bool:
|
||||||
|
match format:
|
||||||
|
"mcp_servers":
|
||||||
|
var servers = cfg.get("mcpServers")
|
||||||
|
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
||||||
|
"vscode_servers":
|
||||||
|
var servers = cfg.get("servers")
|
||||||
|
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
||||||
|
"continue_experimental":
|
||||||
|
var exp = cfg.get("experimental")
|
||||||
|
if typeof(exp) != TYPE_DICTIONARY:
|
||||||
|
return false
|
||||||
|
var arr = exp.get("modelContextProtocolServers")
|
||||||
|
if typeof(arr) != TYPE_ARRAY:
|
||||||
|
return false
|
||||||
|
for entry in arr:
|
||||||
|
if typeof(entry) == TYPE_DICTIONARY and _entry_matches_continue(entry):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
_:
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
static func _set_server_entry(cfg: Dictionary, format: String) -> void:
|
||||||
|
match format:
|
||||||
|
"mcp_servers":
|
||||||
|
if typeof(cfg.get("mcpServers")) != TYPE_DICTIONARY:
|
||||||
|
cfg["mcpServers"] = {}
|
||||||
|
(cfg["mcpServers"] as Dictionary)[SERVER_NAME] = {
|
||||||
|
"command": SERVER_COMMAND,
|
||||||
|
"args": SERVER_ARGS.duplicate(),
|
||||||
|
}
|
||||||
|
"vscode_servers":
|
||||||
|
if typeof(cfg.get("servers")) != TYPE_DICTIONARY:
|
||||||
|
cfg["servers"] = {}
|
||||||
|
(cfg["servers"] as Dictionary)[SERVER_NAME] = {
|
||||||
|
"command": SERVER_COMMAND,
|
||||||
|
"args": SERVER_ARGS.duplicate(),
|
||||||
|
}
|
||||||
|
"continue_experimental":
|
||||||
|
if typeof(cfg.get("experimental")) != TYPE_DICTIONARY:
|
||||||
|
cfg["experimental"] = {}
|
||||||
|
var exp: Dictionary = cfg["experimental"]
|
||||||
|
if typeof(exp.get("modelContextProtocolServers")) != TYPE_ARRAY:
|
||||||
|
exp["modelContextProtocolServers"] = []
|
||||||
|
var arr: Array = exp["modelContextProtocolServers"]
|
||||||
|
# Remove any existing match so we don't duplicate.
|
||||||
|
var kept: Array = []
|
||||||
|
for entry in arr:
|
||||||
|
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
||||||
|
kept.append(entry)
|
||||||
|
kept.append({
|
||||||
|
"transport": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": SERVER_COMMAND,
|
||||||
|
"args": SERVER_ARGS.duplicate(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
exp["modelContextProtocolServers"] = kept
|
||||||
|
|
||||||
|
|
||||||
|
static func _clear_server_entry(cfg: Dictionary, format: String) -> void:
|
||||||
|
match format:
|
||||||
|
"mcp_servers":
|
||||||
|
var servers = cfg.get("mcpServers")
|
||||||
|
if typeof(servers) == TYPE_DICTIONARY:
|
||||||
|
(servers as Dictionary).erase(SERVER_NAME)
|
||||||
|
"vscode_servers":
|
||||||
|
var servers = cfg.get("servers")
|
||||||
|
if typeof(servers) == TYPE_DICTIONARY:
|
||||||
|
(servers as Dictionary).erase(SERVER_NAME)
|
||||||
|
"continue_experimental":
|
||||||
|
var exp = cfg.get("experimental")
|
||||||
|
if typeof(exp) != TYPE_DICTIONARY:
|
||||||
|
return
|
||||||
|
var arr = exp.get("modelContextProtocolServers")
|
||||||
|
if typeof(arr) != TYPE_ARRAY:
|
||||||
|
return
|
||||||
|
var kept: Array = []
|
||||||
|
for entry in arr:
|
||||||
|
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
||||||
|
kept.append(entry)
|
||||||
|
exp["modelContextProtocolServers"] = kept
|
||||||
|
|
||||||
|
|
||||||
|
# Continue's config uses a flat array with no `name` field — identify our entry
|
||||||
|
# by the transport command+args combo.
|
||||||
|
static func _entry_matches_continue(entry: Dictionary) -> bool:
|
||||||
|
var transport = entry.get("transport")
|
||||||
|
if typeof(transport) != TYPE_DICTIONARY:
|
||||||
|
return false
|
||||||
|
if transport.get("command") != SERVER_COMMAND:
|
||||||
|
return false
|
||||||
|
var a = transport.get("args")
|
||||||
|
if typeof(a) != TYPE_ARRAY or (a as Array).size() < 2:
|
||||||
|
return false
|
||||||
|
return a[1] == SERVER_ARGS[1]
|
||||||
|
|
||||||
|
|
||||||
|
static func _ok(data) -> Dictionary:
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
static func _err(code: int, msg: String) -> Dictionary:
|
||||||
|
return {"error": {"code": code, "message": msg}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b3u1el0113l81
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# docs.class_ref — return the public API of a Godot class so the agent can plan
|
||||||
|
# work without guessing method/property/signal names.
|
||||||
|
# Params: {class_name, include_inherited?: false}
|
||||||
|
# Returns: {class, parent, methods: [{name, args, return}], properties, signals, constants}
|
||||||
|
static func class_ref(params: Dictionary) -> Dictionary:
|
||||||
|
var cname: String = params.get("class_name", "")
|
||||||
|
var include_inherited: bool = params.get("include_inherited", false)
|
||||||
|
if cname == "":
|
||||||
|
return _err(-32602, "missing 'class_name'")
|
||||||
|
if not ClassDB.class_exists(cname):
|
||||||
|
return _err(-32602, "unknown class: %s" % cname)
|
||||||
|
|
||||||
|
var no_inheritance := not include_inherited
|
||||||
|
|
||||||
|
var methods: Array = []
|
||||||
|
for m in ClassDB.class_get_method_list(cname, no_inheritance):
|
||||||
|
# Skip internal / editor-only methods — they're noise for an agent.
|
||||||
|
if (m.flags as int) & METHOD_FLAG_OBJECT_CORE:
|
||||||
|
continue
|
||||||
|
var args: Array = []
|
||||||
|
for a in m.args:
|
||||||
|
args.append({"name": a.name, "type": type_string(a.type)})
|
||||||
|
methods.append({
|
||||||
|
"name": m.name,
|
||||||
|
"args": args,
|
||||||
|
"return": type_string(m.return.type) if m.has("return") else "void",
|
||||||
|
"virtual": bool((m.flags as int) & METHOD_FLAG_VIRTUAL),
|
||||||
|
"static": bool((m.flags as int) & METHOD_FLAG_STATIC),
|
||||||
|
})
|
||||||
|
|
||||||
|
var properties: Array = []
|
||||||
|
for p in ClassDB.class_get_property_list(cname, no_inheritance):
|
||||||
|
# Only user-facing properties (storage + editor). Internal group markers have usage=0.
|
||||||
|
if not ((p.usage as int) & PROPERTY_USAGE_EDITOR):
|
||||||
|
continue
|
||||||
|
properties.append({
|
||||||
|
"name": p.name,
|
||||||
|
"type": type_string(p.type),
|
||||||
|
"hint": p.get("hint_string", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
var signals: Array = []
|
||||||
|
for s in ClassDB.class_get_signal_list(cname, no_inheritance):
|
||||||
|
var args: Array = []
|
||||||
|
for a in s.args:
|
||||||
|
args.append({"name": a.name, "type": type_string(a.type)})
|
||||||
|
signals.append({"name": s.name, "args": args})
|
||||||
|
|
||||||
|
var constants: Array = []
|
||||||
|
for c in ClassDB.class_get_integer_constant_list(cname, no_inheritance):
|
||||||
|
constants.append({"name": c, "value": ClassDB.class_get_integer_constant(cname, c)})
|
||||||
|
|
||||||
|
return _ok({
|
||||||
|
"class": cname,
|
||||||
|
"parent": ClassDB.get_parent_class(cname),
|
||||||
|
"inherited_included": include_inherited,
|
||||||
|
"methods": methods,
|
||||||
|
"properties": properties,
|
||||||
|
"signals": signals,
|
||||||
|
"constants": constants,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
static func _ok(data) -> Dictionary:
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
static func _err(code: int, msg: String) -> Dictionary:
|
||||||
|
return {"error": {"code": code, "message": msg}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dwarklg56vsim
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# editor.reload_filesystem — trigger an editor filesystem rescan.
|
||||||
|
# Run this after external file changes (created/moved/deleted by tools outside the editor)
|
||||||
|
# so load() and the FileSystem dock reflect reality.
|
||||||
|
static func reload_filesystem(_params: Dictionary) -> Dictionary:
|
||||||
|
EditorInterface.get_resource_filesystem().scan()
|
||||||
|
return {"data": {"scanned": true}}
|
||||||
|
|
||||||
|
|
||||||
|
# editor.save_all_scenes — save every open edited scene.
|
||||||
|
static func save_all_scenes(_params: Dictionary) -> Dictionary:
|
||||||
|
EditorInterface.save_all_scenes()
|
||||||
|
return {"data": {"saved": true}}
|
||||||
|
|
||||||
|
|
||||||
|
# editor.state — consolidated editor + project status in one call.
|
||||||
|
# Replaces piecewise scene.current + project.get_setting + etc.
|
||||||
|
static func state(_params: Dictionary) -> Dictionary:
|
||||||
|
var version := Engine.get_version_info()
|
||||||
|
var version_str: String = "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status]
|
||||||
|
|
||||||
|
var current_scene_path := ""
|
||||||
|
var current_scene_class := ""
|
||||||
|
var current_scene_name := ""
|
||||||
|
var root := EditorInterface.get_edited_scene_root()
|
||||||
|
if root != null:
|
||||||
|
current_scene_path = root.scene_file_path
|
||||||
|
current_scene_class = root.get_class()
|
||||||
|
current_scene_name = String(root.name)
|
||||||
|
|
||||||
|
var open_scenes: Array = []
|
||||||
|
for s in EditorInterface.get_open_scenes():
|
||||||
|
open_scenes.append(s)
|
||||||
|
|
||||||
|
var project_name := ""
|
||||||
|
if ProjectSettings.has_setting("application/config/name"):
|
||||||
|
project_name = str(ProjectSettings.get_setting("application/config/name"))
|
||||||
|
|
||||||
|
return {"data": {
|
||||||
|
"godot_version": version_str,
|
||||||
|
"project_name": project_name,
|
||||||
|
"current_scene": {
|
||||||
|
"path": current_scene_path,
|
||||||
|
"root_name": current_scene_name,
|
||||||
|
"root_class": current_scene_class,
|
||||||
|
"open": root != null,
|
||||||
|
},
|
||||||
|
"open_scenes": open_scenes,
|
||||||
|
"playing_scene": EditorInterface.is_playing_scene(),
|
||||||
|
"playing_scene_path": EditorInterface.get_playing_scene() if EditorInterface.is_playing_scene() else "",
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
# editor.selection_get — return the currently-selected nodes in the editor tree dock.
|
||||||
|
# Useful for "operate on whatever I clicked" workflows.
|
||||||
|
static func selection_get(_params: Dictionary) -> Dictionary:
|
||||||
|
var selection := EditorInterface.get_selection()
|
||||||
|
var root := EditorInterface.get_edited_scene_root()
|
||||||
|
var out: Array = []
|
||||||
|
for node in selection.get_selected_nodes():
|
||||||
|
var path_str := ""
|
||||||
|
if root != null and (node == root or root.is_ancestor_of(node)):
|
||||||
|
path_str = String(root.get_path_to(node))
|
||||||
|
out.append({
|
||||||
|
"name": String(node.name),
|
||||||
|
"class": node.get_class(),
|
||||||
|
"node_path": path_str,
|
||||||
|
})
|
||||||
|
return {"data": {"selected": out, "count": out.size()}}
|
||||||
|
|
||||||
|
|
||||||
|
# editor.selection_set — select a specific set of nodes in the editor tree dock.
|
||||||
|
# Params: {node_paths: ["Player", "Enemy"]}
|
||||||
|
static func selection_set(params: Dictionary) -> Dictionary:
|
||||||
|
var paths: Array = params.get("node_paths", [])
|
||||||
|
var root := EditorInterface.get_edited_scene_root()
|
||||||
|
if root == null:
|
||||||
|
return {"error": {"code": -32001, "message": "no scene open"}}
|
||||||
|
|
||||||
|
var selection := EditorInterface.get_selection()
|
||||||
|
selection.clear()
|
||||||
|
|
||||||
|
var resolved: Array = []
|
||||||
|
var missing: Array = []
|
||||||
|
for p in paths:
|
||||||
|
var node: Node = root if p == "." else root.get_node_or_null(p)
|
||||||
|
if node == null:
|
||||||
|
missing.append(p)
|
||||||
|
continue
|
||||||
|
selection.add_node(node)
|
||||||
|
resolved.append(p)
|
||||||
|
|
||||||
|
if not missing.is_empty():
|
||||||
|
return {"error": {"code": -32001, "message": "nodes not found: %s" % str(missing)}}
|
||||||
|
return {"data": {"selected": resolved}}
|
||||||
|
|
||||||
|
|
||||||
|
# editor.game_screenshot — capture the viewport of the currently-running game.
|
||||||
|
# Works when the user has pressed F5 (or equivalent) and a game process is running
|
||||||
|
# with our runtime bridge autoload registered. Writes a request file, polls for
|
||||||
|
# the bridge to produce the PNG, returns the path.
|
||||||
|
#
|
||||||
|
# Params: {output?: "res://.godot/agent_tools/game.png", timeout_ms?: 5000}
|
||||||
|
static func game_screenshot(params: Dictionary) -> Dictionary:
|
||||||
|
var output_path: String = params.get("output", "res://.godot/agent_tools/game_screenshot.png")
|
||||||
|
var timeout_ms: int = int(params.get("timeout_ms", 5000))
|
||||||
|
|
||||||
|
if not EditorInterface.is_playing_scene():
|
||||||
|
return {"error": {"code": -32001,
|
||||||
|
"message": "no game running — press F5 (or equivalent) to start the game, then call again. Alternative for arbitrary scenes: run.scene_headless with 'screenshot'."}}
|
||||||
|
|
||||||
|
var bridge_dir := "res://.godot/agent_tools/bridge"
|
||||||
|
if not DirAccess.dir_exists_absolute(bridge_dir):
|
||||||
|
var derr := DirAccess.make_dir_recursive_absolute(bridge_dir)
|
||||||
|
if derr != OK:
|
||||||
|
return {"error": {"code": -32001, "message": "mkdir failed: %d" % derr}}
|
||||||
|
|
||||||
|
# Wipe stale output so we can detect when the bridge has written fresh data.
|
||||||
|
if FileAccess.file_exists(output_path):
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.remove_absolute(output_path)
|
||||||
|
|
||||||
|
var request_path := bridge_dir + "/request.json"
|
||||||
|
var request_id := str(Time.get_ticks_msec())
|
||||||
|
var req := {
|
||||||
|
"id": request_id,
|
||||||
|
"type": "screenshot",
|
||||||
|
"output": output_path,
|
||||||
|
}
|
||||||
|
var f := FileAccess.open(request_path, FileAccess.WRITE)
|
||||||
|
if f == null:
|
||||||
|
return {"error": {"code": -32001, "message": "could not write bridge request"}}
|
||||||
|
f.store_string(JSON.stringify(req))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
# Poll for response — bridge writes response.json with matching id when done.
|
||||||
|
var response_path := bridge_dir + "/response.json"
|
||||||
|
var deadline_usec: int = Time.get_ticks_usec() + timeout_ms * 1000
|
||||||
|
while Time.get_ticks_usec() < deadline_usec:
|
||||||
|
if FileAccess.file_exists(response_path):
|
||||||
|
var rf := FileAccess.open(response_path, FileAccess.READ)
|
||||||
|
if rf:
|
||||||
|
var body := rf.get_as_text()
|
||||||
|
rf.close()
|
||||||
|
var parsed = JSON.parse_string(body)
|
||||||
|
if typeof(parsed) == TYPE_DICTIONARY and parsed.get("id", "") == request_id:
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.remove_absolute(response_path)
|
||||||
|
if parsed.has("error"):
|
||||||
|
return {"error": {"code": -32001, "message": "bridge: %s" % parsed.error}}
|
||||||
|
return {"data": {
|
||||||
|
"path": output_path,
|
||||||
|
"captured": FileAccess.file_exists(output_path),
|
||||||
|
}}
|
||||||
|
OS.delay_msec(50)
|
||||||
|
|
||||||
|
# Clean up stale request if bridge never picked it up.
|
||||||
|
if FileAccess.file_exists(request_path):
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.remove_absolute(request_path)
|
||||||
|
return {"error": {"code": -32001,
|
||||||
|
"message": "bridge timed out after %dms — is the running game using the _MCPGameBridge autoload? Re-enable the plugin to re-register it, then press F5 again." % timeout_ms}}
|
||||||
|
|
||||||
|
|
||||||
|
# logs.read — pull recent log lines captured by the game-bridge autoload.
|
||||||
|
# Params: {clear?: false, max_lines?: 200}
|
||||||
|
static func logs_read(params: Dictionary) -> Dictionary:
|
||||||
|
var clear: bool = params.get("clear", false)
|
||||||
|
var max_lines: int = int(params.get("max_lines", 200))
|
||||||
|
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
|
||||||
|
if not FileAccess.file_exists(logs_path):
|
||||||
|
return {"data": {
|
||||||
|
"entries": [],
|
||||||
|
"count": 0,
|
||||||
|
"note": "no log buffer found — the game may not be running or the autoload isn't initialized. Press F5 and try again.",
|
||||||
|
}}
|
||||||
|
var f := FileAccess.open(logs_path, FileAccess.READ)
|
||||||
|
if f == null:
|
||||||
|
return {"error": {"code": -32001, "message": "could not read logs"}}
|
||||||
|
var parsed = JSON.parse_string(f.get_as_text())
|
||||||
|
f.close()
|
||||||
|
if typeof(parsed) != TYPE_ARRAY:
|
||||||
|
parsed = []
|
||||||
|
var entries: Array = parsed
|
||||||
|
if entries.size() > max_lines:
|
||||||
|
entries = entries.slice(entries.size() - max_lines)
|
||||||
|
if clear:
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.remove_absolute(logs_path)
|
||||||
|
return {"data": {"entries": entries, "count": entries.size()}}
|
||||||
|
|
||||||
|
|
||||||
|
# logs.clear — drop the log buffer regardless of read state.
|
||||||
|
static func logs_clear(_params: Dictionary) -> Dictionary:
|
||||||
|
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
|
||||||
|
if FileAccess.file_exists(logs_path):
|
||||||
|
@warning_ignore("return_value_discarded")
|
||||||
|
DirAccess.remove_absolute(logs_path)
|
||||||
|
return {"data": {"cleared": true}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bgoufu3l40rvx
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# fs.list — enumerate project files by type, with optional glob filter.
|
||||||
|
# Params: {type?: "all"|"scene"|"script"|"resource"|"shader"|"image"|"audio",
|
||||||
|
# glob?: "res://scenes/**/*.tscn",
|
||||||
|
# include_addons?: false}
|
||||||
|
# Returns: {type, count, files: [paths]}
|
||||||
|
static func list(params: Dictionary) -> Dictionary:
|
||||||
|
var type_filter: String = params.get("type", "all")
|
||||||
|
var glob: String = params.get("glob", "")
|
||||||
|
var include_addons: bool = params.get("include_addons", false)
|
||||||
|
|
||||||
|
var exts: Array
|
||||||
|
match type_filter:
|
||||||
|
"", "all":
|
||||||
|
exts = ["gd", "cs", "tscn", "tres", "res", "gdshader", "gdshaderinc",
|
||||||
|
"png", "jpg", "jpeg", "svg", "webp",
|
||||||
|
"ogg", "wav", "mp3",
|
||||||
|
"json", "cfg"]
|
||||||
|
"scene":
|
||||||
|
exts = ["tscn"]
|
||||||
|
"script":
|
||||||
|
exts = ["gd", "cs"]
|
||||||
|
"resource":
|
||||||
|
exts = ["tres", "res"]
|
||||||
|
"shader":
|
||||||
|
exts = ["gdshader", "gdshaderinc"]
|
||||||
|
"image":
|
||||||
|
exts = ["png", "jpg", "jpeg", "svg", "webp"]
|
||||||
|
"audio":
|
||||||
|
exts = ["ogg", "wav", "mp3"]
|
||||||
|
_:
|
||||||
|
return _err(-32602, "unknown type: %s (use all|scene|script|resource|shader|image|audio)" % type_filter)
|
||||||
|
|
||||||
|
var files: Array = []
|
||||||
|
_walk("res://", exts, files, include_addons)
|
||||||
|
|
||||||
|
if glob != "":
|
||||||
|
var filtered: Array = []
|
||||||
|
for f in files:
|
||||||
|
if f.matchn(glob): # case-insensitive glob match
|
||||||
|
filtered.append(f)
|
||||||
|
files = filtered
|
||||||
|
|
||||||
|
files.sort()
|
||||||
|
return _ok({
|
||||||
|
"type": type_filter,
|
||||||
|
"count": files.size(),
|
||||||
|
"files": files,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# fs.read_text — read a text file under res://. Complement to user_fs.read.
|
||||||
|
# Params: {path}
|
||||||
|
static func read_text(params: Dictionary) -> Dictionary:
|
||||||
|
var path: String = params.get("path", "")
|
||||||
|
if path == "":
|
||||||
|
return _err(-32602, "missing 'path'")
|
||||||
|
if not path.begins_with("res://"):
|
||||||
|
return _err(-32602, "path must begin with 'res://' (use user_fs.read for user:// files)")
|
||||||
|
if not _within_sandbox(path, "res://"):
|
||||||
|
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
|
||||||
|
var f := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if f == null:
|
||||||
|
return _err(-32001, "failed to open %s (error %d)" % [path, FileAccess.get_open_error()])
|
||||||
|
var size_bytes := f.get_length()
|
||||||
|
var content := f.get_as_text()
|
||||||
|
f.close()
|
||||||
|
return _ok({"path": path, "content": content, "size_bytes": size_bytes})
|
||||||
|
|
||||||
|
|
||||||
|
# fs.write_text — write a text file under res://. Creates parent directories
|
||||||
|
# if needed. Triggers the editor's filesystem rescan so the new file shows up
|
||||||
|
# in the FileSystem dock.
|
||||||
|
# Params: {path, content, overwrite?: false}
|
||||||
|
static func write_text(params: Dictionary) -> Dictionary:
|
||||||
|
var path: String = params.get("path", "")
|
||||||
|
var content: String = str(params.get("content", ""))
|
||||||
|
var overwrite: bool = params.get("overwrite", false)
|
||||||
|
if path == "":
|
||||||
|
return _err(-32602, "missing 'path'")
|
||||||
|
if not path.begins_with("res://"):
|
||||||
|
return _err(-32602, "path must begin with 'res://'")
|
||||||
|
if not _within_sandbox(path, "res://"):
|
||||||
|
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
|
||||||
|
if FileAccess.file_exists(path) and not overwrite:
|
||||||
|
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
|
||||||
|
|
||||||
|
var dir_path := path.get_base_dir()
|
||||||
|
if not DirAccess.dir_exists_absolute(dir_path):
|
||||||
|
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||||
|
if derr != OK:
|
||||||
|
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||||
|
|
||||||
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if f == null:
|
||||||
|
return _err(-32001, "could not open for write: %s" % path)
|
||||||
|
f.store_string(content)
|
||||||
|
f.close()
|
||||||
|
EditorInterface.get_resource_filesystem().update_file(path)
|
||||||
|
return _ok({"path": path, "bytes_written": content.length()})
|
||||||
|
|
||||||
|
|
||||||
|
static func _walk(dir_path: String, exts: Array, out: Array, include_addons: bool) -> void:
|
||||||
|
var d := DirAccess.open(dir_path)
|
||||||
|
if d == null:
|
||||||
|
return
|
||||||
|
d.list_dir_begin()
|
||||||
|
while true:
|
||||||
|
var name := d.get_next()
|
||||||
|
if name == "":
|
||||||
|
break
|
||||||
|
if name.begins_with("."):
|
||||||
|
continue
|
||||||
|
var full := dir_path.path_join(name)
|
||||||
|
if d.current_is_dir():
|
||||||
|
if not include_addons and full == "res://addons/agent_tools":
|
||||||
|
continue # always skip our own addon
|
||||||
|
_walk(full, exts, out, include_addons)
|
||||||
|
else:
|
||||||
|
for e in exts:
|
||||||
|
if name.ends_with("." + e):
|
||||||
|
out.append(full)
|
||||||
|
break
|
||||||
|
d.list_dir_end()
|
||||||
|
|
||||||
|
|
||||||
|
# Path-traversal guard. `begins_with("res://")` is NOT a sandbox — Godot resolves
|
||||||
|
# `..` segments against the real filesystem, so "res://../../etc/x" escapes the
|
||||||
|
# project. Globalize to an absolute path, collapse `..`, and confirm the result
|
||||||
|
# stays inside the (globalized, collapsed) prefix root.
|
||||||
|
static func _within_sandbox(path: String, prefix: String) -> bool:
|
||||||
|
var root := ProjectSettings.globalize_path(prefix).simplify_path().trim_suffix("/")
|
||||||
|
var abs := ProjectSettings.globalize_path(path).simplify_path()
|
||||||
|
return abs == root or abs.begins_with(root + "/")
|
||||||
|
|
||||||
|
|
||||||
|
static func _ok(data) -> Dictionary:
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
static func _err(code: int, msg: String) -> Dictionary:
|
||||||
|
return {"error": {"code": code, "message": msg}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxq0mo7kx65wd
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
@tool
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# input_map.add_action — register a new input action in project.godot.
|
||||||
|
# Params: {name, deadzone?: 0.5}
|
||||||
|
static func add_action(params: Dictionary) -> Dictionary:
|
||||||
|
var action_name: String = params.get("name", "")
|
||||||
|
if action_name == "":
|
||||||
|
return _err(-32602, "missing 'name'")
|
||||||
|
var deadzone: float = float(params.get("deadzone", 0.5))
|
||||||
|
var key := "input/" + action_name
|
||||||
|
if ProjectSettings.has_setting(key):
|
||||||
|
return _err(-32602, "action already exists: %s" % action_name)
|
||||||
|
ProjectSettings.set_setting(key, {
|
||||||
|
"deadzone": deadzone,
|
||||||
|
"events": [],
|
||||||
|
})
|
||||||
|
var err := ProjectSettings.save()
|
||||||
|
if err != OK:
|
||||||
|
return _err(-32001, "save failed: %d" % err)
|
||||||
|
# Keep the runtime InputMap in sync with the on-disk project settings —
|
||||||
|
# ProjectSettings.save() only writes the file; it doesn't register actions
|
||||||
|
# with InputMap until editor restart.
|
||||||
|
if not InputMap.has_action(action_name):
|
||||||
|
InputMap.add_action(action_name, deadzone)
|
||||||
|
return _ok({"added": action_name})
|
||||||
|
|
||||||
|
|
||||||
|
# input_map.add_event — attach an input event to an existing action.
|
||||||
|
# Supported event shapes (pick one via 'type'):
|
||||||
|
# {type: "key", keycode: "A" | "Space" | "F1" ..., physical?: true}
|
||||||
|
# {type: "mouse_button", button_index: 1 (left) | 2 (right) | 3 (middle)}
|
||||||
|
# {type: "joy_button", button_index: 0..}
|
||||||
|
# Params: {action, event: <shape above>}
|
||||||
|
static func add_event(params: Dictionary) -> Dictionary:
|
||||||
|
var action_name: String = params.get("action", "")
|
||||||
|
var event_spec: Dictionary = params.get("event", {})
|
||||||
|
if action_name == "":
|
||||||
|
return _err(-32602, "missing 'action'")
|
||||||
|
if event_spec.is_empty():
|
||||||
|
return _err(-32602, "missing 'event'")
|
||||||
|
var key := "input/" + action_name
|
||||||
|
if not ProjectSettings.has_setting(key):
|
||||||
|
return _err(-32001, "action not found: %s" % action_name)
|
||||||
|
|
||||||
|
var event := _build_event(event_spec)
|
||||||
|
if event == null:
|
||||||
|
return _err(-32602, "invalid event spec — see tool description for supported shapes")
|
||||||
|
|
||||||
|
var current: Dictionary = ProjectSettings.get_setting(key)
|
||||||
|
var events_arr: Array = current.get("events", [])
|
||||||
|
events_arr.append(event)
|
||||||
|
current["events"] = events_arr
|
||||||
|
ProjectSettings.set_setting(key, current)
|
||||||
|
var err := ProjectSettings.save()
|
||||||
|
if err != OK:
|
||||||
|
return _err(-32001, "save failed: %d" % err)
|
||||||
|
# Sync InputMap so Input.is_action_pressed() etc. see the event without restart.
|
||||||
|
if InputMap.has_action(action_name):
|
||||||
|
InputMap.action_add_event(action_name, event)
|
||||||
|
return _ok({"action": action_name, "event": event_spec})
|
||||||
|
|
||||||
|
|
||||||
|
# input_map.list — return registered input actions with their events.
|
||||||
|
# Params: {include_builtins?: false}
|
||||||
|
# Defaults to user-defined only (parses the [input] section of project.godot,
|
||||||
|
# which contains only user overrides — Godot's ~90 ui_* builtins live in-memory).
|
||||||
|
# When include_builtins, falls back to InputMap which has the full union.
|
||||||
|
static func list_actions(params: Dictionary) -> Dictionary:
|
||||||
|
var include_builtins: bool = params.get("include_builtins", false)
|
||||||
|
var items: Array = []
|
||||||
|
|
||||||
|
if include_builtins:
|
||||||
|
for a in InputMap.get_actions():
|
||||||
|
var described: Array = []
|
||||||
|
for e in InputMap.action_get_events(a):
|
||||||
|
described.append(_describe_event(e))
|
||||||
|
items.append({
|
||||||
|
"name": String(a),
|
||||||
|
"deadzone": InputMap.action_get_deadzone(a),
|
||||||
|
"events": described,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Read straight from project.godot so newly-added actions show up even
|
||||||
|
# if they haven't been registered with the runtime InputMap yet.
|
||||||
|
var cf := ConfigFile.new()
|
||||||
|
var err := cf.load("res://project.godot")
|
||||||
|
if err == OK and cf.has_section("input"):
|
||||||
|
for action_name in cf.get_section_keys("input"):
|
||||||
|
var value: Dictionary = cf.get_value("input", action_name)
|
||||||
|
var described: Array = []
|
||||||
|
for e in value.get("events", []):
|
||||||
|
described.append(_describe_event(e))
|
||||||
|
items.append({
|
||||||
|
"name": action_name,
|
||||||
|
"deadzone": value.get("deadzone", 0.5),
|
||||||
|
"events": described,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _ok({"actions": items})
|
||||||
|
|
||||||
|
|
||||||
|
# input_map.remove_event — remove an event from an action by index.
|
||||||
|
# Call input_map.list first to see indices; events are listed in the order they
|
||||||
|
# were added.
|
||||||
|
# Params: {action, event_index}
|
||||||
|
static func remove_event(params: Dictionary) -> Dictionary:
|
||||||
|
var action_name: String = params.get("action", "")
|
||||||
|
if action_name == "":
|
||||||
|
return _err(-32602, "missing 'action'")
|
||||||
|
if not params.has("event_index"):
|
||||||
|
return _err(-32602, "missing 'event_index'")
|
||||||
|
var idx: int = int(params.event_index)
|
||||||
|
var key := "input/" + action_name
|
||||||
|
if not ProjectSettings.has_setting(key):
|
||||||
|
return _err(-32001, "action not found: %s" % action_name)
|
||||||
|
var current: Dictionary = ProjectSettings.get_setting(key)
|
||||||
|
var events_arr: Array = current.get("events", [])
|
||||||
|
if idx < 0 or idx >= events_arr.size():
|
||||||
|
return _err(-32602, "event_index %d out of range (0..%d)" % [idx, events_arr.size() - 1])
|
||||||
|
var removed_event = events_arr[idx]
|
||||||
|
events_arr.remove_at(idx)
|
||||||
|
current["events"] = events_arr
|
||||||
|
ProjectSettings.set_setting(key, current)
|
||||||
|
var err := ProjectSettings.save()
|
||||||
|
if err != OK:
|
||||||
|
return _err(-32001, "save failed: %d" % err)
|
||||||
|
if InputMap.has_action(action_name) and removed_event != null:
|
||||||
|
InputMap.action_erase_event(action_name, removed_event)
|
||||||
|
return _ok({"action": action_name, "removed_index": idx})
|
||||||
|
|
||||||
|
|
||||||
|
# input_map.remove_action — delete a user-registered action.
|
||||||
|
static func remove_action(params: Dictionary) -> Dictionary:
|
||||||
|
var action_name: String = params.get("name", "")
|
||||||
|
if action_name == "":
|
||||||
|
return _err(-32602, "missing 'name'")
|
||||||
|
var key := "input/" + action_name
|
||||||
|
if not ProjectSettings.has_setting(key):
|
||||||
|
return _err(-32001, "action not found: %s" % action_name)
|
||||||
|
ProjectSettings.clear(key)
|
||||||
|
var err := ProjectSettings.save()
|
||||||
|
if err != OK:
|
||||||
|
return _err(-32001, "save failed: %d" % err)
|
||||||
|
if InputMap.has_action(action_name):
|
||||||
|
InputMap.erase_action(action_name)
|
||||||
|
return _ok({"removed": action_name})
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_event(spec: Dictionary) -> InputEvent:
|
||||||
|
# device defaults to -1 (ALL_DEVICES) matching the editor's "All Devices" default.
|
||||||
|
# For local multiplayer bind device=0, device=1, etc. to distinguish controllers.
|
||||||
|
var device: int = int(spec.get("device", -1))
|
||||||
|
match spec.get("type", ""):
|
||||||
|
"key":
|
||||||
|
var e := InputEventKey.new()
|
||||||
|
e.device = device
|
||||||
|
var kc_raw = spec.get("keycode", "")
|
||||||
|
var keycode := 0
|
||||||
|
if kc_raw is int:
|
||||||
|
keycode = kc_raw
|
||||||
|
elif kc_raw is String:
|
||||||
|
keycode = OS.find_keycode_from_string(kc_raw)
|
||||||
|
if keycode == 0:
|
||||||
|
return null
|
||||||
|
if spec.get("physical", true):
|
||||||
|
e.physical_keycode = keycode
|
||||||
|
else:
|
||||||
|
e.keycode = keycode
|
||||||
|
return e
|
||||||
|
"mouse_button":
|
||||||
|
var e := InputEventMouseButton.new()
|
||||||
|
e.device = device
|
||||||
|
e.button_index = int(spec.get("button_index", 1))
|
||||||
|
return e
|
||||||
|
"joy_button":
|
||||||
|
var e := InputEventJoypadButton.new()
|
||||||
|
e.device = device
|
||||||
|
e.button_index = int(spec.get("button_index", 0))
|
||||||
|
return e
|
||||||
|
"joy_motion":
|
||||||
|
# axis accepts int 0..5 or one of:
|
||||||
|
# "left_x" / "left_y" / "right_x" / "right_y" / "trigger_left" / "trigger_right"
|
||||||
|
# axis_value: -1.0 (full negative) to 1.0 (full positive); sign picks the direction
|
||||||
|
# that triggers the action.
|
||||||
|
var e := InputEventJoypadMotion.new()
|
||||||
|
e.device = device
|
||||||
|
e.axis = _coerce_axis(spec.get("axis", 0))
|
||||||
|
e.axis_value = float(spec.get("axis_value", 1.0))
|
||||||
|
return e
|
||||||
|
_:
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func _coerce_axis(v) -> int:
|
||||||
|
if v is int:
|
||||||
|
return v
|
||||||
|
if v is String:
|
||||||
|
match v:
|
||||||
|
"left_x":
|
||||||
|
return JOY_AXIS_LEFT_X
|
||||||
|
"left_y":
|
||||||
|
return JOY_AXIS_LEFT_Y
|
||||||
|
"right_x":
|
||||||
|
return JOY_AXIS_RIGHT_X
|
||||||
|
"right_y":
|
||||||
|
return JOY_AXIS_RIGHT_Y
|
||||||
|
"trigger_left":
|
||||||
|
return JOY_AXIS_TRIGGER_LEFT
|
||||||
|
"trigger_right":
|
||||||
|
return JOY_AXIS_TRIGGER_RIGHT
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
static func _describe_event(e) -> Dictionary:
|
||||||
|
if e is InputEventKey:
|
||||||
|
var kc: int = e.physical_keycode if e.physical_keycode != 0 else e.keycode
|
||||||
|
return {
|
||||||
|
"type": "key",
|
||||||
|
"keycode": OS.get_keycode_string(kc),
|
||||||
|
"physical": e.physical_keycode != 0,
|
||||||
|
"device": e.device,
|
||||||
|
}
|
||||||
|
if e is InputEventMouseButton:
|
||||||
|
return {"type": "mouse_button", "button_index": e.button_index, "device": e.device}
|
||||||
|
if e is InputEventJoypadButton:
|
||||||
|
return {"type": "joy_button", "button_index": e.button_index, "device": e.device}
|
||||||
|
if e is InputEventJoypadMotion:
|
||||||
|
return {
|
||||||
|
"type": "joy_motion",
|
||||||
|
"axis": e.axis,
|
||||||
|
"axis_value": e.axis_value,
|
||||||
|
"device": e.device,
|
||||||
|
}
|
||||||
|
return {"type": "unknown", "class": (e as Object).get_class() if e else "null"}
|
||||||
|
|
||||||
|
|
||||||
|
static func _ok(data) -> Dictionary:
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
static func _err(code: int, msg: String) -> Dictionary:
|
||||||
|
return {"error": {"code": code, "message": msg}}
|
||||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user