diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..ab9ecd643fe5e0c28b07d4c43477d1787cb0e5e9 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# Unity Agent + +A Python package that generates complete, playable **Unity 2022.3 LTS** +projects on disk. The agent exposes a registry of code-generation tools +that emit C# scripts (MonoBehaviours), `.unity` scene files, project +configuration (manifest, ProjectSettings, .meta) and a full open-world +city example game. The output is a real Unity project you can open in +Unity Hub and press Play. + +## What it does + +* Writes complete, compilable **C#** MonoBehaviours (player, vehicle, + camera, AI, weapons, inventory, quests, save system, ...). +* Scaffolds a Unity project structure (`Assets/`, `Packages/manifest.json`, + `ProjectSettings/`, `.meta` files) exactly the way Unity expects. +* Provides a **system prompt** that teaches an LLM to act like a senior + Unity engineer and to call the right tool in the right order. +* Ships a **knowledge base** of focused markdown references + (physics, navigation, input, UI, audio, rendering, open-world design). +* Runs **static QA** against generated projects to catch unbalanced + braces, missing meta files, missing manifest, etc. +* Comes with a fully working **open-world city example** generated + end-to-end from the tool layer. + +## Installation + +```bash +git clone unity-agent +cd unity-agent +pip install -e . # optional; the package also works with PYTHONPATH=. +``` + +The package has **no third-party Python dependencies** -- it only uses the +standard library. Python 3.9+ is required. + +## Quick start + +### Generate the bundled example + +```bash +# Generate the full open-world city example into examples/open_world_city/ +python -m unity_agent generate --game open_world_city --preset open_world_city --output-dir examples + +# Run static QA on it +python -m unity_agent qa examples/open_world_city +``` + +Open `examples/open_world_city/` in Unity Hub (2022.3 LTS or newer) and +press Play. + +### Use the package from Python + +```python +from unity_agent.config import Settings +from unity_agent.transport.unity_transport import UnityTransport +from unity_agent.tools.unity_tools import ( + SetupUnityProjectTool, + CreatePlayerControllerTool, + CreateProceduralCityTool, + WriteSceneFileTool, +) + +settings = Settings(output_dir="my_games") +transport = UnityTransport(settings) + +SetupUnityProjectTool(settings, transport).run(project_name="MyGame") +CreatePlayerControllerTool(settings, transport).run(project_name="MyGame") +CreateProceduralCityTool(settings, transport).run(project_name="MyGame") +WriteSceneFileTool(settings, transport).run(project_name="MyGame", scene_name="MainScene") + +print(transport.summary()) +``` + +### CLI + +```bash +python -m unity_agent --help + +# Commands +python -m unity_agent list-tools +python -m unity_agent setup --project-name MyGame --output-dir my_games +python -m unity_agent generate --game MyGame --preset open_world_city +python -m unity_agent generate --game MyFPS --preset fps_arena +python -m unity_agent generate --game Minimal --preset minimal +python -m unity_agent qa path/to/MyGame +``` + +## Project layout + +``` +unity-agent/ + unity_agent/ + __init__.py # Package entry point + __main__.py # CLI: `python -m unity_agent ...` + config.py # Settings (Unity path, LLM keys, output dirs) + tests.py # Self-test suite (run with `python -m unity_agent.tests`) + transport/ + __init__.py + unity_transport.py # Writes .cs / .unity / .meta / config files + tools/ + __init__.py + base.py # ToolBase + ToolRegistry + @register_tool + unity_tools.py # 24 tools (see below) + orchestrator/ + __init__.py + prompts.py # SYSTEM_PROMPT + build_user_prompt + tool_catalog + knowledge/ + README.md + __init__.py # load_entry / list_entries / load_all + entries/ + unity_fundamentals.md + unity_physics.md + unity_rendering.md + unity_navigation.md + unity_input_system.md + unity_ui.md + unity_audio.md + open_world_design.md + qa/ + __init__.py # ProjectQA + Issue + QAReport + CLI + examples/ + generate_example.py # Regenerates examples/open_world_city/ + open_world_city/ # A complete generated Unity project + Assets/Scripts/*.cs + Assets/Scenes/MainScene.unity + Packages/manifest.json + ProjectSettings/* + README.md + pyproject.toml + README.md +``` + +## Tools (24 total) + +| Tool | Output | +|-------------------------------|--------------------------------------------------------------| +| `setup_unity_project` | Project scaffold (manifest, ProjectSettings, asmdef) | +| `create_player_controller` | PlayerController.cs (WASD + jump + physics) | +| `create_vehicle_controller` | VehicleController.cs (arcade car physics) | +| `create_procedural_city` | CityGenerator.cs + BuildingGenerator.cs (50+ buildings) | +| `create_third_person_camera` | ThirdPersonCamera.cs (smooth follow + collision) | +| `create_fps_controller` | FPSController.cs (mouse-look + WASD + jump) | +| `create_day_night_cycle` | DayNightCycle.cs (sun rotation + sky/ambient lerp) | +| `create_ai_npc` | NPCController.cs (NavMesh wander/chase FSM) | +| `create_pickup_system` | PickupSystem.cs + Pickup.cs (score + collectibles) | +| `create_health_system` | HealthSystem.cs + HealthBar.cs | +| `create_weapon_system` | WeaponSystem.cs (raycast hitscan + ammo + reload) | +| `create_audio_manager` | AudioManager.cs (SFX + music singleton) | +| `create_ui_manager` | UIManager.cs (TMP HUD: score / health / messages) | +| `create_terrain_generator` | TerrainGenerator.cs (Perlin heightmap + splat) | +| `create_water_shader` | WaterMaterial.cs (animated standard-material water) | +| `create_particle_effects` | ParticleSpawner.cs (bursts + explosion helper) | +| `create_save_system` | SaveSystem.cs (JSON save/load to persistentDataPath) | +| `create_inventory_system` | InventorySystem.cs (stacks + capacity + events) | +| `create_quest_system` | QuestSystem.cs (state machine + progress + completion) | +| `create_building_generator` | BuildingGenerator.cs (walls + emissive windows + collider) | +| `create_road_network` | RoadNetwork.cs (grid layout + intersections) | +| `write_csharp_script` | Any C# script to Assets/Scripts/ | +| `write_scene_file` | .unity scene file to Assets/Scenes/ | +| `generate_complete_game` | Full game in one call (presets: open_world_city, fps_arena, minimal) | + +## The system prompt + +`unity_agent.orchestrator.prompts.SYSTEM_PROMPT` is a long, prescriptive +prompt that teaches the LLM to: + +* Think like a senior Unity engineer (lifecycle, physics step, idiomatic + C#). +* Know the tool catalog and the recommended workflow (scaffold -> + components -> bootstrap script -> scene). +* Always emit code that compiles on the first try in Unity 2022.3 LTS. +* Prefer procedural content (runtime `Start()` generation) over + inspector-wired prefabs so the example "just plays" on open. +* Use the `UnityAgent` namespace and the `UnityAgent.Scripts` asmdef. + +Use it like this from your own LLM driver: + +```python +from unity_agent.orchestrator import SYSTEM_PROMPT, build_user_prompt + +prompt = build_user_prompt("Build me an open-world city game I can drive around in") +# Send SYSTEM_PROMPT + prompt to your LLM, then dispatch tool calls. +``` + +## Knowledge base + +Eight focused markdown references in `unity_agent/knowledge/entries/`: + +1. `unity_fundamentals.md` -- MonoBehaviour lifecycle, GameObjects, + components, coroutines, inspector best practices. +2. `unity_physics.md` -- Rigidbody, colliders, raycasting, ForceModes, + physics vs frame step. +3. `unity_rendering.md` -- Materials, Standard shader, lighting, post- + processing, LOD, fog. +4. `unity_navigation.md` -- NavMesh, NavMeshAgent, AI state machines, + NavMeshObstacle, off-mesh links. +5. `unity_input_system.md` -- Legacy Input Manager, mouse-look recipes, + new Input System, touch. +6. `unity_ui.md` -- Canvas, anchoring, HUD pattern, TMP, performance. +7. `unity_audio.md` -- AudioSource, 3D audio, AudioMixer, AudioManager + singleton. +8. `open_world_design.md` -- Procedural generation, LOD, streaming, + day/night cycle, save systems. + +Load them programmatically: + +```python +from unity_agent.knowledge import list_entries, load_entry, load_all + +for slug in list_entries(): + print(slug, len(load_entry(slug))) +``` + +## QA + +`unity_agent.qa.ProjectQA` runs static checks against a generated project: + +* Required directories exist (`Assets/`, `Assets/Scripts/`, `Packages/`, + `ProjectSettings/`). +* Required files exist (`Packages/manifest.json`, + `ProjectSettings/ProjectVersion.txt`, + `ProjectSettings/ProjectSettings.asset`). +* Every `.cs` file has balanced braces and at least one type declaration. +* Every `.cs` and `.unity` file has a sibling `.meta`. +* Scene files start with the `%YAML` header. +* `manifest.json` parses as JSON and has a `dependencies` object. + +```python +from unity_agent.qa import ProjectQA +report = ProjectQA("examples/open_world_city").run() +print(report.summary()) +print("PASS" if report.passed else "FAIL") +``` + +## Tests + +```bash +python -m unity_agent.tests +``` + +Verifies the tool registry has 24 entries, the project scaffolder writes +the expected files, generated C# has balanced braces, the full game +generator produces 12+ scripts, and QA passes on a freshly generated +project. + +## Requirements + +* Python 3.9+ (no third-party dependencies). +* Unity Hub + Unity 2022.3 LTS (or newer) to actually open and play the + generated projects. The Python layer works without Unity installed. + +## License + +MIT. diff --git a/examples/generate_example.py b/examples/generate_example.py new file mode 100644 index 0000000000000000000000000000000000000000..e2a2fe5d12807b40bae7622f3a55f97ce701a2a7 --- /dev/null +++ b/examples/generate_example.py @@ -0,0 +1,78 @@ +"""Regenerate the open_world_city example using the unity_agent package. + +Usage:: + + python examples/generate_example.py + +This re-creates ``examples/open_world_city/`` from scratch using the same +tool chain the orchestrator would use. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Make the package importable when run directly. +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from unity_agent.config import Settings +from unity_agent.transport.unity_transport import UnityTransport +from unity_agent.tools.unity_tools import ( + GenerateCompleteGameTool, + WriteCSharpScriptTool, +) +from unity_agent.qa import ProjectQA + + +CAMERA_CONTROLLER_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Convenience alias for . Lets designers + /// attach a component named CameraController if they prefer that name. + /// + public class CameraController : ThirdPersonCamera + { + // All behaviour is inherited from ThirdPersonCamera. + } +} +""" + + +def main() -> int: + examples_dir = ROOT / "examples" + settings = Settings(output_dir=str(examples_dir), product_name="open_world_city") + transport = UnityTransport(settings) + transport.open_project("open_world_city") + + # Clear the destination so we always start fresh. + import shutil + if (examples_dir / "open_world_city").exists(): + shutil.rmtree(examples_dir / "open_world_city") + transport.open_project("open_world_city") + + result = GenerateCompleteGameTool(settings, transport).run( + game_name="open_world_city", preset="open_world_city", + project_name="open_world_city", + ) + print(result.summary) + + # Add the CameraController alias the user spec asks for. + cam_result = WriteCSharpScriptTool(settings, transport).run( + filename="CameraController.cs", code=CAMERA_CONTROLLER_CS, + project_name="open_world_city", + ) + print(cam_result.summary) + + # QA the result. + report = ProjectQA(examples_dir / "open_world_city").run() + print(report.summary()) + return 0 if report.passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/open_world_city/Assets/Scenes/MainScene.unity b/examples/open_world_city/Assets/Scenes/MainScene.unity new file mode 100644 index 0000000000000000000000000000000000000000..986c1e11a29ea91499443afad8d2eb2db30925eb --- /dev/null +++ b/examples/open_world_city/Assets/Scenes/MainScene.unity @@ -0,0 +1,101 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 9 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.4, g: 0.4, b: 0.4, a: 1} + m_AmbientEquatorColor: {r: 0.2, g: 0.2, b: 0.2, a: 1} + m_AmbientGroundColor: {r: 0.1, g: 0.1, b: 0.1, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.2, g: 0.2, b: 0.2, a: 1} + m_SkyboxMaterial: {fileID: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_IndirectSpecularColor: {r: 0, g: 0, b: 0, a: 1} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GIWorkflowMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 +--- !u!196 &4 +NavMeshSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_AutoGenerate: 0 +--- !u!1 &100 +GameObject: + m_ObjectHideFlags: 0 + m_Name: Main Camera + m_IsActive: 1 + m_Component: + - component: {fileID: 101} + - component: {fileID: 102} + m_Layer: 0 +--- !u!4 &101 +Transform: + m_GameObject: {fileID: 100} + m_LocalPosition: {x: 0, y: 5, z: -10} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalScale: {x: 1, y: 1, z: 1} +--- !u!20 &102 +Camera: + m_GameObject: {fileID: 100} + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.4, g: 0.6, b: 0.9, a: 1} + m_Orthographic: 0 + m_FieldOfView: 60 + m_NearClipPlane: 0.3 + m_FarClipPlane: 1000 +--- !u!1 &200 +GameObject: + m_ObjectHideFlags: 0 + m_Name: Directional Light + m_IsActive: 1 + m_Component: + - component: {fileID: 201} + - component: {fileID: 202} + m_Layer: 0 +--- !u!4 &201 +Transform: + m_GameObject: {fileID: 200} + m_LocalRotation: {x: 0.4, y: -0.3, z: 0.2, w: 0.8} + m_LocalPosition: {x: 0, y: 50, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} +--- !u!108 &202 +Light: + m_GameObject: {fileID: 200} + m_Type: 1 + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_Intensity: 1 + m_Shadows: + m_Type: 2 diff --git a/examples/open_world_city/Assets/Scenes/MainScene.unity.meta b/examples/open_world_city/Assets/Scenes/MainScene.unity.meta new file mode 100644 index 0000000000000000000000000000000000000000..0eec47cc5a988539b75c511768a671fe324b7b48 --- /dev/null +++ b/examples/open_world_city/Assets/Scenes/MainScene.unity.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8a32e127d322fab3b2c0affc00e372cd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/AudioManager.cs b/examples/open_world_city/Assets/Scripts/AudioManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..92797df84c8fcbe346a39b0f76a1a097d21646ff --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/AudioManager.cs @@ -0,0 +1,67 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Singleton audio manager that exposes named one-shot SFX and music + /// channels. Used by other systems to play clips without having to manage + /// their own AudioSource components. + /// + public class AudioManager : MonoBehaviour + { + public static AudioManager Instance { get; private set; } + + [Header("Mixers")] + public AudioSource sfxSource; + public AudioSource musicSource; + + [Range(0f, 1f)] public float masterVolume = 1f; + [Range(0f, 1f)] public float sfxVolume = 0.8f; + [Range(0f, 1f)] public float musicVolume = 0.5f; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + DontDestroyOnLoad(gameObject); + + if (sfxSource == null) + { + sfxSource = gameObject.AddComponent(); + sfxSource.playOnAwake = false; + } + if (musicSource == null) + { + musicSource = gameObject.AddComponent(); + musicSource.loop = true; + musicSource.playOnAwake = false; + } + ApplyVolumes(); + } + + public void PlaySfx(AudioClip clip, float volumeScale = 1f) + { + if (clip != null && sfxSource != null) + sfxSource.PlayOneShot(clip, volumeScale * sfxVolume * masterVolume); + } + + public void PlayMusic(AudioClip clip) + { + if (clip == null || musicSource == null) return; + musicSource.clip = clip; + musicSource.volume = musicVolume * masterVolume; + musicSource.Play(); + } + + public void StopMusic() + { + if (musicSource != null) musicSource.Stop(); + } + + public void ApplyVolumes() + { + if (musicSource != null) musicSource.volume = musicVolume * masterVolume; + AudioListener.volume = masterVolume; + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/AudioManager.cs.meta b/examples/open_world_city/Assets/Scripts/AudioManager.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..0051f7a9862e6fcfd6a0a3a79768f68cdc9cc0f6 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/AudioManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 803745e8c679b595410a0a143b1abc48 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/BuildingGenerator.cs b/examples/open_world_city/Assets/Scripts/BuildingGenerator.cs new file mode 100644 index 0000000000000000000000000000000000000000..1afddae4c8570e0689ec499888ee8c491e3b8585 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/BuildingGenerator.cs @@ -0,0 +1,68 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Procedural building generator: builds a box building mesh with + /// emissive windows and adds a BoxCollider for collision. + /// + public static class BuildingGenerator + { + public static GameObject Generate(string name, Vector3 position, Vector3 size, + Material wallMaterial, Material windowMaterial) + { + GameObject go = new GameObject(name); + go.transform.position = position; + + // Walls + GameObject walls = GameObject.CreatePrimitive(PrimitiveType.Cube); + walls.name = "Walls"; + walls.transform.SetParent(go.transform, false); + walls.transform.localScale = size; + walls.transform.localPosition = Vector3.up * size.y * 0.5f; + if (wallMaterial != null) walls.GetComponent().sharedMaterial = wallMaterial; + + // Windows: a smaller cube with the window material, slightly offset on each side. + if (windowMaterial != null) + { + GameObject windows = GameObject.CreatePrimitive(PrimitiveType.Cube); + windows.name = "Windows"; + windows.transform.SetParent(go.transform, false); + windows.transform.localScale = size * 0.99f; + windows.transform.localPosition = Vector3.up * size.y * 0.5f; + var mr = windows.GetComponent(); + mr.sharedMaterial = windowMaterial; + } + + // Collider (the cube already has one, but ensure it's a BoxCollider). + var box = go.GetComponent(); + if (box == null) box = go.AddComponent(); + box.size = size; + box.center = Vector3.up * size.y * 0.5f; + + return go; + } + + /// + /// Build an emissive window material procedurally so the agent does + /// not depend on any asset on disk. + /// + public static Material MakeWindowMaterial(Color glow) + { + var m = new Material(Shader.Find("Standard")); + m.name = "WindowMat"; + m.color = Color.black; + m.SetColor("_EmissionColor", glow); + m.EnableKeyword("_EMISSION"); + return m; + } + + public static Material MakeWallMaterial(Color col) + { + var m = new Material(Shader.Find("Standard")); + m.name = "WallMat"; + m.color = col; + return m; + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/BuildingGenerator.cs.meta b/examples/open_world_city/Assets/Scripts/BuildingGenerator.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..d68d03584b9a2720434f8fecad54e6efa50939d5 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/BuildingGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 720373d252ebafade9462c2ee8876f1c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/CameraController.cs b/examples/open_world_city/Assets/Scripts/CameraController.cs new file mode 100644 index 0000000000000000000000000000000000000000..ec4636f22c9478cb040ea6f070d4ec116bb1ec7f --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/CameraController.cs @@ -0,0 +1,13 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Convenience alias for . Lets designers + /// attach a component named CameraController if they prefer that name. + /// + public class CameraController : ThirdPersonCamera + { + // All behaviour is inherited from ThirdPersonCamera. + } +} diff --git a/examples/open_world_city/Assets/Scripts/CameraController.cs.meta b/examples/open_world_city/Assets/Scripts/CameraController.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..5cb8f58cadd0a9f8d2c10f3921c2bc4b749ea769 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/CameraController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34509a07673ea2c5921f70e933984b60 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/CityGenerator.cs b/examples/open_world_city/Assets/Scripts/CityGenerator.cs new file mode 100644 index 0000000000000000000000000000000000000000..63c295e35648797efd2400cfab3183d03fb70537 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/CityGenerator.cs @@ -0,0 +1,110 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Procedural city generator: spawns a grid of buildings of varying + /// heights with emissive windows, street lights and parked vehicles. + /// Designed to be paired with . + /// + public class CityGenerator : MonoBehaviour + { + [Header("Grid")] + public int gridX = 8; + public int gridZ = 8; + public float blockSpacing = 30f; + + [Header("Buildings")] + public int buildingsPerBlock = 1; + public float minBuildingHeight = 8f; + public float maxBuildingHeight = 40f; + public float buildingFootprint = 8f; + public Color wallColor = new Color(0.4f, 0.4f, 0.42f); + public Color windowColor = new Color(1.0f, 0.85f, 0.4f); + + [Header("Street Lights")] + public bool spawnStreetLights = true; + public float lightSpacing = 15f; + public Color lightColor = new Color(1.0f, 0.85f, 0.5f); + public float lightIntensity = 1.5f; + public float lightRange = 15f; + + private readonly System.Collections.Generic.List _lights = new System.Collections.Generic.List(); + + private void Start() + { + Generate(); + } + + public void Generate() + { + Material wallMat = BuildingGenerator.MakeWallMaterial(wallColor); + Material winMat = BuildingGenerator.MakeWindowMaterial(windowColor); + + float totalX = (gridX - 1) * blockSpacing; + float totalZ = (gridZ - 1) * blockSpacing; + Vector3 origin = transform.position - new Vector3(totalX * 0.5f, 0f, totalZ * 0.5f); + + for (int x = 0; x < gridX; x++) + { + for (int z = 0; z < gridZ; z++) + { + Vector3 block = new Vector3(origin.x + x * blockSpacing, 0f, origin.z + z * blockSpacing); + SpawnBlock(block, wallMat, winMat); + } + } + + if (spawnStreetLights) SpawnStreetLights(origin, totalX, totalZ); + } + + private void SpawnBlock(Vector3 center, Material wallMat, Material winMat) + { + for (int i = 0; i < buildingsPerBlock; i++) + { + float h = Random.Range(minBuildingHeight, maxBuildingHeight); + Vector3 size = new Vector3(buildingFootprint, h, buildingFootprint); + Vector3 pos = center + new Vector3( + Random.Range(-4f, 4f), 0f, Random.Range(-4f, 4f)); + BuildingGenerator.Generate($"Building_{center.x}_{center.z}_{i}", pos, size, wallMat, winMat); + } + } + + private void SpawnStreetLights(Vector3 origin, float totalX, float totalZ) + { + Material poleMat = BuildingGenerator.MakeWallMaterial(new Color(0.1f, 0.1f, 0.1f)); + + for (float x = origin.x; x <= origin.x + totalX + 0.1f; x += lightSpacing) + { + for (float z = origin.z; z <= origin.z + totalZ + 0.1f; z += lightSpacing) + { + SpawnStreetLight(new Vector3(x, 0f, z), poleMat); + } + } + } + + private void SpawnStreetLight(Vector3 pos, Material poleMat) + { + GameObject pole = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + pole.name = "StreetLight"; + pole.transform.SetParent(transform, false); + pole.transform.position = pos + Vector3.up * 4f; + pole.transform.localScale = new Vector3(0.2f, 4f, 0.2f); + pole.GetComponent().sharedMaterial = poleMat; + + GameObject bulb = GameObject.CreatePrimitive(PrimitiveType.Sphere); + bulb.transform.SetParent(pole.transform, false); + bulb.transform.localPosition = Vector3.up * 0.9f; + bulb.transform.localScale = Vector3.one * 0.4f; + bulb.GetComponent().sharedMaterial = BuildingGenerator.MakeWindowMaterial(lightColor); + + Light l = bulb.AddComponent(); + l.type = LightType.Point; + l.color = lightColor; + l.intensity = lightIntensity; + l.range = lightRange; + _lights.Add(l); + } + + public Light[] StreetLights => _lights.ToArray(); + } +} diff --git a/examples/open_world_city/Assets/Scripts/CityGenerator.cs.meta b/examples/open_world_city/Assets/Scripts/CityGenerator.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..c0e65dfc447c8364fe039d68aee99600b354458f --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/CityGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1590f0f60a74829eaa91c784094575b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/DayNightCycle.cs b/examples/open_world_city/Assets/Scripts/DayNightCycle.cs new file mode 100644 index 0000000000000000000000000000000000000000..c14a54a0063f20877417c0ed2d86074be75c21d3 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/DayNightCycle.cs @@ -0,0 +1,69 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Rotates a directional light around the world to simulate a day/night + /// cycle. Also lerps between day and night sky/ambient colors and turns + /// street lights on after sunset. + /// + public class DayNightCycle : MonoBehaviour + { + [Header("Sun")] + public Light sun; + public float dayLengthSeconds = 120f; + + [Header("Colors")] + public Color daySky = new Color(0.5f, 0.7f, 1.0f); + public Color nightSky = new Color(0.03f, 0.04f, 0.08f); + public Color dayAmbient = new Color(0.6f, 0.6f, 0.6f); + public Color nightAmbient = new Color(0.12f, 0.13f, 0.18f); + + [Header("Lights")] + public Light[] streetLights; + public float streetLightThreshold = 0.2f; + + private float _timeOfDay; // 0..1 + + private void Start() + { + if (sun == null) sun = GetComponent(); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + } + + private void Update() + { + _timeOfDay = (_timeOfDay + Time.deltaTime / dayLengthSeconds) % 1f; + + // Sun angle: at timeOfDay=0.25 sun is at noon. + float sunAngle = (_timeOfDay - 0.25f) * 360f; + if (sun != null) + { + sun.transform.rotation = Quaternion.Euler(sunAngle, -30f, 0f); + float dayFactor = Mathf.Clamp01(Mathf.Cos((_timeOfDay - 0.25f) * Mathf.PI * 2f) * 0.5f + 0.5f); + sun.intensity = dayFactor * 1.2f; + } + + float t = Mathf.Clamp01(Mathf.Cos((_timeOfDay - 0.25f) * Mathf.PI * 2f) * 0.5f + 0.5f); + RenderSettings.ambientLight = Color.Lerp(nightAmbient, dayAmbient, t); + Camera mainCam = Camera.main; + if (mainCam != null) + { + mainCam.backgroundColor = Color.Lerp(nightSky, daySky, t); + } + + UpdateStreetLights(t); + } + + private void UpdateStreetLights(float dayFactor) + { + if (streetLights == null) return; + bool on = dayFactor < streetLightThreshold; + foreach (var l in streetLights) + if (l != null) l.enabled = on; + } + + /// Current time of day in 0..1 range (0=midnight). + public float TimeOfDay => _timeOfDay; + } +} diff --git a/examples/open_world_city/Assets/Scripts/DayNightCycle.cs.meta b/examples/open_world_city/Assets/Scripts/DayNightCycle.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..f3704f70d8dee6a31662d06e699b66816fa560a9 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/DayNightCycle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 06ebd9e278d5fc98279370f9128b86f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/GameManager.cs b/examples/open_world_city/Assets/Scripts/GameManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..3ec9da21f2f123633a49e347d1be3cb764d42bc9 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/GameManager.cs @@ -0,0 +1,116 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Bootstrap that wires up the generated systems on scene load. The + /// scene only needs an empty GameObject with this component attached. + /// + public class GameManager : MonoBehaviour + { + [Header("Player")] + public GameObject playerPrefab; + public Vector3 spawnPosition = new Vector3(0f, 2f, 0f); + + [Header("City")] + public int cityGridX = 8; + public int cityGridZ = 8; + + private void Start() + { + SpawnPlayer(); + SpawnCity(); + SpawnLights(); + SetupCamera(); + SetupAudio(); + SetupUI(); + } + + private void SpawnPlayer() + { + GameObject player = playerPrefab != null + ? Instantiate(playerPrefab, spawnPosition, Quaternion.identity) + : CreatePrimitivePlayer(); + player.tag = "Player"; + player.name = "Player"; + + var pc = player.GetComponent(); + if (pc == null) pc = player.AddComponent(); + var rb = player.GetComponent(); + if (rb == null) rb = player.AddComponent(); + var col = player.GetComponent(); + if (col == null) col = player.AddComponent(); + col.height = 2f; col.radius = 0.4f; col.center = new Vector3(0f, 1f, 0f); + rb.constraints = RigidbodyConstraints.FreezeRotation; + } + + private GameObject CreatePrimitivePlayer() + { + GameObject go = GameObject.CreatePrimitive(PrimitiveType.Capsule); + go.transform.position = spawnPosition; + return go; + } + + private void SpawnCity() + { + GameObject city = new GameObject("City"); + var gen = city.AddComponent(); + gen.gridX = cityGridX; + gen.gridZ = cityGridZ; + + GameObject roads = new GameObject("Roads"); + roads.transform.SetParent(city.transform, false); + var net = roads.AddComponent(); + net.gridSize = Mathf.Max(cityGridX, cityGridZ); + } + + private void SpawnLights() + { + GameObject sunGo = new GameObject("Sun"); + var sun = sunGo.AddComponent(); + sun.type = LightType.Directional; + sun.intensity = 1.2f; + sun.shadows = LightShadows.Soft; + + var dnc = sunGo.AddComponent(); + dnc.sun = sun; + // Bind street lights from the city generator after one frame. + StartCoroutine(BindStreetLights(dnc)); + } + + private System.Collections.IEnumerator BindStreetLights(DayNightCycle dnc) + { + yield return null; // allow CityGenerator.Start() to run + var cg = FindObjectOfType(); + if (cg != null) dnc.streetLights = cg.StreetLights; + } + + private void SetupCamera() + { + Camera cam = Camera.main; + if (cam == null) + { + var go = new GameObject("Main Camera"); + cam = go.AddComponent(); + go.tag = "MainCamera"; + go.AddComponent(); + } + var tpc = cam.GetComponent(); + if (tpc == null) tpc = cam.gameObject.AddComponent(); + var player = GameObject.FindGameObjectWithTag("Player"); + tpc.target = player != null ? player.transform : null; + } + + private void SetupAudio() + { + var go = new GameObject("AudioManager"); + go.AddComponent(); + } + + private void SetupUI() + { + var go = new GameObject("UIManager"); + go.AddComponent(); + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/GameManager.cs.meta b/examples/open_world_city/Assets/Scripts/GameManager.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..9649596441fecf705999b1fd07df82e64f8f1de3 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/GameManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 146f64a8241e4314114edb414ce6d721 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/HealthSystem.cs b/examples/open_world_city/Assets/Scripts/HealthSystem.cs new file mode 100644 index 0000000000000000000000000000000000000000..518e4b5df39876163a98f454b9f2ed20709db6ae --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/HealthSystem.cs @@ -0,0 +1,82 @@ +using UnityEngine; +using UnityEngine.Events; + +namespace UnityAgent +{ + /// + /// Damageable entity with health regen and death event. Pairs with the + /// optional UI component. + /// + public class HealthSystem : MonoBehaviour + { + [Header("Health")] + public float maxHealth = 100f; + public float currentHealth { get; private set; } + public float regenPerSecond = 1f; + public float regenDelay = 5f; + + [Header("Events")] + public UnityEvent onDeath; + public UnityEvent onDamaged; + + private float _lastDamageTime; + private bool _dead; + + public float Normalised => currentHealth / maxHealth; + + private void Awake() + { + currentHealth = maxHealth; + } + + private void Update() + { + if (_dead) return; + if (Time.time - _lastDamageTime > regenDelay && currentHealth < maxHealth) + { + currentHealth = Mathf.Min(maxHealth, currentHealth + regenPerSecond * Time.deltaTime); + } + } + + public void TakeDamage(float amount) + { + if (_dead) return; + currentHealth = Mathf.Max(0f, currentHealth - amount); + _lastDamageTime = Time.time; + onDamaged?.Invoke(amount); + if (currentHealth <= 0f) Die(); + } + + public void Heal(float amount) + { + if (_dead) return; + currentHealth = Mathf.Min(maxHealth, currentHealth + amount); + } + + private void Die() + { + _dead = true; + onDeath?.Invoke(); + Destroy(gameObject, 0.5f); + } + } + + /// + /// Simple health bar that reads a and updates + /// a child Image.fillAmount. + /// + public class HealthBar : MonoBehaviour + { + public HealthSystem source; + public UnityEngine.UI.Image fillImage; + public bool hideWhenFull = true; + + private void Update() + { + if (source == null || fillImage == null) return; + float n = source.Normalised; + fillImage.fillAmount = n; + if (hideWhenFull) fillImage.transform.parent.gameObject.SetActive(n < 0.999f); + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/HealthSystem.cs.meta b/examples/open_world_city/Assets/Scripts/HealthSystem.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..3403850e84b9b25252a4a700bb9f084a38250c10 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/HealthSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e2b705888cccf6861f62175541c5a1d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/NPCController.cs b/examples/open_world_city/Assets/Scripts/NPCController.cs new file mode 100644 index 0000000000000000000000000000000000000000..ce7a4d69a6d832f63e32a3abf4709c907d2c0c98 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/NPCController.cs @@ -0,0 +1,124 @@ +using UnityEngine; +using UnityEngine.AI; + +namespace UnityAgent +{ + /// + /// AI NPC that wanders between random points and chases the player when + /// they enter the aggro radius. Uses Unity's NavMeshAgent. + /// + [RequireComponent(typeof(NavMeshAgent))] + public class NPCController : MonoBehaviour + { + public enum State { Idle, Wander, Chase } + + [Header("References")] + public Transform player; + + [Header("Behaviour")] + public State initialState = State.Wander; + public float wanderRadius = 20f; + public float aggroRange = 12f; + public float loseSightRange = 20f; + public float idleDuration = 2f; + public float wanderSpeed = 2.5f; + public float chaseSpeed = 5f; + + [Header("Debug")] + public bool drawGizmos = true; + + private NavMeshAgent _agent; + private State _state; + private float _stateTimer; + private Vector3 _startPos; + + private void Awake() + { + _agent = GetComponent(); + _agent.speed = wanderSpeed; + } + + private void Start() + { + _state = initialState; + _startPos = transform.position; + if (player == null && GameObject.FindGameObjectWithTag("Player") != null) + player = GameObject.FindGameObjectWithTag("Player").transform; + } + + private void Update() + { + switch (_state) + { + case State.Idle: TickIdle(); break; + case State.Wander: TickWander(); break; + case State.Chase: TickChase(); break; + } + } + + private void TickIdle() + { + _stateTimer -= Time.deltaTime; + if (PlayerInRange(aggroRange)) { EnterChase(); return; } + if (_stateTimer <= 0f) EnterWander(); + } + + private void TickWander() + { + if (PlayerInRange(aggroRange)) { EnterChase(); return; } + if (_agent.remainingDistance <= _agent.stoppingDistance && !_agent.pathPending) + EnterIdle(); + } + + private void TickChase() + { + if (player == null) { EnterWander(); return; } + _agent.SetDestination(player.position); + + if (!PlayerInRange(loseSightRange)) EnterWander(); + } + + private void EnterIdle() + { + _state = State.Idle; + _stateTimer = idleDuration; + _agent.isStopped = true; + } + + private void EnterWander() + { + _state = State.Wander; + _agent.speed = wanderSpeed; + _agent.isStopped = false; + Vector3 dir = Random.insideUnitSphere * wanderRadius; + dir.y = 0f; + Vector3 target = _startPos + dir; + if (NavMesh.SamplePosition(target, out NavMeshHit hit, wanderRadius, NavMesh.AllAreas)) + _agent.SetDestination(hit.position); + } + + private void EnterChase() + { + _state = State.Chase; + _agent.speed = chaseSpeed; + _agent.isStopped = false; + } + + private bool PlayerInRange(float range) + { + if (player == null) return false; + return Vector3.Distance(transform.position, player.position) <= range; + } + + private void OnDrawGizmosSelected() + { + if (!drawGizmos) return; + Gizmos.color = Color.yellow; + Gizmos.DrawWireSphere(transform.position, aggroRange); + Gizmos.color = Color.red; + Gizmos.DrawWireSphere(transform.position, loseSightRange); + Gizmos.color = Color.green; + Gizmos.DrawWireSphere(_startPos == Vector3.zero ? transform.position : _startPos, wanderRadius); + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/NPCController.cs.meta b/examples/open_world_city/Assets/Scripts/NPCController.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..8024d425fc13f1aedbfdfecf7e6e7b8cca6d9541 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/NPCController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bdb23444532d812096a8a0bf7c0f341f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/PickupSystem.cs b/examples/open_world_city/Assets/Scripts/PickupSystem.cs new file mode 100644 index 0000000000000000000000000000000000000000..c36908498b4b7e9c7800e39366a8f2daf183f6e2 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/PickupSystem.cs @@ -0,0 +1,63 @@ +using UnityEngine; +using UnityEngine.Events; + +namespace UnityAgent +{ + /// + /// Collectible pickup. Triggers an event when collected and adds to the + /// global PickupSystem score. + /// + [RequireComponent(typeof(Collider))] + public class Pickup : MonoBehaviour + { + public int value = 10; + public AudioClip collectClip; + public UnityEvent onCollected; + + private void Reset() + { + GetComponent().isTrigger = true; + } + + private void OnTriggerEnter(Collider other) + { + if (!other.CompareTag("Player")) return; + PickupSystem.Instance?.Collect(value); + onCollected?.Invoke(); + if (collectClip != null && PickupSystem.Instance != null) + PickupSystem.Instance.PlayPickupSound(collectClip); + Destroy(gameObject); + } + } + + /// + /// Singleton that tracks the player's score and plays pickup sounds. + /// + public class PickupSystem : MonoBehaviour + { + public static PickupSystem Instance { get; private set; } + + public int score { get; private set; } + public AudioSource audioSource; + + public System.Action onScoreChanged; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + if (audioSource == null) audioSource = gameObject.AddComponent(); + } + + public void Collect(int amount) + { + score += amount; + onScoreChanged?.Invoke(score); + } + + public void PlayPickupSound(AudioClip clip) + { + if (audioSource != null && clip != null) audioSource.PlayOneShot(clip); + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/PickupSystem.cs.meta b/examples/open_world_city/Assets/Scripts/PickupSystem.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..11a245efc891447d0ac6e0bd95c9b40405b0d613 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/PickupSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 08af630d5809345e6854281a491a6740 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/PlayerController.cs b/examples/open_world_city/Assets/Scripts/PlayerController.cs new file mode 100644 index 0000000000000000000000000000000000000000..d2b40bc51156aea147dd0c552eb813ea8b8c5808 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/PlayerController.cs @@ -0,0 +1,109 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Third-person character controller with WASD movement, jump and a + /// rigidbody-based physics response. Designed to be paired with the + /// ThirdPersonCamera component. + /// + [RequireComponent(typeof(Rigidbody))] + [RequireComponent(typeof(CapsuleCollider))] + public class PlayerController : MonoBehaviour + { + [Header("Movement")] + public float moveSpeed = 6f; + public float sprintMultiplier = 1.6f; + public float rotationSmooth = 12f; + public float jumpForce = 7.5f; + + [Header("Ground Check")] + public float groundCheckDistance = 0.2f; + public LayerMask groundMask = ~0; + + [Header("Camera")] + public Transform cameraTransform; + + private Rigidbody _rb; + private CapsuleCollider _col; + private Vector2 _moveInput; + private bool _jumpRequested; + private bool _isGrounded; + private float _targetRotation; + + private void Awake() + { + _rb = GetComponent(); + _col = GetComponent(); + _rb.freezeRotation = true; + _rb.interpolation = RigidbodyInterpolation.Interpolate; + if (cameraTransform == null && Camera.main != null) + cameraTransform = Camera.main.transform; + } + + private void Update() + { + _moveInput.x = Input.GetAxisRaw("Horizontal"); + _moveInput.y = Input.GetAxisRaw("Vertical"); + _moveInput = Vector2.ClampMagnitude(_moveInput, 1f); + + if (Input.GetButtonDown("Jump") && _isGrounded) + _jumpRequested = true; + } + + private void FixedUpdate() + { + GroundCheck(); + Move(); + if (_jumpRequested) + { + _rb.AddForce(Vector3.up * jumpForce, ForceMode.VelocityChange); + _jumpRequested = false; + } + } + + private void GroundCheck() + { + Vector3 origin = transform.position + Vector3.up * (_col.height * 0.5f - _col.radius); + _isGrounded = Physics.SphereCast( + origin, _col.radius * 0.9f, Vector3.down, + out _, groundCheckDistance, groundMask, QueryTriggerInteraction.Ignore); + } + + private void Move() + { + float speed = moveSpeed; + if (Input.GetKey(KeyCode.LeftShift)) speed *= sprintMultiplier; + + if (_moveInput.sqrMagnitude < 0.01f) + { + // Apply horizontal damping so the character stops cleanly. + Vector3 v = _rb.velocity; + v.x *= 0.85f; + v.z *= 0.85f; + _rb.velocity = v; + return; + } + + Vector3 camForward = cameraTransform ? cameraTransform.forward : transform.forward; + Vector3 camRight = cameraTransform ? cameraTransform.right : transform.right; + camForward.y = 0f; camForward.Normalize(); + camRight.y = 0f; camRight.Normalize(); + + Vector3 desired = (camForward * _moveInput.y + camRight * _moveInput.x) * speed; + Vector3 velocity = _rb.velocity; + velocity.x = desired.x; + velocity.z = desired.z; + _rb.velocity = velocity; + + if (desired.sqrMagnitude > 0.01f) + { + _targetRotation = Mathf.Atan2(desired.x, desired.z) * Mathf.Rad2Deg; + float smoothed = Mathf.LerpAngle( + transform.rotation.eulerAngles.y, _targetRotation, + Time.fixedDeltaTime * rotationSmooth); + _rb.MoveRotation(Quaternion.Euler(0f, smoothed, 0f)); + } + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/PlayerController.cs.meta b/examples/open_world_city/Assets/Scripts/PlayerController.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..5e04a34bfec94274a8d07305bd9ff044356929ae --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/PlayerController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c6566cf7b83ff91f376a7d09d43b5fd3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/RoadNetwork.cs b/examples/open_world_city/Assets/Scripts/RoadNetwork.cs new file mode 100644 index 0000000000000000000000000000000000000000..08591b3d3c053d4e6a9e618a453b629e57cdd5d0 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/RoadNetwork.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Lays out a grid road network at runtime: builds thin box meshes for + /// roads and an intersection marker at every grid crossing. + /// + public class RoadNetwork : MonoBehaviour + { + [Header("Grid")] + public int gridSize = 5; + public float blockSpacing = 30f; + public float roadWidth = 6f; + public float roadThickness = 0.1f; + + [Header("Materials")] + public Material roadMaterial; + public Material intersectionMaterial; + + private readonly List _roads = new List(); + + private void Start() + { + Build(); + } + + public void Build() + { + foreach (var r in _roads) if (r != null) Destroy(r); + _roads.Clear(); + + float total = (gridSize - 1) * blockSpacing; + Vector3 origin = transform.position - new Vector3(total * 0.5f, 0f, total * 0.5f); + + for (int i = 0; i < gridSize; i++) + { + // Horizontal road + _roads.Add(SpawnRoad( + new Vector3(origin.x + total * 0.5f, 0f, origin.z + i * blockSpacing), + new Vector3(total + roadWidth, roadThickness, roadWidth), + roadMaterial, $"Road_H_{i}")); + + // Vertical road + _roads.Add(SpawnRoad( + new Vector3(origin.x + i * blockSpacing, 0f, origin.z + total * 0.5f), + new Vector3(roadWidth, roadThickness, total + roadWidth), + roadMaterial, $"Road_V_{i}")); + } + + // Intersections. + for (int x = 0; x < gridSize; x++) + { + for (int z = 0; z < gridSize; z++) + { + Vector3 p = new Vector3(origin.x + x * blockSpacing, 0f, origin.z + z * blockSpacing); + _roads.Add(SpawnRoad(p, new Vector3(roadWidth * 1.2f, roadThickness * 1.5f, roadWidth * 1.2f), + intersectionMaterial, $"Intersection_{x}_{z}")); + } + } + } + + private GameObject SpawnRoad(Vector3 pos, Vector3 scale, Material mat, string name) + { + GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); + go.name = name; + go.transform.SetParent(transform, false); + go.transform.position = pos + transform.position; + go.transform.localScale = scale; + if (mat != null) go.GetComponent().sharedMaterial = mat; + return go; + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/RoadNetwork.cs.meta b/examples/open_world_city/Assets/Scripts/RoadNetwork.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..5d3d777a3860ca6b676b4dff32909f3840f45847 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/RoadNetwork.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4afc638bbfb2ca81ab494f215750fc85 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/SaveSystem.cs b/examples/open_world_city/Assets/Scripts/SaveSystem.cs new file mode 100644 index 0000000000000000000000000000000000000000..bca9e3e771373e1c5b883f4b5c2ec9c71c4c38d3 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/SaveSystem.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using UnityEngine; + +namespace UnityAgent +{ + /// + /// JSON save system using . Stores a single + /// save slot per profile in Application.persistentDataPath. + /// + public class SaveSystem : MonoBehaviour + { + public static SaveSystem Instance { get; private set; } + + public string profileName = "default"; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + private string SavePath => Path.Combine(Application.persistentDataPath, profileName + ".save"); + + public void Save(T data) where T : class + { + try + { + string json = JsonUtility.ToJson(new Wrapper { payload = data }, true); + File.WriteAllText(SavePath, json); + Debug.Log($"[SaveSystem] Saved to {SavePath}"); + } + catch (Exception e) + { + Debug.LogError($"[SaveSystem] Save failed: {e}"); + } + } + + public T Load() where T : class + { + if (!File.Exists(SavePath)) return null; + try + { + string json = File.ReadAllText(SavePath); + var w = JsonUtility.FromJson>(json); + return w?.payload; + } + catch (Exception e) + { + Debug.LogError($"[SaveSystem] Load failed: {e}"); + return null; + } + } + + public bool HasSave => File.Exists(SavePath); + + public void Delete() + { + if (File.Exists(SavePath)) File.Delete(SavePath); + } + + [Serializable] + private class Wrapper { public T payload; } + } +} diff --git a/examples/open_world_city/Assets/Scripts/SaveSystem.cs.meta b/examples/open_world_city/Assets/Scripts/SaveSystem.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..e76504ed589e0061af7bdbf8ca7967012e690909 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/SaveSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 713fe181501c9d094612732235d78b10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/ThirdPersonCamera.cs b/examples/open_world_city/Assets/Scripts/ThirdPersonCamera.cs new file mode 100644 index 0000000000000000000000000000000000000000..d992c338dca9d1e7f57e14a8e6bc10744327be79 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/ThirdPersonCamera.cs @@ -0,0 +1,85 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Smooth third-person camera with collision handling. Attach to a Camera + /// GameObject and assign the target transform in the inspector. + /// + public class ThirdPersonCamera : MonoBehaviour + { + [Header("Target")] + public Transform target; + + [Header("Positioning")] + public Vector3 pivotOffset = new Vector3(0f, 1.5f, 0f); + public float distance = 5f; + public float minDistance = 1.5f; + public float maxDistance = 8f; + public float height = 1.2f; + + [Header("Smoothing")] + public float positionSmooth = 10f; + public float rotationSmooth = 10f; + + [Header("Collision")] + public LayerMask collisionMask = ~0; + public float collisionRadius = 0.3f; + + [Header("Input")] + public bool useMouseOrbit = true; + public float mouseSensitivity = 3f; + public float minPitch = -30f; + public float maxPitch = 75f; + + private float _yaw; + private float _pitch = 15f; + private float _currentDistance; + + private void Start() + { + _currentDistance = distance; + if (target != null) + { + Vector3 e = transform.eulerAngles; + _yaw = e.y; _pitch = e.x; + } + Cursor.lockState = CursorLockMode.Locked; + } + + private void LateUpdate() + { + if (target == null) return; + + if (useMouseOrbit) + { + _yaw += Input.GetAxis("Mouse X") * mouseSensitivity; + _pitch -= Input.GetAxis("Mouse Y") * mouseSensitivity; + _pitch = Mathf.Clamp(_pitch, minPitch, maxPitch); + } + + Quaternion rotation = Quaternion.Euler(_pitch, _yaw, 0f); + Vector3 pivot = target.position + pivotOffset; + + // Desired camera position. + Vector3 desired = pivot + rotation * new Vector3(0f, height, -distance); + + // Collision: shrink the distance if there's an obstacle. + if (Physics.SphereCast( + pivot, collisionRadius, (desired - pivot).normalized, + out RaycastHit hit, distance, collisionMask, QueryTriggerInteraction.Ignore)) + { + _currentDistance = Mathf.Clamp(hit.distance, minDistance, distance); + } + else + { + _currentDistance = Mathf.Lerp(_currentDistance, distance, Time.deltaTime * 4f); + } + + Vector3 finalPos = pivot + rotation * new Vector3(0f, height, -_currentDistance); + transform.position = Vector3.Lerp(transform.position, finalPos, positionSmooth * Time.deltaTime); + transform.rotation = Quaternion.Slerp( + transform.rotation, rotation, rotationSmooth * Time.deltaTime); + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/ThirdPersonCamera.cs.meta b/examples/open_world_city/Assets/Scripts/ThirdPersonCamera.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..923333b694151252caa01b85de9f00d5d0bf966a --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/ThirdPersonCamera.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d76bc6d6a55fca62d80d72f6cff57325 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/UIManager.cs b/examples/open_world_city/Assets/Scripts/UIManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..727bb05b24fc6ee55d726061cda79ba3919e26f7 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/UIManager.cs @@ -0,0 +1,59 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace UnityAgent +{ + /// + /// Lightweight HUD manager using Unity UI + TextMeshPro. Exposes methods + /// for other systems to update score / health / messages. + /// + public class UIManager : MonoBehaviour + { + public static UIManager Instance { get; private set; } + + [Header("HUD")] + public TMP_Text scoreText; + public TMP_Text healthText; + public TMP_Text messageText; + public Image healthBarFill; + + [Header("Message")] + public float messageDuration = 2f; + + private float _messageTimer; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + SetMessage(string.Empty); + } + + private void Update() + { + if (_messageTimer > 0f) + { + _messageTimer -= Time.deltaTime; + if (_messageTimer <= 0f && messageText != null) messageText.text = string.Empty; + } + } + + public void SetScore(int score) + { + if (scoreText != null) scoreText.text = $"Score: {score}"; + } + + public void SetHealth(float current, float max) + { + if (healthText != null) healthText.text = $"HP: {Mathf.CeilToInt(current)} / {Mathf.CeilToInt(max)}"; + if (healthBarFill != null) healthBarFill.fillAmount = Mathf.Clamp01(current / max); + } + + public void SetMessage(string msg) + { + if (messageText != null) messageText.text = msg; + _messageTimer = string.IsNullOrEmpty(msg) ? 0f : messageDuration; + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/UIManager.cs.meta b/examples/open_world_city/Assets/Scripts/UIManager.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..6a85515d266bc4bd99589aa23674c17a0510115a --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/UIManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dcbdcd3761369cee5079e3e57f514884 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/UnityAgent.Scripts.asmdef b/examples/open_world_city/Assets/Scripts/UnityAgent.Scripts.asmdef new file mode 100644 index 0000000000000000000000000000000000000000..6019b912cc82618576e477adae9e6d59414d8ace --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/UnityAgent.Scripts.asmdef @@ -0,0 +1,11 @@ +{ + "name": "UnityAgent.Scripts", + "rootNamespace": "UnityAgent", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [] +} \ No newline at end of file diff --git a/examples/open_world_city/Assets/Scripts/UnityAgent.Scripts.asmdef.meta b/examples/open_world_city/Assets/Scripts/UnityAgent.Scripts.asmdef.meta new file mode 100644 index 0000000000000000000000000000000000000000..de801c683ea0a469063d6277a14b13c577459f29 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/UnityAgent.Scripts.asmdef.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51397beb9916b964e08c142ac4dd2500 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Assets/Scripts/VehicleController.cs b/examples/open_world_city/Assets/Scripts/VehicleController.cs new file mode 100644 index 0000000000000000000000000000000000000000..40fc57fbd39579503f7e9be93ef26ef876054fd6 --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/VehicleController.cs @@ -0,0 +1,136 @@ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Arcade car physics: motor torque, steering, braking and a simple + /// four-wheel raycast suspension. Place this on a root GameObject with + /// a Rigidbody and four wheel transforms. + /// + [RequireComponent(typeof(Rigidbody))] + public class VehicleController : MonoBehaviour + { + [System.Serializable] + public class Wheel + { + public Transform transform; + public bool isSteering; + public bool isPowered; + public bool isBraking; + [HideInInspector] public bool isGrounded; + [HideInInspector] public float suspensionOffset; + } + + [Header("Wheels")] + public Wheel[] wheels = new Wheel[4]; + + [Header("Engine")] + public float motorTorque = 1500f; + public float brakeTorque = 3000f; + public float maxSteerAngle = 30f; + public float maxSpeedKph = 180f; + + [Header("Suspension")] + public float suspensionRestLength = 0.5f; + public float suspensionSpring = 35000f; + public float suspensionDamper = 4500f; + public float wheelRadius = 0.35f; + public LayerMask wheelMask = ~0; + + [Header("Aerodynamics")] + public float downforce = 100f; + public float centerOfMassOffset = -0.5f; + + private Rigidbody _rb; + private float _throttle; + private float _steer; + private float _brake; + + private void Awake() + { + _rb = GetComponent(); + _rb.centerOfMass += Vector3.up * centerOfMassOffset; + _rb.interpolation = RigidbodyInterpolation.Interpolate; + } + + private void Update() + { + _throttle = Input.GetAxis("Vertical"); + _steer = Input.GetAxis("Horizontal"); + _brake = Input.GetKey(KeyCode.Space) ? 1f : 0f; + } + + private void FixedUpdate() + { + UpdateWheels(); + ApplySuspension(); + ApplyDrive(); + ApplyDownforce(); + } + + private void UpdateWheels() + { + foreach (var w in wheels) + { + if (w.transform == null) continue; + Vector3 origin = w.transform.position; + bool hit = Physics.Raycast( + origin, -transform.up, out RaycastHit r, + suspensionRestLength + wheelRadius, wheelMask, + QueryTriggerInteraction.Ignore); + w.isGrounded = hit; + w.suspensionOffset = hit ? r.distance - wheelRadius : suspensionRestLength; + } + } + + private void ApplySuspension() + { + foreach (var w in wheels) + { + if (!w.isGrounded) continue; + float force = (suspensionRestLength - w.suspensionOffset) * suspensionSpring; + Vector3 velocity = _rb.GetPointVelocity(w.transform.position); + float upwardVel = Vector3.Dot(transform.up, velocity); + force -= upwardVel * suspensionDamper; + _rb.AddForceAtPosition(transform.up * Mathf.Max(0f, force), w.transform.position); + } + } + + private void ApplyDrive() + { + float kph = _rb.velocity.magnitude * 3.6f; + float speedFactor = Mathf.Clamp01(1f - kph / maxSpeedKph); + + foreach (var w in wheels) + { + if (!w.isGrounded) continue; + if (w.isPowered) + { + Vector3 force = transform.forward * (_throttle * motorTorque * speedFactor); + _rb.AddForceAtPosition(force, w.transform.position, ForceMode.Force); + } + if (w.isBraking && _brake > 0.01f) + { + Vector3 brakeForce = -transform.forward * (_brake * brakeTorque); + _rb.AddForceAtPosition(brakeForce, w.transform.position, ForceMode.Force); + } + if (w.isSteering) + { + // Rotate the visual wheel and apply a small yaw torque. + w.transform.localRotation = Quaternion.Euler(0f, _steer * maxSteerAngle, 0f); + } + } + // Steering yaw torque. + if (Mathf.Abs(_steer) > 0.01f) + { + float turn = _steer * maxSteerAngle * 0.02f; + _rb.AddTorque(transform.up * turn * (_rb.velocity.magnitude + 1f), ForceMode.Force); + } + } + + private void ApplyDownforce() + { + _rb.AddForce(-transform.up * downforce * _rb.velocity.magnitude); + } + } +} diff --git a/examples/open_world_city/Assets/Scripts/VehicleController.cs.meta b/examples/open_world_city/Assets/Scripts/VehicleController.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..dd20301ad9e60351d2c39c77fd1f74d5f74b798f --- /dev/null +++ b/examples/open_world_city/Assets/Scripts/VehicleController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67179e275140460130f6cafb48739957 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/open_world_city/Packages/manifest.json b/examples/open_world_city/Packages/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..9ab3a503efee300dab44233ecd2c0378d846f81a --- /dev/null +++ b/examples/open_world_city/Packages/manifest.json @@ -0,0 +1,23 @@ +{ + "dependencies": { + "com.unity.ai.navigation": "1.1.5", + "com.unity.cinemachine": "2.9.7", + "com.unity.collab-proxy": "2.2.0", + "com.unity.ide.rider": "3.0.27", + "com.unity.ide.visualstudio": "2.0.22", + "com.unity.inputsystem": "1.7.0", + "com.unity.postprocessing": "3.4.0", + "com.unity.textmeshpro": "3.0.6", + "com.unity.ugui": "1.0.0", + "com.unity.modules.ai": "1.0.0", + "com.unity.modules.animation": "1.0.0", + "com.unity.modules.audio": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0", + "com.unity.modules.particlesystem": "1.0.0", + "com.unity.modules.physics": "1.0.0", + "com.unity.modules.physics2d": "1.0.0", + "com.unity.modules.terrain": "1.0.0", + "com.unity.modules.terrainphysics": "1.0.0", + "com.unity.modules.ui": "1.0.0" + } +} \ No newline at end of file diff --git a/examples/open_world_city/ProjectSettings/ProjectSettings.asset b/examples/open_world_city/ProjectSettings/ProjectSettings.asset new file mode 100644 index 0000000000000000000000000000000000000000..b05940348fb5f1981c58da17e4434e4ece93f5f5 --- /dev/null +++ b/examples/open_world_city/ProjectSettings/ProjectSettings.asset @@ -0,0 +1,10 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!129 &1 +PlayerSettings: + m_ObjectHideFlags: 0 + serializedVersion: 26 + companyName: UnityAgent + productName: open_world_city + defaultCursor: {instanceID: 0} + m_Version: 1 diff --git a/examples/open_world_city/ProjectSettings/ProjectVersion.txt b/examples/open_world_city/ProjectSettings/ProjectVersion.txt new file mode 100644 index 0000000000000000000000000000000000000000..59dd0f65f20feeb4fbbc71f39a95d48de630887c --- /dev/null +++ b/examples/open_world_city/ProjectSettings/ProjectVersion.txt @@ -0,0 +1 @@ +m_EditorVersion: 2022.3.20f1 diff --git a/examples/open_world_city/ProjectSettings/TagManager.asset b/examples/open_world_city/ProjectSettings/TagManager.asset new file mode 100644 index 0000000000000000000000000000000000000000..4b04e9b0069d8fbede37e2d6fcfdef2ea0b5a371 --- /dev/null +++ b/examples/open_world_city/ProjectSettings/TagManager.asset @@ -0,0 +1,15 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!78 &1 +TagManager: + serializedVersion: 2 + tags: + - Player + - Enemy + - Pickup + layers: + - Default + - TransparentFX + - Ignore Raycast + - Water + - UI diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..2e3576c1334e7697f27b3e69cd61b913eb945b6d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "unity-agent" +version = "0.1.0" +description = "A Python agent that generates complete Unity game projects (C#, .unity scenes, project config) you can open and play in any Unity 2022+ installation." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "Unity Agent" }] +keywords = ["unity", "unity3d", "game-development", "code-generation", "agent"] + +[project.scripts] +unity-agent = "unity_agent.__main__:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["unity_agent*"] + +[tool.setuptools.package-data] +"unity_agent.knowledge" = ["entries/*.md", "README.md"] diff --git a/unity_agent/__init__.py b/unity_agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d66e76e6acb1749389fc3bd053146b5bf467aba6 --- /dev/null +++ b/unity_agent/__init__.py @@ -0,0 +1,32 @@ +""" +Unity Agent - A Python package that generates complete Unity game projects. + +The agent uses a collection of code-generation tools to emit compilable C# +scripts, Unity scene (.unity) files, project configuration (.meta, ProjectSettings, +Packages/manifest.json) and an open-world example game. + +Quick start +----------- +>>> from unity_agent.config import Settings +>>> from unity_agent.transport.unity_transport import UnityTransport +>>> from unity_agent.tools.unity_tools import GenerateCompleteGameTool +>>> settings = Settings(output_dir="my_game") +>>> transport = UnityTransport(settings) +>>> GenerateCompleteGameTool(settings, transport).run(game_name="OpenWorldCity") +""" + +from unity_agent.config import Settings +from unity_agent.transport.unity_transport import UnityTransport +from unity_agent.tools.base import ToolBase, ToolRegistry +from unity_agent.orchestrator.prompts import SYSTEM_PROMPT, build_user_prompt + +__all__ = [ + "Settings", + "UnityTransport", + "ToolBase", + "ToolRegistry", + "SYSTEM_PROMPT", + "build_user_prompt", +] + +__version__ = "0.1.0" diff --git a/unity_agent/__main__.py b/unity_agent/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..4829163953070b3a33c29d46bbb501a3a077abfd --- /dev/null +++ b/unity_agent/__main__.py @@ -0,0 +1,103 @@ +"""Command-line entry point for the unity_agent package. + +Usage:: + + python -m unity_agent generate --game OpenWorldCity --preset open_world_city + python -m unity_agent list-tools + python -m unity_agent qa examples/open_world_city + +Run ``python -m unity_agent --help`` for the full list of commands. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from unity_agent.config import Settings +from unity_agent.transport.unity_transport import UnityTransport +from unity_agent.tools.base import get_registry +from unity_agent.tools.unity_tools import GenerateCompleteGameTool, SetupUnityProjectTool + + +def _cmd_list_tools(args: argparse.Namespace) -> int: + registry = get_registry() + for name in registry.all_names(): + cls = registry.get(name) + print(f" {name:35s} {cls.description}") + return 0 + + +def _cmd_generate(args: argparse.Namespace) -> int: + settings = Settings( + output_dir=args.output_dir, + product_name=args.game, + ) + transport = UnityTransport(settings) + tool = GenerateCompleteGameTool(settings, transport) + result = tool.run(game_name=args.game, preset=args.preset, project_name=args.game) + print(result.summary) + for f in result.files: + print(f" {f}") + print() + print(transport.summary()) + return 0 if result.ok else 1 + + +def _cmd_setup(args: argparse.Namespace) -> int: + settings = Settings(output_dir=args.output_dir, product_name=args.project_name) + transport = UnityTransport(settings) + tool = SetupUnityProjectTool(settings, transport) + result = tool.run(project_name=args.project_name) + print(result.summary) + for f in result.files: + print(f" {f}") + return 0 if result.ok else 1 + + +def _cmd_qa(args: argparse.Namespace) -> int: + from unity_agent.qa import ProjectQA + qa = ProjectQA(args.project_root) + report = qa.run() + print(report.summary()) + for issue in report.issues: + print(f" [{issue.severity.upper():7}] {issue.category:8} {issue.message} {issue.path}") + return 0 if report.passed else 1 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="unity_agent", description="Unity game agent CLI.") + sub = p.add_subparsers(dest="cmd", required=True) + + p_list = sub.add_parser("list-tools", help="List every registered tool.") + p_list.set_defaults(func=_cmd_list_tools) + + p_gen = sub.add_parser("generate", help="Generate a complete game.") + p_gen.add_argument("--game", default="OpenWorldCity", help="Game / project name.") + p_gen.add_argument("--preset", default="open_world_city", + choices=["open_world_city", "fps_arena", "minimal"]) + p_gen.add_argument("--output-dir", default="unity_output", + help="Where to write the project (default: unity_output).") + p_gen.set_defaults(func=_cmd_generate) + + p_setup = sub.add_parser("setup", help="Scaffold an empty Unity project.") + p_setup.add_argument("--project-name", default="GeneratedGame") + p_setup.add_argument("--output-dir", default="unity_output") + p_setup.set_defaults(func=_cmd_setup) + + p_qa = sub.add_parser("qa", help="Run QA checks on a generated project.") + p_qa.add_argument("project_root", help="Path to the project root.") + p_qa.set_defaults(func=_cmd_qa) + + return p + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/unity_agent/config.py b/unity_agent/config.py new file mode 100644 index 0000000000000000000000000000000000000000..3dfee13777b73a2cb3adbbf4aad08f5775c547b3 --- /dev/null +++ b/unity_agent/config.py @@ -0,0 +1,90 @@ +"""Global configuration for the Unity agent. + +The :class:`Settings` dataclass is the single source of truth for runtime +parameters such as the Unity installation path, optional LLM API keys, the +default output directory and project metadata used when scaffolding a new +Unity project. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Optional + + +DEFAULT_OUTPUT_DIR = "unity_output" +DEFAULT_UNITY_VERSION = "2022.3.20f1" + +# Default Unity project metadata. These values are written into +# ProjectSettings/ProjectSettings.asset so the editor knows what to display +# in the project wizard and on the splash screen. +DEFAULT_COMPANY_NAME = "UnityAgent" +DEFAULT_PRODUCT_NAME = "GeneratedGame" + + +@dataclass +class Settings: + """Runtime configuration shared by every tool. + + Parameters + ---------- + unity_path: + Optional path to the Unity executable (``Unity.exe`` / ``Unity``). + When provided, the agent can optionally invoke Unity in batch mode + to validate that generated projects compile. + unity_version: + Target Unity version written into ``ProjectSettings.asset``. + output_dir: + Root directory where generated projects are written. Can be either a + relative path (resolved against the current working directory) or an + absolute path. + company_name / product_name: + Metadata written into the project settings. + openai_api_key / anthropic_api_key / zai_api_key: + Optional LLM provider keys. Only required if the orchestrator is + wired up to call an LLM directly. + overwrite: + If ``True`` existing files are replaced; otherwise a ``FileExistsError`` + is raised when a destination file already exists. + """ + + unity_path: Optional[str] = None + unity_version: str = DEFAULT_UNITY_VERSION + output_dir: str = DEFAULT_OUTPUT_DIR + company_name: str = DEFAULT_COMPANY_NAME + product_name: str = DEFAULT_PRODUCT_NAME + + openai_api_key: Optional[str] = field(default_factory=lambda: os.environ.get("OPENAI_API_KEY")) + anthropic_api_key: Optional[str] = field(default_factory=lambda: os.environ.get("ANTHROPIC_API_KEY")) + zai_api_key: Optional[str] = field(default_factory=lambda: os.environ.get("ZAI_API_KEY")) + + overwrite: bool = True + auto_create_dirs: bool = True + + # ------------------------------------------------------------------ # + # Convenience helpers + # ------------------------------------------------------------------ # + @property + def output_path(self) -> Path: + p = Path(self.output_dir) + return p if p.is_absolute() else (Path.cwd() / p) + + def project_path(self, project_name: str) -> Path: + """Return the absolute path to a named project inside ``output_dir``.""" + return self.output_path / project_name + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_env(cls) -> "Settings": + """Build a :class:`Settings` instance populated from environment vars.""" + return cls( + unity_path=os.environ.get("UNITY_PATH"), + unity_version=os.environ.get("UNITY_VERSION", DEFAULT_UNITY_VERSION), + output_dir=os.environ.get("UNITY_AGENT_OUTPUT_DIR", DEFAULT_OUTPUT_DIR), + company_name=os.environ.get("UNITY_COMPANY_NAME", DEFAULT_COMPANY_NAME), + product_name=os.environ.get("UNITY_PRODUCT_NAME", DEFAULT_PRODUCT_NAME), + ) diff --git a/unity_agent/knowledge/README.md b/unity_agent/knowledge/README.md new file mode 100644 index 0000000000000000000000000000000000000000..633879afd867525bda3613567e7c07b3d98571f3 --- /dev/null +++ b/unity_agent/knowledge/README.md @@ -0,0 +1,33 @@ +# Unity Agent Knowledge Base + +This directory contains markdown knowledge entries the agent consults when +generating Unity projects. Each file is a short, focused reference on one +area of Unity development. + +## Entries + +| File | Topic | +|----------------------------|------------------------------------------------------------| +| `unity_fundamentals.md` | MonoBehaviour lifecycle, GameObjects, components, coroutines | +| `unity_physics.md` | Rigidbody, colliders, raycasting, physics vs frame step | +| `unity_rendering.md` | Materials, shaders, lighting, post-processing, LOD, fog | +| `unity_navigation.md` | NavMesh, NavMeshAgent, AI state machines, obstacles | +| `unity_input_system.md` | Legacy Input Manager, mouse-look, the new Input System | +| `unity_ui.md` | Canvas, HUD, health bars, TextMeshPro, layout | +| `unity_audio.md` | AudioSource, 3D audio, AudioMixer, AudioManager pattern | +| `open_world_design.md` | Procedural generation, LOD, streaming, day/night, save | + +## How the agent uses these + +The orchestrator can embed the contents of these files as context for the +LLM. The system prompt instructs the LLM to "consult the knowledge entries +for [topic] when generating code that touches [topic]." + +For example, when generating a third-person controller the LLM should pull +`unity_fundamentals.md` (for the lifecycle) and `unity_input_system.md` +(for the mouse-look recipe). + +## Adding entries + +Create a new `.md` file in this directory and update the table above. The +file name (without `.md`) becomes the entry's slug. diff --git a/unity_agent/knowledge/__init__.py b/unity_agent/knowledge/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bb1be8fce61604aabe03185b7899a81c750d54f5 --- /dev/null +++ b/unity_agent/knowledge/__init__.py @@ -0,0 +1,41 @@ +"""Knowledge base loader. + +Provides a simple API for loading the markdown knowledge entries that ship +with the package. Entries are stored as ``.md`` files in +``unity_agent/knowledge/entries/``. +""" + +from __future__ import annotations + +from importlib import resources +from pathlib import Path +from typing import Dict, List, Optional + + +def entries_dir() -> Path: + """Return the absolute path to the knowledge entries directory.""" + return Path(__file__).resolve().parent / "entries" + + +def list_entries() -> List[str]: + """Return a list of knowledge entry slugs (file names without .md).""" + d = entries_dir() + if not d.exists(): + return [] + return sorted(p.stem for p in d.glob("*.md")) + + +def load_entry(slug: str) -> Optional[str]: + """Return the markdown body of a knowledge entry, or None if missing.""" + p = entries_dir() / f"{slug}.md" + if not p.exists(): + return None + return p.read_text(encoding="utf-8") + + +def load_all() -> Dict[str, str]: + """Return a dict mapping slug -> markdown body for every entry.""" + return {slug: load_entry(slug) for slug in list_entries()} + + +__all__ = ["entries_dir", "list_entries", "load_entry", "load_all"] diff --git a/unity_agent/knowledge/entries/open_world_design.md b/unity_agent/knowledge/entries/open_world_design.md new file mode 100644 index 0000000000000000000000000000000000000000..20247426d989f6026d82c09f9a92a6cf1776a74e --- /dev/null +++ b/unity_agent/knowledge/entries/open_world_design.md @@ -0,0 +1,185 @@ +# Open-World Game Design + +Designing an open world is fundamentally a trade-off between **density of +content** and **memory/CPU budget**. This document collects the patterns the +agent uses when generating the open-world city example. + +## Procedural generation + +### Grid + jitter + +The simplest viable city layout is a grid of blocks where each block holds +one or more buildings whose position is jittered slightly to break the +symmetry: + +```csharp +for (int x = 0; x < gridX; x++) +for (int z = 0; z < gridZ; z++) +{ + Vector3 block = origin + new Vector3(x * blockSpacing, 0f, z * blockSpacing); + SpawnBuilding(block + Random.insideUnitSphere * 4f); +} +``` + +The generated `CityGenerator.cs` follows this pattern. For more variety, +swap the grid for: + +* **Voronoi street networks** (Delaunay triangulation, then prune). +* **L-system road growth** (recursive rewrite rules -- great for organic + cities). +* **Wave function collapse** -- tile-based, but expensive to author. + +### Building composition + +A building is rarely a single mesh. The generated `BuildingGenerator.cs` +composes three primitives: + +1. A wall cube with a Standard material (concrete/glass color). +2. A slightly smaller cube with an emissive **window material** so windows + glow at night. +3. A `BoxCollider` for collision. + +For more variety: + +* Vary the footprint (square vs rectangular). +* Add a flat roof cap with a different material. +* Add a parapet along the top. +* Randomly choose between glass-tower, brick-mid-rise, and concrete-low-rise + palettes. + +### Runtime vs baked + +The agent generates everything **at runtime in `Start()`** because: + +* The Unity project opens with an empty scene and the user immediately + sees content after pressing Play. +* No need to ship `.unity` scenes with hundreds of GameObjects. +* Easy to randomize: just change the seed. + +The downside is the first-frame hitch on large worlds. Mitigations: + +* Spread generation across frames using a coroutine that yields after + every N buildings. +* Use `Object.Instantiate` from a small set of prefabs instead of building + primitives from scratch each time. +* Use `Graphics.DrawMeshInstanced` for buildings once you do not need + per-building colliders. + +## Level of Detail (LOD) + +For an open world you must use LOD. Without it, the GPU processes every +window pane on every building no matter how far away it is. + +| LOD | Screen size | Poly count | +|-----|-------------|-----------------------------| +| 0 | 60%+ | Full mesh with windows | +| 1 | 25-60% | Simplified mesh, no windows | +| 2 | 5-25% | Single box per building | +| Culled | <5% | Not rendered at all | + +Add a `LODGroup` to each building and assign LOD meshes. For procedural +buildings you can build the LOD meshes in code by reducing the window +count and merging meshes. + +## Streaming and chunking + +For worlds larger than ~1km x 1km you must chunk: + +* Divide the world into a grid of chunks (e.g. 200m x 200m). +* Load chunks near the player; unload chunks far away. +* Use `Addressables` or `SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive)` + to load chunk scenes. + +The generated example does NOT chunk -- it builds everything in one shot, +which is fine for an 8x8 block city. Chunking becomes mandatory around +16x16 blocks. + +## Fog and far plane + +Always set `RenderSettings.fog` for open worlds. It hides: + +* Distant popping when LOD changes happen. +* The far clip plane edge. + +A linear fog from 100m to 800m is a good default for a city. + +## Day/Night cycle + +The generated `DayNightCycle.cs` rotates a directional light around the X +axis to simulate the sun: + +```csharp +float sunAngle = (_timeOfDay - 0.25f) * 360f; // 0.25 = noon +sun.transform.rotation = Quaternion.Euler(sunAngle, -30f, 0f); +``` + +* Keep the day length short (60-180 s) for demo purposes; 24 min real time + per day for actual gameplay. +* Lerp ambient light and skybox color between day/night presets so the + world does not suddenly flip black. +* Turn street lights on/off based on a sun-intensity threshold. + +## Player traversal + +Open worlds need fast traversal. Options: + +| Method | Speed (m/s) | Use case | +|-----------------------|-------------|---------------------------------------| +| Walking | 3-5 | On-foot exploration | +| Sprinting | 7-10 | Short bursts | +| Car (arcade) | 15-30 | The generated `VehicleController.cs` | +| Horse / mount | 8-12 | Fantasy open world | +| Fast travel | instant | Pre-unlocked locations | + +The generated project ships both a `PlayerController` (on-foot) and a +`VehicleController` (car) so the user can switch between them. + +## NPC traffic + +For an open-world city, AI traffic should be **lightweight**: + +* Use `NavMeshAgent` with a low `acceleration` for cars so they look + smooth, not jerky. +* Pool the agents -- never `Instantiate` a new car every time one drives + off-screen. +* Despawn agents when they are > 100m from the player. + +The generated `NPCController.cs` covers pedestrians; for cars you would +extend it with lane-following logic. + +## Save systems + +Open worlds need persistent saves. The generated `SaveSystem.cs` writes a +single JSON file to `Application.persistentDataPath`: + +```csharp +[Serializable] +public class GameSave +{ + public Vector3 playerPosition; + public int score; + public float timeOfDay; + public List completedQuests; +} +``` + +For larger worlds, split saves into: + +* Player profile (small, frequent). +* World state (large, infrequent -- chunk ownership, building destruction, + NPC schedules). + +## Common pitfalls + +* **Floating point precision:** Unity uses single-precision floats. Beyond + ~5km from the origin, physics and rendering jitter. Use a **floating + origin** that re-centres the world on the player every few hundred metres. +* **Shadow distance:** the default 150m shadow distance is too short for an + open world. Raise to 300-500m for desktop; lower for mobile. +* **Lighting rebuild:** make sure all your static city geometry is marked + **Navigation Static** AND **Lightmap Static** before baking. For runtime- + generated cities you cannot bake -- rely on realtime lighting and the + directional sun. +* **Collider spam:** 50 buildings x 6 faces each = 300 colliders. Mark + small decoration meshes as `isTrigger = false` and use a single + `BoxCollider` per building. diff --git a/unity_agent/knowledge/entries/unity_audio.md b/unity_agent/knowledge/entries/unity_audio.md new file mode 100644 index 0000000000000000000000000000000000000000..c42a413b2874be37b28fa726faef880926aed0ba --- /dev/null +++ b/unity_agent/knowledge/entries/unity_audio.md @@ -0,0 +1,120 @@ +# Unity Audio + +Unity plays audio through `AudioSource` components that reference an +`AudioClip` asset. A single `AudioListener` in the scene is required +(usually on the Main Camera). + +## AudioClip + +An `AudioClip` is the audio data. Supported formats: WAV (uncompressed, +fastest), MP3/Ogg Vorbis (compressed, smaller). Unity imports them and +lets you choose compression per-platform in the import settings. + +## AudioSource + +```csharp +var src = gameObject.AddComponent(); +src.clip = jumpClip; +src.volume = 0.7f; +src.pitch = 1.0f; +src.loop = false; +src.playOnAwake = false; +src.spatialBlend = 0.0f; // 0=2D, 1=3D +src.Play(); +``` + +Important properties: + +| Property | Meaning | +|-----------------|-----------------------------------------------------------| +| `clip` | The AudioClip to play | +| `volume` | 0..1 | +| `pitch` | Default 1; >1 = higher pitch, <1 = lower | +| `loop` | Repeat when the clip ends | +| `playOnAwake` | Play automatically when the AudioSource is enabled | +| `spatialBlend` | 0=fully 2D, 1=fully 3D (position-based) | +| `minDistance` | Within this distance the sound is at full volume (3D) | +| `maxDistance` | Beyond this distance the sound is inaudible (3D) | +| `rolloffMode` | Logarithmic, Linear, or custom curve (3D falloff) | + +## 3D audio + +For positional sounds (footsteps, gunfire, engines), set +`spatialBlend = 1.0f`. The volume then depends on the distance between the +AudioSource and the AudioListener. + +```csharp +src.spatialBlend = 1.0f; +src.rolloffMode = AudioRolloffMode.Logarithmic; +src.minDistance = 1f; +src.maxDistance = 50f; +``` + +> Default `maxDistance` is 500 -- way too large for most gameplay. Lower +> it to 30-50 for SFX. + +## One-shot SFX + +For short sound effects (pickups, gunshots, impacts) use `PlayOneShot`: + +```csharp +audioSource.PlayOneShot(clip, volumeScale); +``` + +`PlayOneShot` can layer multiple clips on the same AudioSource without +cutting each other off. + +## AudioMixer + +For volume control and grouping, create an **AudioMixer** asset +(`Assets > Create > Audio Mixer`). Expose parameters and control them at +runtime: + +```csharp +[SerializeField] private AudioMixer mixer; + +public void SetMasterVolume(float dB) => mixer.SetFloat("MasterVolume", dB); +public void SetMusicVolume(float dB) => mixer.SetFloat("MusicVolume", dB); +public void SetSfxVolume(float dB) => mixer.SetFloat("SfxVolume", dB); +``` + +Convert linear 0..1 to dB: + +```csharp +public static float LinearToDB(float linear) => + linear <= 0.0001f ? -80f : Mathf.Log10(linear) * 20f; +``` + +## The AudioManager pattern + +The generated `AudioManager.cs` is a singleton with two channels +(`sfxSource`, `musicSource`). It exposes: + +```csharp +AudioManager.Instance.PlaySfx(pickupClip, 1.0f); +AudioManager.Instance.PlayMusic(menuMusic); +AudioManager.Instance.StopMusic(); +AudioManager.Instance.ApplyVolumes(); +``` + +`DontDestroyOnLoad` keeps the manager alive across scene loads so music +does not restart. + +## AudioListener + +* Exactly one `AudioListener` per scene. The Main Camera has one by + default; remove it if you add another. +* The listener's transform defines "where the player's ears are" for 3D + audio panning. + +## Performance + +* Pool AudioSources for sounds that play many times per second (machine + guns, rain). Reusing one AudioSource and calling `PlayOneShot` is fine + up to ~20 simultaneous clips. +* Use compressed formats (Vorbis) for music; uncompressed (WAV) for + frequent short SFX. +* Set `AudioClip.preloadAudioData = true` for short clips and `false` for + music that should stream from disk. +* Lower `maxDistance` aggressively; large falloff radii cost CPU on 3D + sounds. diff --git a/unity_agent/knowledge/entries/unity_fundamentals.md b/unity_agent/knowledge/entries/unity_fundamentals.md new file mode 100644 index 0000000000000000000000000000000000000000..d829076cc12d65308eab1ad6ea3f112801ca8a64 --- /dev/null +++ b/unity_agent/knowledge/entries/unity_fundamentals.md @@ -0,0 +1,99 @@ +# Unity Fundamentals + +## MonoBehaviour Lifecycle + +Every gameplay script in Unity derives from `MonoBehaviour`. The engine calls +a fixed sequence of messages; understanding the order is the difference +between code that runs smoothly and code that stutters or races. + +``` +Awake() -> OnEnable() -> Start() + | + v +FixedUpdate() (physics, fixed timestep) --+ + | +Update() (input, gameplay) --+ + | +LateUpdate() (camera follow, post-sim) --+ + | +OnDisable() -> OnDestroy() +``` + +| Message | When it runs | Typical use | +|----------------|-----------------------------------------------|-----------------------------------------------| +| `Awake` | Once, when the script instance is loaded | Cache components, set up singletons | +| `OnEnable` | Every time the component is enabled | Subscribe to events | +| `Start` | Once, before the first Update | Cross-object references (other objects' Awake have run) | +| `FixedUpdate` | Fixed timestep (default 0.02 s = 50 Hz) | Rigidbody physics forces | +| `Update` | Every rendered frame | Input polling, gameplay logic | +| `LateUpdate` | After all Update calls have finished | Camera follow, post-processing | +| `OnDisable` | When the component is disabled | Unsubscribe from events | +| `OnDestroy` | When the GameObject is destroyed | Cleanup native resources | + +> **Gotcha:** `Camera.main` searches the scene by tag every call in 2022.3. +> Cache it in `Awake` if you call it more than once per frame. + +## GameObjects and Components + +A `GameObject` is an empty container. Behaviour is added through +**components**: + +```csharp +var go = new GameObject("Enemy"); +go.transform.position = Vector3.zero; +go.AddComponent(); +go.AddComponent(); +var ai = go.AddComponent(); +ai.player = GameObject.FindGameObjectWithTag("Player").transform; +``` + +* Every GameObject always has a `Transform` (or `RectTransform` under a + Canvas). You cannot remove it. +* Use `GetComponent()` / `GetComponentInChildren()` / + `GetComponentInParent()` to reach related components. Cache the result + in `Awake` instead of calling it every frame. +* `RequireComponent(typeof(T))` makes Unity auto-add the required component + when the script is added in the inspector. + +## The Component pattern in practice + +```csharp +[RequireComponent(typeof(Rigidbody))] +public class PlayerController : MonoBehaviour +{ + private Rigidbody _rb; + + private void Awake() + { + _rb = GetComponent(); // safe: RequireComponent guarantees it + } +} +``` + +## Coroutines + +Coroutines let you write asynchronous-looking code that runs across multiple +frames: + +```csharp +private IEnumerator RespawnAfterDelay(float seconds) +{ + yield return new WaitForSeconds(seconds); + transform.position = _spawnPoint; +} + +private void OnDeath() => StartCoroutine(RespawnAfterDelay(2f)); +``` + +* `yield return null;` waits one frame. +* `yield return new WaitForFixedUpdate();` waits until the next physics step. +* `yield return new WaitUntil(() => _isGrounded);` waits for a condition. + +## Inspector best practices + +* Use `[Header]`, `[Tooltip]`, `[Range]`, `[SerializeField]` to keep the + inspector tidy. +* Prefer `private` + `[SerializeField]` over `public` for fields that should + not be touched by other scripts but should be editable in the inspector. +* Use `[System.Serializable]` on structs/classes to make them expandable in + the inspector. diff --git a/unity_agent/knowledge/entries/unity_input_system.md b/unity_agent/knowledge/entries/unity_input_system.md new file mode 100644 index 0000000000000000000000000000000000000000..d94a95d52dfc7a91ffc0f0906f3a74d616e00d81 --- /dev/null +++ b/unity_agent/knowledge/entries/unity_input_system.md @@ -0,0 +1,154 @@ +# Unity Input System + +Unity offers two input systems: + +1. **Input Manager (legacy)** -- `Input.GetAxis`, `Input.GetKeyDown`. The + default in 2022.3 LTS. Used by every generated script in this project. +2. **Input System package** -- the new event-driven API. Available via the + `com.unity.inputsystem` package. + +## Input Manager (legacy) + +### Axes + +The legacy system reads virtual axes defined in +`Edit > Project Settings > Input Manager`. The defaults include: + +| Axis name | Default keys | +|---------------|---------------------------------| +| `Horizontal` | A/D, Arrow Left/Right | +| `Vertical` | W/S, Arrow Up/Down | +| `Mouse X` | Mouse delta X | +| `Mouse Y` | Mouse delta Y | +| `Jump` | Space | +| `Fire1` | Left Ctrl / Mouse 0 | +| `Fire2` | Right Alt / Mouse 1 | +| `Fire3` | Left Shift / Mouse 2 | + +### Reading axes + +```csharp +float x = Input.GetAxisRaw("Horizontal"); // -1, 0, +1 (no smoothing) +float y = Input.GetAxis("Vertical"); // smoothed, -1..+1 +Vector2 move = new Vector2(x, y); +move = Vector2.ClampMagnitude(move, 1f); // prevent diagonal speed boost +``` + +* Use `GetAxisRaw` for responsive arcade movement; `GetAxis` for smooth + camera-style axes. +* Always clamp the magnitude of a 2-axis vector -- otherwise diagonal + movement is ~1.41x faster than cardinal. + +### Keys and buttons + +```csharp +if (Input.GetKeyDown(KeyCode.Space)) Jump(); +if (Input.GetKey(KeyCode.LeftShift)) Sprint(); +if (Input.GetKeyUp(KeyCode.E)) Interact(); +if (Input.GetMouseButtonDown(0)) Fire(); +if (Input.GetMouseButton(0)) FireContinuous(); +``` + +| Method | Fires when | +|-----------------------|-------------------------------------------| +| `GetKey` | Every frame the key is held | +| `GetKeyDown` | Only on the frame the key was pressed | +| `GetKeyUp` | Only on the frame the key was released | +| `GetMouseButton` | Every frame the button is held | +| `GetMouseButtonDown` | Only on the frame the button went down | + +### Mouse position and delta + +```csharp +Vector3 mousePos = Input.mousePosition; // screen-space pixels +float dx = Input.GetAxis("Mouse X"); // smoothed delta +float rawDx = Input.GetAxisRaw("Mouse X"); +``` + +To lock the cursor for first-person look: + +```csharp +Cursor.lockState = CursorLockMode.Locked; // hide + center +Cursor.visible = false; +``` + +## Mouse-look recipe (first-person) + +```csharp +private float _pitch; + +private void Update() +{ + float mx = Input.GetAxisRaw("Mouse X") * sensitivity; + float my = Input.GetAxisRaw("Mouse Y") * sensitivity; + + transform.Rotate(Vector3.up * mx); + _pitch = Mathf.Clamp(_pitch - my, -85f, 85f); + cameraTransform.localRotation = Quaternion.Euler(_pitch, 0f, 0f); +} +``` + +## Mouse-look recipe (third-person orbit) + +For a third-person camera we accumulate a **yaw** and **pitch** and build a +rotation each frame: + +```csharp +_yaw += Input.GetAxis("Mouse X") * sensitivity; +_pitch -= Input.GetAxis("Mouse Y") * sensitivity; +_pitch = Mathf.Clamp(_pitch, -30f, 75f); +Quaternion rotation = Quaternion.Euler(_pitch, _yaw, 0f); +Vector3 position = pivot + rotation * new Vector3(0f, height, -distance); +``` + +The `ThirdPersonCamera.cs` generator follows exactly this pattern. + +## Input System package (new) + +When the project has the Input System package active, the legacy `Input` +class will throw or return zeros unless `Project Settings > Player > Active Input Handling = Both`. + +The new API is event-based: + +```csharp +[SerializeField] private InputActionAsset actions; +private InputAction _moveAction; + +private void OnEnable() +{ + _moveAction = actions.FindAction("Move"); + _moveAction.performed += OnMove; + _moveAction.Enable(); +} + +private void OnMove(InputAction.CallbackContext ctx) => _moveInput = ctx.ReadValue(); +private void OnDisable() => _moveAction.Disable(); +``` + +For the generated project we keep Active Input Handling on **Both** so the +legacy `Input` calls in the generated scripts keep working. + +## Touch and mobile + +For mobile, read `Input.touches` (legacy) or the new Input System's +`Pointer`/`Touchscreen`: + +```csharp +foreach (Touch t in Input.touches) +{ + if (t.phase == TouchPhase.Began) HandleTap(t.position); +} +``` + +The generated scripts are desktop-first; you would add a virtual joystick +overlay for mobile ports. + +## Common pitfalls + +* Reading `Input` in `FixedUpdate` -- the input system updates once per + frame, but `FixedUpdate` may run 0..N times per frame, so you will drop + or duplicate inputs. +* Using `Input.GetAxis` for physics forces -- the smoothed value can cause + floaty controls. Prefer `GetAxisRaw` and apply your own smoothing. +* Forgetting to lock the cursor in an FPS leads to the camera spinning + when the cursor leaves the game window. diff --git a/unity_agent/knowledge/entries/unity_navigation.md b/unity_agent/knowledge/entries/unity_navigation.md new file mode 100644 index 0000000000000000000000000000000000000000..531d89d6787f2ab726f8a4febeca4e1ac4d00b0a --- /dev/null +++ b/unity_agent/knowledge/entries/unity_navigation.md @@ -0,0 +1,105 @@ +# Unity Navigation (NavMesh, Pathfinding, AI) + +## Overview + +Unity's AI Navigation package (`com.unity.ai.navigation`) bakes a +**NavMesh** -- a simplified walkable surface -- and provides +`NavMeshAgent` to move characters along it. This is the standard solution +for AI NPCs in the generated project. + +## Baking a NavMesh + +1. Mark static colliders as **Navigation Static** (Window > AI > + Navigation > Object). +2. Bake the NavMesh (Window > AI > Navigation > Bake). +3. At runtime, every `NavMeshAgent` on the scene automatically walks on + the baked NavMesh. + +> For a procedurally generated city, baking at design time is impossible. +> Either: +> * Bake at runtime via `NavMeshSurface.BuildNavMesh()` (AI Navigation +> package), or +> * Use the simpler approach in `NPCController.cs`: pick random points via +> `NavMesh.SamplePosition` so the agent always lands on a valid walkable +> spot. + +## NavMeshAgent + +```csharp +[RequireComponent(typeof(NavMeshAgent))] +public class NPCController : MonoBehaviour +{ + private NavMeshAgent _agent; + + private void Awake() => _agent = GetComponent(); + + private void Start() + { + PickNewDestination(); + } + + private void PickNewDestination() + { + Vector3 random = transform.position + Random.insideUnitSphere * 20f; + if (NavMesh.SamplePosition(random, out NavMeshHit hit, 20f, NavMesh.AllAreas)) + _agent.SetDestination(hit.position); + } +} +``` + +Key properties: + +| Property | Meaning | +|---------------------|------------------------------------------------------------| +| `speed` | Max movement speed (m/s) | +| `angularSpeed` | Max turning speed (deg/s) | +| `acceleration` | How quickly the agent reaches `speed` | +| `stoppingDistance` | Distance at which the agent considers the destination reached | +| `autoBraking` | Whether to slow down near the destination | +| `isStopped` | Pause the agent without clearing the path | +| `remainingDistance` | Distance to the end of the current path | +| `pathPending` | True while a new path is still being computed | + +## State machines for AI + +A common pattern is a small finite state machine: + +``` +Idle --(timer)--> Wander --(player in range)--> Chase + ^ | | + | (lost sight) +---------------------------------+ + +--------------------+ +``` + +The generated `NPCController.cs` implements exactly this pattern with three +states (`Idle`, `Wander`, `Chase`) and switch-based dispatch in `Update()`. + +## Pathfinding details + +* `NavMeshAgent.SetDestination(target)` is async -- the path may not be + ready on the same frame. Check `!agent.pathPending` before reading + `remainingDistance`. +* `agent.pathStatus` tells you whether a path is `PathComplete`, + `PathPartial` or `PathInvalid`. +* `NavMesh.CalculatePath(source, target, mask, out path)` lets you compute a + path without an agent (useful for "can the NPC reach this point?" checks). + +## NavMeshObstacle + +For dynamic obstacles (a car parked across a road, a door that closes), add +a `NavMeshObstacle` component. Set `carve = true` to actually cut a hole in +the NavMesh; otherwise the obstacle only pushes agents around. + +## Off-mesh links + +When the walkable surface is disconnected (e.g. a gap between rooftops), +drop an `OffMeshLink` to let agents jump or teleport across. You can also +use them for ladders, climbing walls, and jumping down from ledges. + +## Performance + +* Keep the agent count under ~100 simultaneous active agents on desktop. +* For larger crowds use the **ECS/DOTS navigation** solutions or batch + pathfinding across frames. +* Set `agent.autoRepath = false` if you do not need the agent to follow + moving targets, and call `SetDestination` less often (e.g. every 0.5 s). diff --git a/unity_agent/knowledge/entries/unity_physics.md b/unity_agent/knowledge/entries/unity_physics.md new file mode 100644 index 0000000000000000000000000000000000000000..9a19724a84253e9232df48790f42042ab7fd537f --- /dev/null +++ b/unity_agent/knowledge/entries/unity_physics.md @@ -0,0 +1,117 @@ +# Unity Physics + +Unity ships two physics engines: **3D (NVIDIA PhysX)** and **2D (Box2D)**. +This document covers 3D physics, which is what the open-world city example +uses. + +## Rigidbody + +A `Rigidbody` puts a GameObject under physics control. There are two ways to +move a rigidbody: + +1. **Force-based** (preferred for realistic motion): + ```csharp + _rb.AddForce(Vector3.up * jumpForce, ForceMode.VelocityChange); + _rb.AddForceAtPosition(force, wheel.position, ForceMode.Force); + ``` + +2. **Direct velocity set** (preferred for arcade controllers): + ```csharp + Vector3 v = _rb.velocity; + v.x = desired.x; + v.z = desired.z; + _rb.velocity = v; + ``` + +| ForceMode | Mass dependent? | Use case | +|-----------------|-----------------|------------------------------------| +| `Force` | Yes | Continuous pushes (default) | +| `Acceleration` | No | Continuous push ignoring mass | +| `Impulse` | Yes | Instantaneous burst (explosion) | +| `VelocityChange`| No | Instantaneous burst (jump) | + +* Set `rb.freezeRotation = true` when you want to control rotation manually + (most third-person controllers). +* Use `rb.interpolation = RigidbodyInterpolation.Interpolate` to smooth out + stutter when the physics timestep (50 Hz) is slower than the render rate + (60+ Hz). +* Set `rb.collisionDetectionMode = CollisionDetectionMode.Continuous` for + fast-moving objects to prevent tunnelling. + +## Colliders + +* `BoxCollider`, `SphereCollider`, `CapsuleCollider`, `MeshCollider` are the + built-in shapes. +* `MeshCollider` should be **convex** unless the mesh is static (mark + `Convex` in the inspector) -- rigidbodies require convex colliders. +* Set `isTrigger = true` to receive `OnTriggerEnter/Stay/Exit` without + physically blocking movement. Use triggers for pickups, zones, audio + volumes. + +```csharp +private void OnTriggerEnter(Collider other) +{ + if (other.CompareTag("Player")) + Debug.Log("Player entered the zone"); +} +``` + +## Raycasting + +```csharp +// Single hit +if (Physics.Raycast(origin, direction, out RaycastHit hit, maxDistance, layerMask)) +{ + Debug.Log(hit.collider.name); +} + +// Sphere cast -- great for ground checks on characters +if (Physics.SphereCast(origin, radius, Vector3.down, out RaycastHit hit, distance, mask)) +{ + _isGrounded = true; +} + +// Overlap sphere -- explosions, area damage +Collider[] hits = Physics.OverlapSphere(center, radius); +foreach (var h in hits) +{ + var hp = h.GetComponentInParent(); + if (hp != null) hp.TakeDamage(damage); +} +``` + +> Use `QueryTriggerInteraction.Ignore` to skip trigger colliders in physics +> queries unless you explicitly want them. + +## Layer masks + +Layer masks let you filter physics queries and collisions: + +```csharp +public LayerMask groundMask = ~0; // everything by default + +if (Physics.Raycast(origin, dir, out _, distance, groundMask)) { ... } +``` + +You can also use `LayerMask.GetMask("Ground", "Default")` to convert names +at runtime, but exposing the mask as a serialised field is cleaner. + +## Physics step vs frame step + +| Step | Method | Frequency | Use it for | +|---------------|---------------|---------------------------|---------------------------| +| Physics | `FixedUpdate` | Fixed (50 Hz by default) | `rb.AddForce`, wheel torque | +| Frame | `Update` | Variable (frame rate) | `Input.GetAxis`, gameplay | +| Post-frame | `LateUpdate` | After all Updates | Camera follow | + +Never read `Input` in `FixedUpdate` -- the input system only updates once +per frame, so `FixedUpdate` may run zero or multiple times per frame. + +## Collision matrix gotchas + +* Two trigger colliders do not fire `OnTriggerEnter` unless **at least one** + of their GameObjects has a Rigidbody. +* A Rigidbody marked `IsKinematic` will not respond to forces but **will** + push other rigidbodies. +* Adjust the collision matrix in `Project Settings > Physics` to skip + collision between layers (e.g. Player vs. PlayerBullet). diff --git a/unity_agent/knowledge/entries/unity_rendering.md b/unity_agent/knowledge/entries/unity_rendering.md new file mode 100644 index 0000000000000000000000000000000000000000..af02885fbd1afbacc35ef369ae1d98a586cc3914 --- /dev/null +++ b/unity_agent/knowledge/entries/unity_rendering.md @@ -0,0 +1,108 @@ +# Unity Rendering + +## Materials and Shaders + +A **Material** is an asset that defines how a surface is rendered. It +references a **Shader** (the GPU program) and sets its properties (colors, +textures, numbers). + +Unity 2022 ships the following built-in shaders worth knowing: + +| Shader | Use case | +|-------------------|---------------------------------------------------------| +| `Standard` | The default PBR shader -- metal/smoothness workflow | +| `Universal Render Pipeline/Lit` | URP equivalent of Standard | +| `Unlit/Texture` | Pure textured quad, no lighting | +| `Sprites/Default` | 2D sprites | +| `UI/Default` | Canvas UI Image | + +The generated project uses the Built-in Render Pipeline (not URP) so that +the agent can rely on `Shader.Find("Standard")` without any package setup. + +### Procedural materials + +```csharp +var mat = new Material(Shader.Find("Standard")); +mat.name = "ProceduralWater"; +mat.color = new Color(0.2f, 0.6f, 0.8f, 0.7f); +mat.SetFloat("_Glossiness", 0.85f); +mat.SetFloat("_Metallic", 0.1f); +mat.EnableKeyword("_EMISSION"); +mat.SetColor("_EmissionColor", new Color(1f, 0.85f, 0.4f)); +mat.renderQueue = 3000; // transparent +GetComponent().sharedMaterial = mat; +``` + +* `_Color`, `_MainTex`, `_Glossiness`, `_Metallic`, `_EmissionColor` are the + Standard shader's main properties. +* Call `EnableKeyword("_EMISSION")` before setting `_EmissionColor`. +* For transparent materials set `_Mode` to 3 (Transparent) and configure + blend modes as in the `WaterMaterial.cs` generator. + +## Lighting + +* **Directional light** -- simulates the sun. Rotating it changes time of + day. The `DayNightCycle.cs` generator does exactly this. +* **Point light** -- radiates in all directions; cost is proportional to + the number of pixels it touches. Use sparingly on mobile. +* **Spot light** -- cone shape; useful for flashlights, street lamps. +* **Area light** -- only baked; good for indoor ceilings. + +**Realtime vs Baked:** static geometry should use **baked** lighting +(Lightmap) for performance; dynamic objects use realtime lighting. + +```csharp +Light l = gameObject.AddComponent(); +l.type = LightType.Point; +l.color = Color.white; +l.intensity = 1.5f; +l.range = 15f; +l.shadows = LightShadows.Soft; // softest, but most expensive +``` + +## Skybox and ambient + +* The skybox is set via `RenderSettings.skybox` or `Window > Rendering > Lighting > Environment`. +* `RenderSettings.ambientLight` controls flat ambient color when + `RenderSettings.ambientMode = AmbientMode.Flat`. +* The `DayNightCycle` generator lerps both `ambientLight` and + `Camera.main.backgroundColor` between day/night colors. + +## Post-processing (Built-in Pipeline) + +Post-processing is provided by the `com.unity.postprocessing` package. Add a +`Post-process Layer` to the Main Camera and a `Post-process Volume` to the +scene to drive effects such as Bloom, Color Grading, Depth of Field, +Vignette and Motion Blur. + +```csharp +// You usually configure post-processing in the inspector rather than code. +// Make sure the camera has a PostProcessLayer and a global PostProcessVolume. +``` + +## LOD (Level of Detail) + +Use `LODGroup` to swap meshes based on distance. Unity picks the LOD level +based on the screen-space size of the bounding box. + +``` +LOD 0 -- 60%+ screen size -- high-poly mesh +LOD 1 -- 30%-60% -- medium +LOD 2 -- 10%-30% -- low +Cull -- <10% -- not rendered +``` + +For procedural cities, prefer instancing (`Graphics.DrawMeshInstanced`) or +`LODGroup` over spawning 50+ full-detail buildings. + +## Fog + +```csharp +RenderSettings.fog = true; +RenderSettings.fogMode = FogMode.Linear; +RenderSettings.fogColor = new Color(0.5f, 0.5f, 0.5f, 1f); +RenderSettings.fogStartDistance = 50f; +RenderSettings.fogEndDistance = 300f; +``` + +Fog hides the far clip plane pop-in and is essential for large open worlds. diff --git a/unity_agent/knowledge/entries/unity_ui.md b/unity_agent/knowledge/entries/unity_ui.md new file mode 100644 index 0000000000000000000000000000000000000000..244a77db894fba9dfb7d476a0fe5d8db949ac6d8 --- /dev/null +++ b/unity_agent/knowledge/entries/unity_ui.md @@ -0,0 +1,113 @@ +# Unity UI + +Unity ships two UI systems: + +1. **uGUI (UnityEngine.UI)** -- the Canvas-based system that has been + standard since Unity 4.6. Used by the generated `UIManager.cs`. +2. **UI Toolkit** -- the new web-style (UXML/USS) system. Not used here. + +## Canvas + +The `Canvas` is the root of every UI hierarchy. There are three render +modes: + +| Mode | Use case | +|---------------------|-----------------------------------------------------------| +| `ScreenSpaceOverlay` | UI drawn on top of everything (default, cheapest) | +| `ScreenSpaceCamera` | UI positioned in front of a specific camera | +| `WorldSpace` | UI lives in the 3D world (health bars, dialogue bubbles) | + +Set the **Canvas Scaler** to **Scale With Screen Size** with a reference +resolution of 1920x1080 for desktop games. + +## Common components + +* `Image` -- a sprite or solid color rectangle. +* `Text` (uGUI) / `TMP_Text` (TextMeshPro) -- text. TMP produces sharper + glyphs and is preferred; the `UIManager.cs` generator uses `TMP_Text`. +* `Button` -- a clickable button with `onClick` events. +* `Slider` -- a draggable value selector. +* `Toggle` -- a checkbox. +* `ScrollRect` -- a scrolling viewport. +* `InputField` / `TMP_InputField` -- text entry. + +## Layout + +* `VerticalLayoutGroup` / `HorizontalLayoutGroup` / `GridLayoutGroup` -- + auto-arrange children. +* `LayoutElement` -- override min/preferred sizes. +* `ContentSizeFitter` -- resize a container to fit its children. + +## Anchoring + +Anchors determine how a UI element scales with its parent. Use the anchor +presets in the inspector to pin an element to a corner, edge, or center. + +* Top-left HUD elements: anchor top-left. +* Health bars that grow left-to-right: anchor top-left, pivot `(0, 0.5)`. +* Centered messages: anchor middle-center. + +## Health bar recipe + +```csharp +public Image healthBarFill; + +private void Update() +{ + healthBarFill.fillAmount = Mathf.Clamp01(currentHealth / maxHealth); +} +``` + +* Use an `Image` with `Image Type = Filled`, `Fill Method = Horizontal` to + get a depleting bar. +* Place a darker `Image` behind it as the background. +* For a world-space health bar, parent a Canvas to the enemy with + `Render Mode = World Space` and use a `LookAtCamera` script to keep it + facing the camera. + +## HUD wiring pattern + +The generated `UIManager.cs` is a singleton that other systems call: + +```csharp +UIManager.Instance.SetScore(PickupSystem.Instance.score); +UIManager.Instance.SetHealth(health.Current, health.Max); +UIManager.Instance.SetMessage("Level Complete!"); +``` + +The HUD GameObjects (TMP_Text, Image) are assigned in the inspector. The +GameManager bootstrapper in the example game wires these up at runtime if +they are missing. + +## Buttons and events + +```csharp +[SerializeField] private Button restartButton; + +private void OnEnable() => restartButton.onClick.AddListener(Restart); +private void OnDisable() => restartButton.onClick.RemoveListener(Restart); + +private void Restart() => SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); +``` + +* Always unsubscribe from events in `OnDisable` to avoid leaks. +* `UnityEvent` fields (e.g. `Button.onClick`) let designers wire callbacks + in the inspector without code changes. + +## TMP essentials + +* Use `TMP_Text` instead of `Text` for any text the player reads -- the + rendering quality is dramatically better. +* Generate a TMP Font Asset via `Window > TextMeshPro > Font Asset Creator`. +* `TMP_Dropdown`, `TMP_InputField`, and `TMP_Text` are drop-in replacements + for the uGUI equivalents. + +## Performance + +* Mark static UI elements as `Static` in the Canvas to batch them. +* Split a complex UI into multiple Canvases so a single text change does + not rebuild the entire hierarchy. +* Avoid `LayoutGroup` chains deeper than 3 levels on mobile -- they are + expensive to rebuild. +* Pool item tooltips, inventory slots and damage popups instead of + instantiating/destroying them every frame. diff --git a/unity_agent/orchestrator/__init__.py b/unity_agent/orchestrator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b74ea573d2acdb07eef84ae82926e6d6fd9100ed --- /dev/null +++ b/unity_agent/orchestrator/__init__.py @@ -0,0 +1,21 @@ +"""Orchestrator for the Unity agent. + +The orchestrator is the layer that an LLM-driven loop (or a human) uses to +pick which tool to run next. It exposes: + +* :data:`SYSTEM_PROMPT` -- a long, opinionated system prompt that teaches + the agent to think like a Unity developer and to call the registered tools + in the correct order. +* :func:`build_user_prompt` -- helper that turns a natural-language game + description into a structured user prompt. +* :func:`tool_catalog` -- returns a compact, LLM-friendly list of every + registered tool with its argument schema. +""" + +from unity_agent.orchestrator.prompts import ( + SYSTEM_PROMPT, + build_user_prompt, + tool_catalog, +) + +__all__ = ["SYSTEM_PROMPT", "build_user_prompt", "tool_catalog"] diff --git a/unity_agent/orchestrator/prompts.py b/unity_agent/orchestrator/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..4d8f43e9a5e7d8ec8e53d16508cf4163cac3ef2e --- /dev/null +++ b/unity_agent/orchestrator/prompts.py @@ -0,0 +1,197 @@ +"""System prompt and prompt-building helpers for the Unity agent. + +The :data:`SYSTEM_PROMPT` constant is the heart of the agent's behaviour. +It is intentionally long and prescriptive: it tells the LLM exactly what +Unity is, what the tool layer can do, what order to call tools in, and what +"good" output looks like. +""" + +from __future__ import annotations + +import json +from typing import Iterable, List, Optional + +from unity_agent.tools.base import get_registry + + +SYSTEM_PROMPT = """\ +You are Unity Agent, an expert Unity game developer that writes complete, +compilable Unity projects in C# and Unity YAML. You operate by calling a +small set of file-generation tools. Every tool call writes real files to a +Unity project on disk; the end user opens that project in the Unity Editor +(Unity 2022.3 LTS or newer) and presses Play. + +============================================================ +1. YOUR MINDSET +============================================================ +* You think like a senior Unity gameplay engineer: you know the + MonoBehaviour lifecycle (Awake, Start, OnEnable, Update, FixedUpdate, + LateUpdate, OnDestroy), you understand the difference between the + physics step (FixedUpdate) and the input/render step (Update), and you + never put input reads in FixedUpdate. +* You write idiomatic, well-formatted C# 9 (Unity 2022 supports C# 9 by + default). You prefer `[Header]`/`[SerializeField]` over public fields + when exposing values to the inspector; you use `[RequireComponent]` to + make dependencies explicit. +* You know Unity's API surface: Rigidbody, CharacterController, NavMeshAgent, + Physics.SphereCast/Raycast/OverlapSphere, LayerMask, Quaternion math, + Cinemachine, TextMeshPro, the Post Processing stack, Terrain, AudioSource. +* You structure Unity projects exactly the way the editor expects: + Assets/Scripts/*.cs, Assets/Scenes/*.unity, Packages/manifest.json, + ProjectSettings/*.asset, plus .meta files for every asset. +* You ALWAYS emit a .meta file alongside a .cs or .unity file (the + transport layer does this automatically for write_csharp_script and + write_scene_file). +* You write code that compiles on the first try. That means: + - matching every namespace you open with a `using` directive + - closing every brace + - never referencing a type that is not in UnityEngine, UnityEngine.AI, + UnityEngine.UI, TMPro, or a class the agent has already generated + - using `CompareTag` instead of `== "Player"` where possible + - guarding `Camera.main` for null because it can be null in 2022+ + +============================================================ +2. THE TOOL LAYER +============================================================ +The available tools are listed by the runtime as JSON schemas. In summary +they are: + + setup_unity_project(project_name, include_asmdef?, dependencies?) + -> Creates Assets/, Packages/manifest.json, ProjectSettings/*, .asmdef. + Always call this FIRST when starting a new project. + + create_player_controller(project_name) + create_vehicle_controller(project_name) + create_procedural_city(project_name) # also writes BuildingGenerator.cs + create_third_person_camera(project_name) + create_fps_controller(project_name) + create_day_night_cycle(project_name) + create_ai_npc(project_name) + create_pickup_system(project_name) + create_health_system(project_name) + create_weapon_system(project_name) + create_audio_manager(project_name) + create_ui_manager(project_name) + create_terrain_generator(project_name) + create_water_shader(project_name) + create_particle_effects(project_name) + create_save_system(project_name) + create_inventory_system(project_name) + create_quest_system(project_name) + create_building_generator(project_name) + create_road_network(project_name) + -> Each writes one complete C# MonoBehaviour to Assets/Scripts/. + + write_csharp_script(project_name, filename, code) + -> Write any C# script. Use this for game-specific code (GameManager, + custom enemies, etc.) that is not covered by a dedicated tool. + + write_scene_file(project_name, scene_name?, yaml?) + -> Write a .unity scene file. Defaults to a minimal MainScene with a + Camera and a Directional Light. + + generate_complete_game(game_name?, preset?, project_name?) + -> One-shot generator. preset can be 'open_world_city', 'fps_arena', + or 'minimal'. Use this when the user asks for "a complete game". + +============================================================ +3. RECOMMENDED WORKFLOW +============================================================ +For a typical "build me X" request: + +1. If the project does not exist yet, call setup_unity_project. +2. Call the relevant component tools (player controller, camera, etc.). +3. If the request needs game-specific logic (an enemy spawner, a + cutscene trigger, a custom UI), use write_csharp_script with hand-written + C# that references the components you have already generated. +4. Always finish by writing a scene file with write_scene_file so the + project has a playable entry point. +5. If the user just wants a complete game with no custom logic, prefer + generate_complete_game over calling every tool individually. + +============================================================ +4. OUTPUT EXPECTATIONS +============================================================ +* C# code MUST compile in Unity 2022.3 LTS without external packages + beyond those declared in the default manifest.json (Cinemachine, TMP, + Post Processing, Input System, AI Navigation). +* All code lives under the namespace `UnityAgent` and matches the asmdef + `UnityAgent.Scripts` that setup_unity_project emits. +* Prefer procedural content (generated at runtime in Start()) over + requiring the user to drag assets into the inspector. The GameManager + bootstrapper pattern is the canonical way to wire systems together. +* Every generated MonoBehaviour should be self-contained: if it needs a + reference, it should try to find it (FindObjectOfType, Camera.main, + GameObject.FindGameObjectWithTag) before giving up. + +============================================================ +5. COMMUNICATION +============================================================ +* Be concise. State which tool you are calling and why, then call it. +* After the final tool call, give the user a one-paragraph summary of + what was generated and how to open it in Unity Hub. +* If a request is ambiguous, ask ONE clarifying question, then proceed + with the most reasonable interpretation. +""" + + +def tool_catalog() -> List[dict]: + """Return a list of {name, description, input_schema} for every tool.""" + registry = get_registry() + return [ + { + "name": cls.name, + "description": cls.description, + "input_schema": cls.input_schema, + } + for cls in (registry._tools.values()) # noqa: SLF001 + ] + + +def build_user_prompt( + request: str, + *, + project_name: Optional[str] = None, + preset: Optional[str] = None, + include_catalog: bool = True, +) -> str: + """Build a user prompt for the LLM. + + Parameters + ---------- + request: + The end user's natural-language game request. + project_name: + Optional project name hint. If omitted the agent should pick one. + preset: + Optional game preset hint ('open_world_city', 'fps_arena', 'minimal'). + include_catalog: + If ``True``, embed the full tool catalog as JSON in the prompt. + """ + parts: list[str] = [] + parts.append("USER REQUEST:") + parts.append(request.strip() if request else "(no request provided)") + parts.append("") + + hints: list[str] = [] + if project_name: + hints.append(f"- Suggested project name: {project_name}") + if preset: + hints.append(f"- Suggested game preset: {preset}") + if hints: + parts.append("HINTS:") + parts.extend(hints) + parts.append("") + + if include_catalog: + parts.append("AVAILABLE TOOLS (JSON):") + parts.append(json.dumps(tool_catalog(), indent=2)) + parts.append("") + + parts.append( + "Decide which tools to call, in order, to satisfy the request. " + "Prefer generate_complete_game when the user asks for 'a complete " + "game' or 'an open-world game'. Reply with a short plan, then call " + "the tools." + ) + return "\n".join(parts) diff --git a/unity_agent/qa/__init__.py b/unity_agent/qa/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0b93bb3fb3df14ed1393486f812c0ed0f98e4ad8 --- /dev/null +++ b/unity_agent/qa/__init__.py @@ -0,0 +1,255 @@ +"""Quality assurance for generated Unity projects. + +The :class:`ProjectQA` class runs a series of static checks against a +generated project directory and reports a list of issues. The checks are +intentionally conservative -- they look for problems that will *definitely* +prevent the project from compiling in Unity, not style nitpicks. + +Run from code:: + + from unity_agent.qa import ProjectQA + qa = ProjectQA("/path/to/GeneratedGame") + report = qa.run() + print(report.summary()) + for issue in report.issues: + print(issue) + +Or from the command line:: + + python -m unity_agent.qa /path/to/GeneratedGame +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import List + + +# --------------------------------------------------------------------------- # +# Data types +# --------------------------------------------------------------------------- # +@dataclass +class Issue: + severity: str # "error" | "warning" | "info" + category: str + message: str + path: str = "" + + +@dataclass +class QAReport: + project_root: str + issues: List[Issue] = field(default_factory=list) + + @property + def errors(self) -> List[Issue]: + return [i for i in self.issues if i.severity == "error"] + + @property + def warnings(self) -> List[Issue]: + return [i for i in self.issues if i.severity == "warning"] + + @property + def passed(self) -> bool: + return len(self.errors) == 0 + + def summary(self) -> str: + return ( + f"QA report for {self.project_root}\n" + f" errors: {len(self.errors)}\n" + f" warnings: {len(self.warnings)}\n" + f" total: {len(self.issues)}\n" + f" result: {'PASS' if self.passed else 'FAIL'}" + ) + + +# --------------------------------------------------------------------------- # +# QA runner +# --------------------------------------------------------------------------- # +class ProjectQA: + """Run static checks against a generated Unity project.""" + + # Required project structure. + REQUIRED_DIRS = ("Assets", "Assets/Scripts", "Packages", "ProjectSettings") + REQUIRED_FILES = ( + "Packages/manifest.json", + "ProjectSettings/ProjectVersion.txt", + "ProjectSettings/ProjectSettings.asset", + ) + + def __init__(self, project_root: str | Path) -> None: + self.root = Path(project_root) + + # ------------------------------------------------------------------ # + # Public API + # ------------------------------------------------------------------ # + def run(self) -> QAReport: + report = QAReport(project_root=str(self.root)) + if not self.root.exists(): + report.issues.append(Issue( + "error", "structure", + f"Project root does not exist: {self.root}", + )) + return report + + self._check_structure(report) + self._check_required_files(report) + self._check_csharp_files(report) + self._check_scene_files(report) + self._check_manifest(report) + return report + + # ------------------------------------------------------------------ # + # Individual checks + # ------------------------------------------------------------------ # + def _check_structure(self, report: QAReport) -> None: + for d in self.REQUIRED_DIRS: + p = self.root / d + if not p.is_dir(): + report.issues.append(Issue( + "error", "structure", + f"Missing required directory: {d}", + str(p), + )) + + def _check_required_files(self, report: QAReport) -> None: + for f in self.REQUIRED_FILES: + p = self.root / f + if not p.is_file(): + report.issues.append(Issue( + "error", "structure", + f"Missing required file: {f}", + str(p), + )) + + def _check_csharp_files(self, report: QAReport) -> None: + scripts = list((self.root / "Assets/Scripts").rglob("*.cs")) + if not scripts: + report.issues.append(Issue( + "warning", "scripts", + "No .cs files found in Assets/Scripts.", + )) + return + + for script in scripts: + try: + src = script.read_text(encoding="utf-8") + except Exception as e: + report.issues.append(Issue( + "error", "io", + f"Could not read {script.name}: {e}", + str(script), + )) + continue + + self._check_braces(report, script, src) + self._check_namespace_and_class(report, script, src) + self._check_meta(report, script) + + def _check_braces(self, report: QAReport, path: Path, src: str) -> None: + opens = src.count("{") + closes = src.count("}") + if opens != closes: + report.issues.append(Issue( + "error", "syntax", + f"Unbalanced braces: {opens} '{{' vs {closes} '}}' in {path.name}", + str(path), + )) + + def _check_namespace_and_class(self, report: QAReport, path: Path, src: str) -> None: + if "namespace " not in src and "class " in src: + report.issues.append(Issue( + "warning", "syntax", + f"{path.name} declares a class but no namespace.", + str(path), + )) + # Every C# file should have at least one type declaration. + if not re.search(r"\b(class|struct|interface|enum)\s+\w+", src): + report.issues.append(Issue( + "warning", "syntax", + f"{path.name} does not declare any type.", + str(path), + )) + + def _check_meta(self, report: QAReport, path: Path) -> None: + meta = path.with_suffix(path.suffix + ".meta") + if not meta.exists(): + report.issues.append(Issue( + "warning", "meta", + f"Missing .meta file for {path.name}", + str(meta), + )) + + def _check_scene_files(self, report: QAReport) -> None: + scenes_dir = self.root / "Assets/Scenes" + if not scenes_dir.is_dir(): + return + scenes = list(scenes_dir.glob("*.unity")) + if not scenes: + report.issues.append(Issue( + "warning", "scene", + "No .unity scene files found in Assets/Scenes.", + )) + return + for scene in scenes: + try: + body = scene.read_text(encoding="utf-8") + except Exception as e: + report.issues.append(Issue( + "error", "io", + f"Could not read scene {scene.name}: {e}", + str(scene), + )) + continue + if not body.startswith("%YAML"): + report.issues.append(Issue( + "error", "scene", + f"{scene.name} does not start with '%YAML' header.", + str(scene), + )) + + def _check_manifest(self, report: QAReport) -> None: + manifest = self.root / "Packages/manifest.json" + if not manifest.is_file(): + return + try: + import json + data = json.loads(manifest.read_text(encoding="utf-8")) + except Exception as e: + report.issues.append(Issue( + "error", "manifest", + f"Packages/manifest.json is not valid JSON: {e}", + str(manifest), + )) + return + deps = data.get("dependencies") + if not isinstance(deps, dict): + report.issues.append(Issue( + "error", "manifest", + "Packages/manifest.json has no 'dependencies' object.", + str(manifest), + )) + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run QA on a generated Unity project.") + parser.add_argument("project_root", help="Path to the generated Unity project root.") + args = parser.parse_args(argv) + + qa = ProjectQA(args.project_root) + report = qa.run() + print(report.summary()) + for issue in report.issues: + print(f" [{issue.severity.upper():7}] {issue.category:8} {issue.message} {issue.path}") + return 0 if report.passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/unity_agent/tests.py b/unity_agent/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..a75cf52da85bad4e1f8bded0a639e64a7244abdb --- /dev/null +++ b/unity_agent/tests.py @@ -0,0 +1,118 @@ +"""Self-test suite for the unity_agent package. + +Run with:: + + python -m unity_agent.tests + +The tests do not require Unity to be installed; they only verify that the +Python layer produces files with the expected names and that the generated +C# code passes the brace-balance and namespace checks. +""" + +from __future__ import annotations + +import shutil +import sys +import tempfile +from pathlib import Path + +# Make the package importable when run as a script. +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT.parent)) + +from unity_agent.config import Settings +from unity_agent.transport.unity_transport import UnityTransport +from unity_agent.tools.base import get_registry +import unity_agent.tools.unity_tools # noqa: F401 - registers tools +from unity_agent.tools.unity_tools import ( + GenerateCompleteGameTool, + SetupUnityProjectTool, + WriteCSharpScriptTool, + WriteSceneFileTool, +) +from unity_agent.qa import ProjectQA + + +def _count_braces(src: str) -> bool: + return src.count("{") == src.count("}") + + +def test_tool_registry() -> None: + names = get_registry().all_names() + assert "generate_complete_game" in names + assert "setup_unity_project" in names + assert "write_csharp_script" in names + # The user spec asked for 20+ tools. + assert len(names) >= 20, f"Expected >= 20 tools, got {len(names)}" + print(f" [ok] tool registry has {len(names)} tools") + + +def test_setup_project(tmp: Path) -> None: + settings = Settings(output_dir=str(tmp), product_name="TestProj") + transport = UnityTransport(settings) + r = SetupUnityProjectTool(settings, transport).run(project_name="TestProj") + assert r.ok, r.error + for f in ("Assets", "Packages/manifest.json", "ProjectSettings/ProjectSettings.asset"): + assert (tmp / "TestProj" / f).exists(), f"missing {f}" + print(" [ok] setup_unity_project writes the full scaffold") + + +def test_csharp_braces(tmp: Path) -> None: + settings = Settings(output_dir=str(tmp), product_name="BracesProj") + transport = UnityTransport(settings) + transport.open_project("BracesProj") + code = "namespace X { public class Y { void Z() {} } }" + r = WriteCSharpScriptTool(settings, transport).run( + filename="Foo.cs", code=code, project_name="BracesProj") + assert r.ok + src = (tmp / "BracesProj/Assets/Scripts/Foo.cs").read_text() + assert _count_braces(src) + print(" [ok] write_csharp_script produces balanced C#") + + +def test_complete_game(tmp: Path) -> None: + settings = Settings(output_dir=str(tmp), product_name="GenGame") + transport = UnityTransport(settings) + r = GenerateCompleteGameTool(settings, transport).run( + game_name="GenGame", preset="open_world_city") + assert r.ok, r.error + # Expect a C# file per major system. + scripts = list((tmp / "GenGame/Assets/Scripts").glob("*.cs")) + assert len(scripts) >= 12, f"only {len(scripts)} scripts generated" + # Every C# file must have balanced braces. + for s in scripts: + assert _count_braces(s.read_text(encoding="utf-8")), f"unbalanced braces in {s.name}" + # Scene file present. + assert (tmp / "GenGame/Assets/Scenes/MainScene.unity").exists() + print(f" [ok] generate_complete_game produced {len(scripts)} scripts + scene") + + +def test_qa(tmp: Path) -> None: + settings = Settings(output_dir=str(tmp), product_name="QAProj") + transport = UnityTransport(settings) + GenerateCompleteGameTool(settings, transport).run( + game_name="QAProj", preset="open_world_city") + report = ProjectQA(tmp / "QAProj").run() + assert report.passed, "QA must pass on a freshly generated project" + print(f" [ok] QA passes on generated project ({len(report.issues)} issues)") + + +def main() -> int: + tmp = Path(tempfile.mkdtemp(prefix="unity_agent_test_")) + try: + test_tool_registry() + test_setup_project(tmp) + test_csharp_braces(tmp) + test_complete_game(tmp) + test_qa(tmp) + except AssertionError as e: + print(f"\nFAIL: {e}") + return 1 + finally: + shutil.rmtree(tmp, ignore_errors=True) + print("\nAll tests passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/unity_agent/tools/__init__.py b/unity_agent/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5c85a348773a18d5c3d2ecf43d26f9a89a961c2d --- /dev/null +++ b/unity_agent/tools/__init__.py @@ -0,0 +1,68 @@ +"""Unity agent tool collection. + +Each tool produces one piece of a Unity project: a C# script, a scene file, a +project configuration file, or a complete game assembled from many other +tools. All tools share the same base class and are registered in a global +:class:`~unity_agent.tools.base.ToolRegistry` so the orchestrator (and end +users) can discover and invoke them by name. +""" + +from unity_agent.tools.base import ToolBase, ToolRegistry, get_registry +from unity_agent.tools.unity_tools import ( + CreatePlayerControllerTool, + CreateVehicleControllerTool, + CreateProceduralCityTool, + CreateThirdPersonCameraTool, + CreateFPSControllerTool, + CreateDayNightCycleTool, + CreateAiNpcTool, + CreatePickupSystemTool, + CreateHealthSystemTool, + CreateWeaponSystemTool, + CreateAudioManagerTool, + CreateUIManagerTool, + CreateTerrainGeneratorTool, + CreateWaterShaderTool, + CreateParticleEffectsTool, + CreateSaveSystemTool, + CreateInventorySystemTool, + CreateQuestSystemTool, + CreateBuildingGeneratorTool, + CreateRoadNetworkTool, + SetupUnityProjectTool, + WriteCSharpScriptTool, + WriteSceneFileTool, + GenerateCompleteGameTool, + ALL_TOOLS, +) + +__all__ = [ + "ToolBase", + "ToolRegistry", + "get_registry", + "CreatePlayerControllerTool", + "CreateVehicleControllerTool", + "CreateProceduralCityTool", + "CreateThirdPersonCameraTool", + "CreateFPSControllerTool", + "CreateDayNightCycleTool", + "CreateAiNpcTool", + "CreatePickupSystemTool", + "CreateHealthSystemTool", + "CreateWeaponSystemTool", + "CreateAudioManagerTool", + "CreateUIManagerTool", + "CreateTerrainGeneratorTool", + "CreateWaterShaderTool", + "CreateParticleEffectsTool", + "CreateSaveSystemTool", + "CreateInventorySystemTool", + "CreateQuestSystemTool", + "CreateBuildingGeneratorTool", + "CreateRoadNetworkTool", + "SetupUnityProjectTool", + "WriteCSharpScriptTool", + "WriteSceneFileTool", + "GenerateCompleteGameTool", + "ALL_TOOLS", +] diff --git a/unity_agent/tools/base.py b/unity_agent/tools/base.py new file mode 100644 index 0000000000000000000000000000000000000000..31cb204fe2dba558623406d3b0135fd75bdc50a5 --- /dev/null +++ b/unity_agent/tools/base.py @@ -0,0 +1,175 @@ +"""Tool base class and registry. + +Following the same pattern used by the codebase this agent is modelled on, +every tool is a small subclass of :class:`ToolBase` that: + +* declares a unique ``name`` and ``description`` (used by the orchestrator / + LLM to decide which tool to call) +* exposes a JSON-schema-ish ``input_schema`` describing its arguments +* implements :meth:`run` which performs the actual work and returns a + dictionary result + +Tools register themselves automatically on import via the +``@register_tool`` decorator (or by being added to ``ALL_TOOLS`` in +``unity_tools.py``). The :class:`ToolRegistry` keeps a single global map of +``name -> tool_class``. +""" + +from __future__ import annotations + +import inspect +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Type + +from unity_agent.config import Settings +from unity_agent.transport.unity_transport import UnityTransport + +log = logging.getLogger("unity_agent.tools") + + +# --------------------------------------------------------------------------- # +# Tool base +# --------------------------------------------------------------------------- # +@dataclass +class ToolResult: + """Structured return value from :meth:`ToolBase.run`.""" + + ok: bool + tool: str + summary: str + files: List[str] = field(default_factory=list) + data: Dict[str, Any] = field(default_factory=dict) + error: Optional[str] = None + + def to_dict(self) -> dict: + return { + "ok": self.ok, + "tool": self.tool, + "summary": self.summary, + "files": self.files, + "data": self.data, + "error": self.error, + } + + +class ToolBase: + """Base class for every Unity agent tool. + + Subclasses MUST override: + + * ``name`` (str) -- unique tool name (snake_case) + * ``description`` (str) -- natural language description for the LLM + * ``input_schema`` (dict)-- JSON-schema describing parameters + * :meth:`run` -- the actual implementation + """ + + name: str = "base_tool" + description: str = "Override me." + input_schema: Dict[str, Any] = {"type": "object", "properties": {}} + + def __init__(self, settings: Settings, transport: UnityTransport) -> None: + self.settings = settings + self.transport = transport + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _ensure_project(self, project_name: Optional[str] = None) -> None: + """Open a project if one is not already open.""" + if project_name and ( + self.transport.project_name is None + or self.transport.project_name != project_name + ): + self.transport.open_project(project_name) + # If no project is open at all, default to settings.product_name. + if self.transport._project_root is None: + self.transport.open_project(self.settings.product_name) + + def result( + self, + ok: bool, + summary: str, + files: Optional[List[str]] = None, + data: Optional[Dict[str, Any]] = None, + error: Optional[str] = None, + ) -> ToolResult: + return ToolResult( + ok=ok, + tool=self.name, + summary=summary, + files=files or [], + data=data or {}, + error=error, + ) + + # ------------------------------------------------------------------ # + # Public API + # ------------------------------------------------------------------ # + def run(self, **kwargs: Any) -> ToolResult: # noqa: D401 - simple + raise NotImplementedError(f"{self.__class__.__name__}.run() not implemented") + + def schema(self) -> Dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "input_schema": self.input_schema, + } + + +# --------------------------------------------------------------------------- # +# Registry +# --------------------------------------------------------------------------- # +class ToolRegistry: + """Global registry mapping tool names to tool classes.""" + + def __init__(self) -> None: + self._tools: Dict[str, Type[ToolBase]] = {} + + def register(self, tool_cls: Type[ToolBase]) -> Type[ToolBase]: + if not issubclass(tool_cls, ToolBase): + raise TypeError(f"{tool_cls} must subclass ToolBase") + name = tool_cls.name + if name in self._tools: + log.debug("Overwriting already-registered tool %s", name) + self._tools[name] = tool_cls + return tool_cls + + def get(self, name: str) -> Optional[Type[ToolBase]]: + return self._tools.get(name) + + def all_names(self) -> List[str]: + return sorted(self._tools.keys()) + + def all_schemas(self) -> List[Dict[str, Any]]: + return [cls(None, None).schema() for cls in self._tools.values()] # type: ignore[arg-type] + + def instantiate( + self, name: str, settings: Settings, transport: UnityTransport + ) -> ToolBase: + cls = self.get(name) + if cls is None: + raise KeyError(f"Unknown tool: {name}") + return cls(settings, transport) + + +# Single shared registry. +_GLOBAL_REGISTRY = ToolRegistry() + + +def get_registry() -> ToolRegistry: + return _GLOBAL_REGISTRY + + +def register_tool(tool_cls: Type[ToolBase]) -> Type[ToolBase]: + """Class decorator that registers a tool with the global registry.""" + return _GLOBAL_REGISTRY.register(tool_cls) + + +def instantiate_all(settings: Settings, transport: UnityTransport) -> Dict[str, ToolBase]: + """Instantiate every registered tool with the given settings + transport.""" + return { + name: cls(settings, transport) + for name, cls in _GLOBAL_REGISTRY._tools.items() + } diff --git a/unity_agent/tools/unity_tools.py b/unity_agent/tools/unity_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..ca6aac5b02c7a176d2aa985dec7a1f3749198535 --- /dev/null +++ b/unity_agent/tools/unity_tools.py @@ -0,0 +1,2514 @@ +"""Concrete Unity agent tools. + +Every tool in this module is a subclass of :class:`ToolBase` that emits a +piece of a Unity project: typically a complete C# script, occasionally a +scene YAML file or a project configuration file. The C# code generated here +targets Unity 2022.3 LTS and uses only APIs from the standard Unity modules +(``UnityEngine``, ``UnityEngine.AI``, ``UnityEngine.UI``) plus TextMeshPro, +Cinemachine and the Post Processing stack that are declared in the default +``manifest.json`` produced by :class:`SetupUnityProjectTool`. + +All tools are registered with the global :class:`ToolRegistry` via the +``@register_tool`` decorator and listed in :data:`ALL_TOOLS` at the bottom. +""" + +from __future__ import annotations + +import json +import textwrap +from typing import Any, Dict, List + +from unity_agent.config import Settings +from unity_agent.tools.base import ToolBase, ToolResult, register_tool +from unity_agent.transport.unity_transport import UnityTransport + + +# =========================================================================== # +# C# CODE TEMPLATES +# +# Each constant below is a complete, compilable C# MonoBehaviour (or static +# helper) returned by the matching tool. They are kept as plain strings so +# they can be inspected / unit-tested independently of the transport layer. +# =========================================================================== # + +PLAYER_CONTROLLER_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Third-person character controller with WASD movement, jump and a + /// rigidbody-based physics response. Designed to be paired with the + /// ThirdPersonCamera component. + /// + [RequireComponent(typeof(Rigidbody))] + [RequireComponent(typeof(CapsuleCollider))] + public class PlayerController : MonoBehaviour + { + [Header("Movement")] + public float moveSpeed = 6f; + public float sprintMultiplier = 1.6f; + public float rotationSmooth = 12f; + public float jumpForce = 7.5f; + + [Header("Ground Check")] + public float groundCheckDistance = 0.2f; + public LayerMask groundMask = ~0; + + [Header("Camera")] + public Transform cameraTransform; + + private Rigidbody _rb; + private CapsuleCollider _col; + private Vector2 _moveInput; + private bool _jumpRequested; + private bool _isGrounded; + private float _targetRotation; + + private void Awake() + { + _rb = GetComponent(); + _col = GetComponent(); + _rb.freezeRotation = true; + _rb.interpolation = RigidbodyInterpolation.Interpolate; + if (cameraTransform == null && Camera.main != null) + cameraTransform = Camera.main.transform; + } + + private void Update() + { + _moveInput.x = Input.GetAxisRaw("Horizontal"); + _moveInput.y = Input.GetAxisRaw("Vertical"); + _moveInput = Vector2.ClampMagnitude(_moveInput, 1f); + + if (Input.GetButtonDown("Jump") && _isGrounded) + _jumpRequested = true; + } + + private void FixedUpdate() + { + GroundCheck(); + Move(); + if (_jumpRequested) + { + _rb.AddForce(Vector3.up * jumpForce, ForceMode.VelocityChange); + _jumpRequested = false; + } + } + + private void GroundCheck() + { + Vector3 origin = transform.position + Vector3.up * (_col.height * 0.5f - _col.radius); + _isGrounded = Physics.SphereCast( + origin, _col.radius * 0.9f, Vector3.down, + out _, groundCheckDistance, groundMask, QueryTriggerInteraction.Ignore); + } + + private void Move() + { + float speed = moveSpeed; + if (Input.GetKey(KeyCode.LeftShift)) speed *= sprintMultiplier; + + if (_moveInput.sqrMagnitude < 0.01f) + { + // Apply horizontal damping so the character stops cleanly. + Vector3 v = _rb.velocity; + v.x *= 0.85f; + v.z *= 0.85f; + _rb.velocity = v; + return; + } + + Vector3 camForward = cameraTransform ? cameraTransform.forward : transform.forward; + Vector3 camRight = cameraTransform ? cameraTransform.right : transform.right; + camForward.y = 0f; camForward.Normalize(); + camRight.y = 0f; camRight.Normalize(); + + Vector3 desired = (camForward * _moveInput.y + camRight * _moveInput.x) * speed; + Vector3 velocity = _rb.velocity; + velocity.x = desired.x; + velocity.z = desired.z; + _rb.velocity = velocity; + + if (desired.sqrMagnitude > 0.01f) + { + _targetRotation = Mathf.Atan2(desired.x, desired.z) * Mathf.Rad2Deg; + float smoothed = Mathf.LerpAngle( + transform.rotation.eulerAngles.y, _targetRotation, + Time.fixedDeltaTime * rotationSmooth); + _rb.MoveRotation(Quaternion.Euler(0f, smoothed, 0f)); + } + } + } +} +""" + +VEHICLE_CONTROLLER_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Arcade car physics: motor torque, steering, braking and a simple + /// four-wheel raycast suspension. Place this on a root GameObject with + /// a Rigidbody and four wheel transforms. + /// + [RequireComponent(typeof(Rigidbody))] + public class VehicleController : MonoBehaviour + { + [System.Serializable] + public class Wheel + { + public Transform transform; + public bool isSteering; + public bool isPowered; + public bool isBraking; + [HideInInspector] public bool isGrounded; + [HideInInspector] public float suspensionOffset; + } + + [Header("Wheels")] + public Wheel[] wheels = new Wheel[4]; + + [Header("Engine")] + public float motorTorque = 1500f; + public float brakeTorque = 3000f; + public float maxSteerAngle = 30f; + public float maxSpeedKph = 180f; + + [Header("Suspension")] + public float suspensionRestLength = 0.5f; + public float suspensionSpring = 35000f; + public float suspensionDamper = 4500f; + public float wheelRadius = 0.35f; + public LayerMask wheelMask = ~0; + + [Header("Aerodynamics")] + public float downforce = 100f; + public float centerOfMassOffset = -0.5f; + + private Rigidbody _rb; + private float _throttle; + private float _steer; + private float _brake; + + private void Awake() + { + _rb = GetComponent(); + _rb.centerOfMass += Vector3.up * centerOfMassOffset; + _rb.interpolation = RigidbodyInterpolation.Interpolate; + } + + private void Update() + { + _throttle = Input.GetAxis("Vertical"); + _steer = Input.GetAxis("Horizontal"); + _brake = Input.GetKey(KeyCode.Space) ? 1f : 0f; + } + + private void FixedUpdate() + { + UpdateWheels(); + ApplySuspension(); + ApplyDrive(); + ApplyDownforce(); + } + + private void UpdateWheels() + { + foreach (var w in wheels) + { + if (w.transform == null) continue; + Vector3 origin = w.transform.position; + bool hit = Physics.Raycast( + origin, -transform.up, out RaycastHit r, + suspensionRestLength + wheelRadius, wheelMask, + QueryTriggerInteraction.Ignore); + w.isGrounded = hit; + w.suspensionOffset = hit ? r.distance - wheelRadius : suspensionRestLength; + } + } + + private void ApplySuspension() + { + foreach (var w in wheels) + { + if (!w.isGrounded) continue; + float force = (suspensionRestLength - w.suspensionOffset) * suspensionSpring; + Vector3 velocity = _rb.GetPointVelocity(w.transform.position); + float upwardVel = Vector3.Dot(transform.up, velocity); + force -= upwardVel * suspensionDamper; + _rb.AddForceAtPosition(transform.up * Mathf.Max(0f, force), w.transform.position); + } + } + + private void ApplyDrive() + { + float kph = _rb.velocity.magnitude * 3.6f; + float speedFactor = Mathf.Clamp01(1f - kph / maxSpeedKph); + + foreach (var w in wheels) + { + if (!w.isGrounded) continue; + if (w.isPowered) + { + Vector3 force = transform.forward * (_throttle * motorTorque * speedFactor); + _rb.AddForceAtPosition(force, w.transform.position, ForceMode.Force); + } + if (w.isBraking && _brake > 0.01f) + { + Vector3 brakeForce = -transform.forward * (_brake * brakeTorque); + _rb.AddForceAtPosition(brakeForce, w.transform.position, ForceMode.Force); + } + if (w.isSteering) + { + // Rotate the visual wheel and apply a small yaw torque. + w.transform.localRotation = Quaternion.Euler(0f, _steer * maxSteerAngle, 0f); + } + } + // Steering yaw torque. + if (Mathf.Abs(_steer) > 0.01f) + { + float turn = _steer * maxSteerAngle * 0.02f; + _rb.AddTorque(transform.up * turn * (_rb.velocity.magnitude + 1f), ForceMode.Force); + } + } + + private void ApplyDownforce() + { + _rb.AddForce(-transform.up * downforce * _rb.velocity.magnitude); + } + } +} +""" + +THIRD_PERSON_CAMERA_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Smooth third-person camera with collision handling. Attach to a Camera + /// GameObject and assign the target transform in the inspector. + /// + public class ThirdPersonCamera : MonoBehaviour + { + [Header("Target")] + public Transform target; + + [Header("Positioning")] + public Vector3 pivotOffset = new Vector3(0f, 1.5f, 0f); + public float distance = 5f; + public float minDistance = 1.5f; + public float maxDistance = 8f; + public float height = 1.2f; + + [Header("Smoothing")] + public float positionSmooth = 10f; + public float rotationSmooth = 10f; + + [Header("Collision")] + public LayerMask collisionMask = ~0; + public float collisionRadius = 0.3f; + + [Header("Input")] + public bool useMouseOrbit = true; + public float mouseSensitivity = 3f; + public float minPitch = -30f; + public float maxPitch = 75f; + + private float _yaw; + private float _pitch = 15f; + private float _currentDistance; + + private void Start() + { + _currentDistance = distance; + if (target != null) + { + Vector3 e = transform.eulerAngles; + _yaw = e.y; _pitch = e.x; + } + Cursor.lockState = CursorLockMode.Locked; + } + + private void LateUpdate() + { + if (target == null) return; + + if (useMouseOrbit) + { + _yaw += Input.GetAxis("Mouse X") * mouseSensitivity; + _pitch -= Input.GetAxis("Mouse Y") * mouseSensitivity; + _pitch = Mathf.Clamp(_pitch, minPitch, maxPitch); + } + + Quaternion rotation = Quaternion.Euler(_pitch, _yaw, 0f); + Vector3 pivot = target.position + pivotOffset; + + // Desired camera position. + Vector3 desired = pivot + rotation * new Vector3(0f, height, -distance); + + // Collision: shrink the distance if there's an obstacle. + if (Physics.SphereCast( + pivot, collisionRadius, (desired - pivot).normalized, + out RaycastHit hit, distance, collisionMask, QueryTriggerInteraction.Ignore)) + { + _currentDistance = Mathf.Clamp(hit.distance, minDistance, distance); + } + else + { + _currentDistance = Mathf.Lerp(_currentDistance, distance, Time.deltaTime * 4f); + } + + Vector3 finalPos = pivot + rotation * new Vector3(0f, height, -_currentDistance); + transform.position = Vector3.Lerp(transform.position, finalPos, positionSmooth * Time.deltaTime); + transform.rotation = Quaternion.Slerp( + transform.rotation, rotation, rotationSmooth * Time.deltaTime); + } + } +} +""" + +FPS_CONTROLLER_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// First-person controller: WASD + mouse look + jump. Attach to a capsule + /// with a CharacterController and a child Camera. + /// + [RequireComponent(typeof(CharacterController))] + public class FPSController : MonoBehaviour + { + [Header("Look")] + public Transform cameraTransform; + public float mouseSensitivity = 2f; + public float maxPitch = 85f; + + [Header("Movement")] + public float walkSpeed = 5f; + public float sprintSpeed = 8f; + public float jumpForce = 6f; + public float gravity = -20f; + public float groundCheckOffset = 0.1f; + + private CharacterController _cc; + private float _pitch; + private float _verticalVel; + private bool _isGrounded; + + private void Awake() + { + _cc = GetComponent(); + if (cameraTransform == null && GetComponentInChildren() != null) + cameraTransform = GetComponentInChildren().transform; + Cursor.lockState = CursorLockMode.Locked; + } + + private void Update() + { + Look(); + Move(); + } + + private void Look() + { + float mx = Input.GetAxisRaw("Mouse X") * mouseSensitivity; + float my = Input.GetAxisRaw("Mouse Y") * mouseSensitivity; + transform.Rotate(Vector3.up * mx); + _pitch = Mathf.Clamp(_pitch - my, -maxPitch, maxPitch); + if (cameraTransform) + cameraTransform.localRotation = Quaternion.Euler(_pitch, 0f, 0f); + } + + private void Move() + { + _isGrounded = _cc.isGrounded; + if (_isGrounded && _verticalVel < 0f) _verticalVel = -2f; + + float x = Input.GetAxisRaw("Horizontal"); + float z = Input.GetAxisRaw("Vertical"); + Vector3 move = transform.right * x + transform.forward * z; + move = Vector3.ClampMagnitude(move, 1f); + + float speed = Input.GetKey(KeyCode.LeftShift) ? sprintSpeed : walkSpeed; + _cc.Move(move * speed * Time.deltaTime); + + if (Input.GetButtonDown("Jump") && _isGrounded) + _verticalVel = jumpForce; + + _verticalVel += gravity * Time.deltaTime; + _cc.Move(Vector3.up * _verticalVel * Time.deltaTime); + } + } +} +""" + +DAY_NIGHT_CYCLE_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Rotates a directional light around the world to simulate a day/night + /// cycle. Also lerps between day and night sky/ambient colors and turns + /// street lights on after sunset. + /// + public class DayNightCycle : MonoBehaviour + { + [Header("Sun")] + public Light sun; + public float dayLengthSeconds = 120f; + + [Header("Colors")] + public Color daySky = new Color(0.5f, 0.7f, 1.0f); + public Color nightSky = new Color(0.03f, 0.04f, 0.08f); + public Color dayAmbient = new Color(0.6f, 0.6f, 0.6f); + public Color nightAmbient = new Color(0.12f, 0.13f, 0.18f); + + [Header("Lights")] + public Light[] streetLights; + public float streetLightThreshold = 0.2f; + + private float _timeOfDay; // 0..1 + + private void Start() + { + if (sun == null) sun = GetComponent(); + RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat; + } + + private void Update() + { + _timeOfDay = (_timeOfDay + Time.deltaTime / dayLengthSeconds) % 1f; + + // Sun angle: at timeOfDay=0.25 sun is at noon. + float sunAngle = (_timeOfDay - 0.25f) * 360f; + if (sun != null) + { + sun.transform.rotation = Quaternion.Euler(sunAngle, -30f, 0f); + float dayFactor = Mathf.Clamp01(Mathf.Cos((_timeOfDay - 0.25f) * Mathf.PI * 2f) * 0.5f + 0.5f); + sun.intensity = dayFactor * 1.2f; + } + + float t = Mathf.Clamp01(Mathf.Cos((_timeOfDay - 0.25f) * Mathf.PI * 2f) * 0.5f + 0.5f); + RenderSettings.ambientLight = Color.Lerp(nightAmbient, dayAmbient, t); + Camera mainCam = Camera.main; + if (mainCam != null) + { + mainCam.backgroundColor = Color.Lerp(nightSky, daySky, t); + } + + UpdateStreetLights(t); + } + + private void UpdateStreetLights(float dayFactor) + { + if (streetLights == null) return; + bool on = dayFactor < streetLightThreshold; + foreach (var l in streetLights) + if (l != null) l.enabled = on; + } + + /// Current time of day in 0..1 range (0=midnight). + public float TimeOfDay => _timeOfDay; + } +} +""" + +NPC_CONTROLLER_CS = """\ +using UnityEngine; +using UnityEngine.AI; + +namespace UnityAgent +{ + /// + /// AI NPC that wanders between random points and chases the player when + /// they enter the aggro radius. Uses Unity's NavMeshAgent. + /// + [RequireComponent(typeof(NavMeshAgent))] + public class NPCController : MonoBehaviour + { + public enum State { Idle, Wander, Chase } + + [Header("References")] + public Transform player; + + [Header("Behaviour")] + public State initialState = State.Wander; + public float wanderRadius = 20f; + public float aggroRange = 12f; + public float loseSightRange = 20f; + public float idleDuration = 2f; + public float wanderSpeed = 2.5f; + public float chaseSpeed = 5f; + + [Header("Debug")] + public bool drawGizmos = true; + + private NavMeshAgent _agent; + private State _state; + private float _stateTimer; + private Vector3 _startPos; + + private void Awake() + { + _agent = GetComponent(); + _agent.speed = wanderSpeed; + } + + private void Start() + { + _state = initialState; + _startPos = transform.position; + if (player == null && GameObject.FindGameObjectWithTag("Player") != null) + player = GameObject.FindGameObjectWithTag("Player").transform; + } + + private void Update() + { + switch (_state) + { + case State.Idle: TickIdle(); break; + case State.Wander: TickWander(); break; + case State.Chase: TickChase(); break; + } + } + + private void TickIdle() + { + _stateTimer -= Time.deltaTime; + if (PlayerInRange(aggroRange)) { EnterChase(); return; } + if (_stateTimer <= 0f) EnterWander(); + } + + private void TickWander() + { + if (PlayerInRange(aggroRange)) { EnterChase(); return; } + if (_agent.remainingDistance <= _agent.stoppingDistance && !_agent.pathPending) + EnterIdle(); + } + + private void TickChase() + { + if (player == null) { EnterWander(); return; } + _agent.SetDestination(player.position); + + if (!PlayerInRange(loseSightRange)) EnterWander(); + } + + private void EnterIdle() + { + _state = State.Idle; + _stateTimer = idleDuration; + _agent.isStopped = true; + } + + private void EnterWander() + { + _state = State.Wander; + _agent.speed = wanderSpeed; + _agent.isStopped = false; + Vector3 dir = Random.insideUnitSphere * wanderRadius; + dir.y = 0f; + Vector3 target = _startPos + dir; + if (NavMesh.SamplePosition(target, out NavMeshHit hit, wanderRadius, NavMesh.AllAreas)) + _agent.SetDestination(hit.position); + } + + private void EnterChase() + { + _state = State.Chase; + _agent.speed = chaseSpeed; + _agent.isStopped = false; + } + + private bool PlayerInRange(float range) + { + if (player == null) return false; + return Vector3.Distance(transform.position, player.position) <= range; + } + + private void OnDrawGizmosSelected() + { + if (!drawGizmos) return; + Gizmos.color = Color.yellow; + Gizmos.DrawWireSphere(transform.position, aggroRange); + Gizmos.color = Color.red; + Gizmos.DrawWireSphere(transform.position, loseSightRange); + Gizmos.color = Color.green; + Gizmos.DrawWireSphere(_startPos == Vector3.zero ? transform.position : _startPos, wanderRadius); + } + } +} +""" + +PICKUP_SYSTEM_CS = """\ +using UnityEngine; +using UnityEngine.Events; + +namespace UnityAgent +{ + /// + /// Collectible pickup. Triggers an event when collected and adds to the + /// global PickupSystem score. + /// + [RequireComponent(typeof(Collider))] + public class Pickup : MonoBehaviour + { + public int value = 10; + public AudioClip collectClip; + public UnityEvent onCollected; + + private void Reset() + { + GetComponent().isTrigger = true; + } + + private void OnTriggerEnter(Collider other) + { + if (!other.CompareTag("Player")) return; + PickupSystem.Instance?.Collect(value); + onCollected?.Invoke(); + if (collectClip != null && PickupSystem.Instance != null) + PickupSystem.Instance.PlayPickupSound(collectClip); + Destroy(gameObject); + } + } + + /// + /// Singleton that tracks the player's score and plays pickup sounds. + /// + public class PickupSystem : MonoBehaviour + { + public static PickupSystem Instance { get; private set; } + + public int score { get; private set; } + public AudioSource audioSource; + + public System.Action onScoreChanged; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + if (audioSource == null) audioSource = gameObject.AddComponent(); + } + + public void Collect(int amount) + { + score += amount; + onScoreChanged?.Invoke(score); + } + + public void PlayPickupSound(AudioClip clip) + { + if (audioSource != null && clip != null) audioSource.PlayOneShot(clip); + } + } +} +""" + +HEALTH_SYSTEM_CS = """\ +using UnityEngine; +using UnityEngine.Events; + +namespace UnityAgent +{ + /// + /// Damageable entity with health regen and death event. Pairs with the + /// optional UI component. + /// + public class HealthSystem : MonoBehaviour + { + [Header("Health")] + public float maxHealth = 100f; + public float currentHealth { get; private set; } + public float regenPerSecond = 1f; + public float regenDelay = 5f; + + [Header("Events")] + public UnityEvent onDeath; + public UnityEvent onDamaged; + + private float _lastDamageTime; + private bool _dead; + + public float Normalised => currentHealth / maxHealth; + + private void Awake() + { + currentHealth = maxHealth; + } + + private void Update() + { + if (_dead) return; + if (Time.time - _lastDamageTime > regenDelay && currentHealth < maxHealth) + { + currentHealth = Mathf.Min(maxHealth, currentHealth + regenPerSecond * Time.deltaTime); + } + } + + public void TakeDamage(float amount) + { + if (_dead) return; + currentHealth = Mathf.Max(0f, currentHealth - amount); + _lastDamageTime = Time.time; + onDamaged?.Invoke(amount); + if (currentHealth <= 0f) Die(); + } + + public void Heal(float amount) + { + if (_dead) return; + currentHealth = Mathf.Min(maxHealth, currentHealth + amount); + } + + private void Die() + { + _dead = true; + onDeath?.Invoke(); + Destroy(gameObject, 0.5f); + } + } + + /// + /// Simple health bar that reads a and updates + /// a child Image.fillAmount. + /// + public class HealthBar : MonoBehaviour + { + public HealthSystem source; + public UnityEngine.UI.Image fillImage; + public bool hideWhenFull = true; + + private void Update() + { + if (source == null || fillImage == null) return; + float n = source.Normalised; + fillImage.fillAmount = n; + if (hideWhenFull) fillImage.transform.parent.gameObject.SetActive(n < 0.999f); + } + } +} +""" + +WEAPON_SYSTEM_CS = """\ +using UnityEngine; +using UnityEngine.Events; + +namespace UnityAgent +{ + /// + /// Hitscan weapon with raycast, muzzle flash and basic ammo tracking. + /// + public class WeaponSystem : MonoBehaviour + { + [Header("Stats")] + public float damage = 25f; + public float range = 200f; + public float fireRate = 8f; // shots/sec + public int magazineSize = 30; + public float reloadTime = 1.5f; + public LayerMask hitMask = ~0; + + [Header("Effects")] + public Transform muzzle; + public ParticleSystem muzzleFlash; + public GameObject impactPrefab; + public AudioClip fireClip; + public AudioClip reloadClip; + + [Header("Input")] + public bool automatic = true; + + public UnityEvent onFired; + public UnityEvent onReloaded; + + private Camera _cam; + private float _nextFireTime; + private int _ammo; + private bool _reloading; + private float _reloadEnd; + private AudioSource _audio; + + private void Awake() + { + _cam = GetComponentInChildren(); + if (_cam == null) _cam = Camera.main; + _audio = gameObject.AddComponent(); + _ammo = magazineSize; + } + + private void Update() + { + if (_reloading && Time.time >= _reloadEnd) + { + _reloading = false; + _ammo = magazineSize; + onReloaded?.Invoke(); + } + + bool fire = automatic ? Input.GetMouseButton(0) : Input.GetMouseButtonDown(0); + if (fire && !_reloading && Time.time >= _nextFireTime && _ammo > 0) + Fire(); + + if (Input.GetKeyDown(KeyCode.R) && !_reloading && _ammo < magazineSize) + Reload(); + } + + private void Fire() + { + _nextFireTime = Time.time + 1f / fireRate; + _ammo--; + + if (muzzleFlash != null) muzzleFlash.Play(); + if (fireClip != null && _audio != null) _audio.PlayOneShot(fireClip); + + Ray ray = new Ray(_cam.transform.position, _cam.transform.forward); + if (Physics.Raycast(ray, out RaycastHit hit, range, hitMask, QueryTriggerInteraction.Ignore)) + { + var dmg = hit.collider.GetComponentInParent(); + if (dmg != null) dmg.TakeDamage(damage); + if (impactPrefab != null) + Instantiate(impactPrefab, hit.point, Quaternion.LookRotation(hit.normal)); + } + onFired?.Invoke(); + } + + private void Reload() + { + _reloading = true; + _reloadEnd = Time.time + reloadTime; + if (reloadClip != null && _audio != null) _audio.PlayOneShot(reloadClip); + } + + public int Ammo => _ammo; + public bool IsReloading => _reloading; + } +} +""" + +AUDIO_MANAGER_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Singleton audio manager that exposes named one-shot SFX and music + /// channels. Used by other systems to play clips without having to manage + /// their own AudioSource components. + /// + public class AudioManager : MonoBehaviour + { + public static AudioManager Instance { get; private set; } + + [Header("Mixers")] + public AudioSource sfxSource; + public AudioSource musicSource; + + [Range(0f, 1f)] public float masterVolume = 1f; + [Range(0f, 1f)] public float sfxVolume = 0.8f; + [Range(0f, 1f)] public float musicVolume = 0.5f; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + DontDestroyOnLoad(gameObject); + + if (sfxSource == null) + { + sfxSource = gameObject.AddComponent(); + sfxSource.playOnAwake = false; + } + if (musicSource == null) + { + musicSource = gameObject.AddComponent(); + musicSource.loop = true; + musicSource.playOnAwake = false; + } + ApplyVolumes(); + } + + public void PlaySfx(AudioClip clip, float volumeScale = 1f) + { + if (clip != null && sfxSource != null) + sfxSource.PlayOneShot(clip, volumeScale * sfxVolume * masterVolume); + } + + public void PlayMusic(AudioClip clip) + { + if (clip == null || musicSource == null) return; + musicSource.clip = clip; + musicSource.volume = musicVolume * masterVolume; + musicSource.Play(); + } + + public void StopMusic() + { + if (musicSource != null) musicSource.Stop(); + } + + public void ApplyVolumes() + { + if (musicSource != null) musicSource.volume = musicVolume * masterVolume; + AudioListener.volume = masterVolume; + } + } +} +""" + +UI_MANAGER_CS = """\ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace UnityAgent +{ + /// + /// Lightweight HUD manager using Unity UI + TextMeshPro. Exposes methods + /// for other systems to update score / health / messages. + /// + public class UIManager : MonoBehaviour + { + public static UIManager Instance { get; private set; } + + [Header("HUD")] + public TMP_Text scoreText; + public TMP_Text healthText; + public TMP_Text messageText; + public Image healthBarFill; + + [Header("Message")] + public float messageDuration = 2f; + + private float _messageTimer; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + SetMessage(string.Empty); + } + + private void Update() + { + if (_messageTimer > 0f) + { + _messageTimer -= Time.deltaTime; + if (_messageTimer <= 0f && messageText != null) messageText.text = string.Empty; + } + } + + public void SetScore(int score) + { + if (scoreText != null) scoreText.text = $"Score: {score}"; + } + + public void SetHealth(float current, float max) + { + if (healthText != null) healthText.text = $"HP: {Mathf.CeilToInt(current)} / {Mathf.CeilToInt(max)}"; + if (healthBarFill != null) healthBarFill.fillAmount = Mathf.Clamp01(current / max); + } + + public void SetMessage(string msg) + { + if (messageText != null) messageText.text = msg; + _messageTimer = string.IsNullOrEmpty(msg) ? 0f : messageDuration; + } + } +} +""" + +TERRAIN_GENERATOR_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Procedurally generates a terrain heightmap using layered Perlin noise + /// and applies splat textures based on height/slope. Attach to a GameObject + /// with a Terrain component; the script rebuilds the terrain on Awake. + /// + [RequireComponent(typeof(Terrain))] + public class TerrainGenerator : MonoBehaviour + { + [Header("Dimensions")] + public int heightmapResolution = 513; + public float terrainSize = 500f; + public float maxHeight = 80f; + + [Header("Noise")] + public float baseFrequency = 0.005f; + public int octaves = 5; + public float persistence = 0.5f; + public float lacunarity = 2.0f; + public int seed = 1337; + + [Header("Splat")] + public Texture2D grassTexture; + public Texture2D rockTexture; + public Texture2D sandTexture; + + private Terrain _terrain; + private TerrainData _data; + + private void Awake() + { + _terrain = GetComponent(); + _data = _terrain.terrainData; + Generate(); + } + + public void Generate() + { + _data.heightmapResolution = heightmapResolution; + _data.size = new Vector3(terrainSize, maxHeight, terrainSize); + + float[,] heights = new float[heightmapResolution, heightmapResolution]; + var rng = new System.Random(seed); + float offsetX = (float)rng.NextDouble() * 1000f; + float offsetY = (float)rng.NextDouble() * 1000f; + + for (int y = 0; y < heightmapResolution; y++) + { + for (int x = 0; x < heightmapResolution; x++) + { + float amplitude = 1f; + float frequency = baseFrequency; + float sum = 0f; + float norm = 0f; + for (int o = 0; o < octaves; o++) + { + float nx = (x + offsetX) * frequency; + float ny = (y + offsetY) * frequency; + sum += amplitude * (Mathf.PerlinNoise(nx, ny) * 2f - 1f); + norm += amplitude; + amplitude *= persistence; + frequency *= lacunarity; + } + heights[y, x] = (sum / norm) * 0.5f + 0.5f; + } + } + _data.SetHeights(0, 0, heights); + ApplySplats(); + } + + private void ApplySplats() + { + if (grassTexture == null || rockTexture == null || sandTexture == null) + return; + var proto = new TerrainLayer[3]; + for (int i = 0; i < 3; i++) proto[i] = new TerrainLayer(); + proto[0].diffuseTexture = grassTexture; + proto[1].diffuseTexture = rockTexture; + proto[2].diffuseTexture = sandTexture; + _data.terrainLayers = proto; + + int res = _data.alphamapResolution; + float[,,] map = new float[res, res, 3]; + for (int y = 0; y < res; y++) + { + for (int x = 0; x < res; x++) + { + float h = _data.GetHeight(Mathf.RoundToInt((float)x / res * heightmapResolution), + Mathf.RoundToInt((float)y / res * heightmapResolution)); + float norm = h / maxHeight; + float grass = Mathf.Clamp01(1f - Mathf.Abs(norm - 0.4f) * 3f); + float rock = Mathf.Clamp01(norm - 0.6f) * 2f; + float sand = Mathf.Clamp01(1f - norm * 4f); + float total = grass + rock + sand + 0.0001f; + map[y, x, 0] = grass / total; + map[y, x, 1] = rock / total; + map[y, x, 2] = sand / total; + } + } + _data.SetAlphamaps(0, 0, map); + } + } +} +""" + +WATER_SHADER_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Procedural animated water: builds a MeshRenderer material at runtime + /// that uses sine-wave vertex displacement for gentle waves and a + /// scrolling normal map for ripples. Drop on a flat Plane. + /// + [RequireComponent(typeof(MeshRenderer))] + public class WaterMaterial : MonoBehaviour + { + [Header("Color")] + public Color shallowColor = new Color(0.2f, 0.6f, 0.8f, 0.7f); + public Color deepColor = new Color(0.05f, 0.15f, 0.3f, 0.9f); + + [Header("Animation")] + public float waveSpeed = 1.0f; + public float waveHeight = 0.15f; + public float waveFrequency = 1.5f; + public Vector2 scrollSpeed = new Vector2(0.05f, 0.05f); + + [Header("Material")] + public Texture normalMap; + public float glossiness = 0.85f; + public float metallic = 0.1f; + + private Material _mat; + private MeshRenderer _mr; + + private void Awake() + { + _mr = GetComponent(); + _mat = new Material(Shader.Find("Standard")); + _mat.name = "ProceduralWater"; + _mat.color = shallowColor; + _mat.SetFloat("_Glossiness", glossiness); + _mat.SetFloat("_Metallic", metallic); + _mat.SetFloat("_Mode", 3); // Transparent + _mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); + _mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + _mat.SetInt("_ZWrite", 0); + _mat.renderQueue = 3000; + if (normalMap != null) _mat.SetTexture("_BumpMap", normalMap); + _mr.material = _mat; + } + + private void Update() + { + if (_mat == null) return; + float t = Time.time * waveSpeed; + // Approximate wave displacement by oscillating the local scale Y. + transform.localScale = new Vector3( + transform.localScale.x, + 1f + Mathf.Sin(t * waveFrequency) * waveHeight * 0.1f, + transform.localScale.z); + if (normalMap != null) + { + Vector2 offset = _mat.GetTextureOffset("_BumpMap") + scrollSpeed * Time.deltaTime; + _mat.SetTextureOffset("_BumpMap", offset); + } + } + } +} +""" + +PARTICLE_EFFECTS_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Spawns configurable particle bursts at points of interest (impacts, + /// explosions, ambient effects). Use from any system. + /// + public class ParticleSpawner : MonoBehaviour + { + public static ParticleSpawner Instance { get; private set; } + + [Header("Burst Prefab")] + public ParticleSystem burstPrefab; + + [Header("Ambient")] + public ParticleSystem ambientSystem; + public bool playAmbientOnStart = true; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + if (ambientSystem != null && playAmbientOnStart) ambientSystem.Play(); + } + + /// + /// Spawn a burst particle effect at with + /// the given rotation. Returns the spawned ParticleSystem (or null). + /// + public ParticleSystem Spawn(Vector3 position, Quaternion rotation, float scale = 1f) + { + if (burstPrefab == null) return null; + var ps = Instantiate(burstPrefab, position, rotation); + ps.transform.localScale = Vector3.one * scale; + ps.Play(); + Destroy(ps.gameObject, ps.main.duration + ps.main.startLifetime.constantMax + 0.5f); + return ps; + } + + /// + /// Convenience explosion helper: spawns a burst, applies a radial + /// physics impulse, and damages HealthSystem objects in range. + /// + public void Explode(Vector3 center, float radius, float force, float damage) + { + Spawn(center, Quaternion.identity, radius * 0.5f); + Collider[] hits = Physics.OverlapSphere(center, radius); + foreach (var h in hits) + { + var rb = h.attachedRigidbody; + if (rb != null) + { + rb.AddExplosionForce(force, center, radius, 1f, ForceMode.Impulse); + } + var hp = h.GetComponentInParent(); + if (hp != null) + { + float falloff = 1f - Mathf.Clamp01(Vector3.Distance(center, h.transform.position) / radius); + hp.TakeDamage(damage * falloff); + } + } + } + } +} +""" + +SAVE_SYSTEM_CS = """\ +using System; +using System.IO; +using UnityEngine; + +namespace UnityAgent +{ + /// + /// JSON save system using . Stores a single + /// save slot per profile in Application.persistentDataPath. + /// + public class SaveSystem : MonoBehaviour + { + public static SaveSystem Instance { get; private set; } + + public string profileName = "default"; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + private string SavePath => Path.Combine(Application.persistentDataPath, profileName + ".save"); + + public void Save(T data) where T : class + { + try + { + string json = JsonUtility.ToJson(new Wrapper { payload = data }, true); + File.WriteAllText(SavePath, json); + Debug.Log($"[SaveSystem] Saved to {SavePath}"); + } + catch (Exception e) + { + Debug.LogError($"[SaveSystem] Save failed: {e}"); + } + } + + public T Load() where T : class + { + if (!File.Exists(SavePath)) return null; + try + { + string json = File.ReadAllText(SavePath); + var w = JsonUtility.FromJson>(json); + return w?.payload; + } + catch (Exception e) + { + Debug.LogError($"[SaveSystem] Load failed: {e}"); + return null; + } + } + + public bool HasSave => File.Exists(SavePath); + + public void Delete() + { + if (File.Exists(SavePath)) File.Delete(SavePath); + } + + [Serializable] + private class Wrapper { public T payload; } + } +} +""" + +INVENTORY_SYSTEM_CS = """\ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Events; + +namespace UnityAgent +{ + /// + /// Lightweight inventory with item IDs, stack counts and events. + /// + [Serializable] + public class InventoryItem + { + public string id; + public string displayName; + public int count; + public int maxStack = 99; + public Sprite icon; + } + + public class InventorySystem : MonoBehaviour + { + public static InventorySystem Instance { get; private set; } + + public int capacity = 24; + public List items = new List(); + + [Header("Events")] + public UnityEvent onInventoryChanged; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + } + + public bool Add(InventoryItem item) + { + // Try to stack with existing items first. + foreach (var i in items) + { + if (i.id == item.id && i.count < i.maxStack) + { + int space = i.maxStack - i.count; + int add = Mathf.Min(space, item.count); + i.count += add; + item.count -= add; + if (item.count <= 0) + { + onInventoryChanged?.Invoke(); + return true; + } + } + } + + // Add to a fresh slot if there's room. + if (items.Count < capacity) + { + items.Add(item); + onInventoryChanged?.Invoke(); + return true; + } + return false; + } + + public bool Remove(string id, int count = 1) + { + for (int i = items.Count - 1; i >= 0; i--) + { + if (items[i].id == id) + { + int take = Mathf.Min(count, items[i].count); + items[i].count -= take; + count -= take; + if (items[i].count <= 0) items.RemoveAt(i); + if (count <= 0) + { + onInventoryChanged?.Invoke(); + return true; + } + } + } + onInventoryChanged?.Invoke(); + return count == 0; + } + + public int Count(string id) + { + int c = 0; + foreach (var i in items) if (i.id == id) c += i.count; + return c; + } + + public bool Has(string id, int count = 1) => Count(id) >= count; + } +} +""" + +QUEST_SYSTEM_CS = """\ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Events; + +namespace UnityAgent +{ + /// + /// Simple quest / objective tracker. Quests progress through states and + /// fire events when objectives complete. + /// + [Serializable] + public class Quest + { + public string id; + public string title; + [TextArea] public string description; + public int requiredAmount = 1; + [HideInInspector] public int currentAmount; + [HideInInspector] public QuestState state = QuestState.NotStarted; + + public bool IsComplete => state == QuestState.Complete; + } + + public enum QuestState { NotStarted, Active, Complete, Failed } + + public class QuestSystem : MonoBehaviour + { + public static QuestSystem Instance { get; private set; } + + public List quests = new List(); + public UnityEvent onQuestUpdated; + public UnityEvent onQuestCompleted; + + private void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + } + + public void StartQuest(string id) + { + var q = quests.Find(x => x.id == id); + if (q == null || q.state != QuestState.NotStarted) return; + q.state = QuestState.Active; + onQuestUpdated?.Invoke(q); + } + + public void Progress(string id, int amount = 1) + { + var q = quests.Find(x => x.id == id); + if (q == null || q.state != QuestState.Active) return; + q.currentAmount = Mathf.Min(q.requiredAmount, q.currentAmount + amount); + onQuestUpdated?.Invoke(q); + if (q.currentAmount >= q.requiredAmount) + { + q.state = QuestState.Complete; + onQuestCompleted?.Invoke(q); + } + } + + public void Fail(string id) + { + var q = quests.Find(x => x.id == id); + if (q == null) return; + q.state = QuestState.Failed; + onQuestUpdated?.Invoke(q); + } + } +} +""" + +BUILDING_GENERATOR_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Procedural building generator: builds a box building mesh with + /// emissive windows and adds a BoxCollider for collision. + /// + public static class BuildingGenerator + { + public static GameObject Generate(string name, Vector3 position, Vector3 size, + Material wallMaterial, Material windowMaterial) + { + GameObject go = new GameObject(name); + go.transform.position = position; + + // Walls + GameObject walls = GameObject.CreatePrimitive(PrimitiveType.Cube); + walls.name = "Walls"; + walls.transform.SetParent(go.transform, false); + walls.transform.localScale = size; + walls.transform.localPosition = Vector3.up * size.y * 0.5f; + if (wallMaterial != null) walls.GetComponent().sharedMaterial = wallMaterial; + + // Windows: a smaller cube with the window material, slightly offset on each side. + if (windowMaterial != null) + { + GameObject windows = GameObject.CreatePrimitive(PrimitiveType.Cube); + windows.name = "Windows"; + windows.transform.SetParent(go.transform, false); + windows.transform.localScale = size * 0.99f; + windows.transform.localPosition = Vector3.up * size.y * 0.5f; + var mr = windows.GetComponent(); + mr.sharedMaterial = windowMaterial; + } + + // Collider (the cube already has one, but ensure it's a BoxCollider). + var box = go.GetComponent(); + if (box == null) box = go.AddComponent(); + box.size = size; + box.center = Vector3.up * size.y * 0.5f; + + return go; + } + + /// + /// Build an emissive window material procedurally so the agent does + /// not depend on any asset on disk. + /// + public static Material MakeWindowMaterial(Color glow) + { + var m = new Material(Shader.Find("Standard")); + m.name = "WindowMat"; + m.color = Color.black; + m.SetColor("_EmissionColor", glow); + m.EnableKeyword("_EMISSION"); + return m; + } + + public static Material MakeWallMaterial(Color col) + { + var m = new Material(Shader.Find("Standard")); + m.name = "WallMat"; + m.color = col; + return m; + } + } +} +""" + +ROAD_NETWORK_CS = """\ +using System.Collections.Generic; +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Lays out a grid road network at runtime: builds thin box meshes for + /// roads and an intersection marker at every grid crossing. + /// + public class RoadNetwork : MonoBehaviour + { + [Header("Grid")] + public int gridSize = 5; + public float blockSpacing = 30f; + public float roadWidth = 6f; + public float roadThickness = 0.1f; + + [Header("Materials")] + public Material roadMaterial; + public Material intersectionMaterial; + + private readonly List _roads = new List(); + + private void Start() + { + Build(); + } + + public void Build() + { + foreach (var r in _roads) if (r != null) Destroy(r); + _roads.Clear(); + + float total = (gridSize - 1) * blockSpacing; + Vector3 origin = transform.position - new Vector3(total * 0.5f, 0f, total * 0.5f); + + for (int i = 0; i < gridSize; i++) + { + // Horizontal road + _roads.Add(SpawnRoad( + new Vector3(origin.x + total * 0.5f, 0f, origin.z + i * blockSpacing), + new Vector3(total + roadWidth, roadThickness, roadWidth), + roadMaterial, $"Road_H_{i}")); + + // Vertical road + _roads.Add(SpawnRoad( + new Vector3(origin.x + i * blockSpacing, 0f, origin.z + total * 0.5f), + new Vector3(roadWidth, roadThickness, total + roadWidth), + roadMaterial, $"Road_V_{i}")); + } + + // Intersections. + for (int x = 0; x < gridSize; x++) + { + for (int z = 0; z < gridSize; z++) + { + Vector3 p = new Vector3(origin.x + x * blockSpacing, 0f, origin.z + z * blockSpacing); + _roads.Add(SpawnRoad(p, new Vector3(roadWidth * 1.2f, roadThickness * 1.5f, roadWidth * 1.2f), + intersectionMaterial, $"Intersection_{x}_{z}")); + } + } + } + + private GameObject SpawnRoad(Vector3 pos, Vector3 scale, Material mat, string name) + { + GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); + go.name = name; + go.transform.SetParent(transform, false); + go.transform.position = pos + transform.position; + go.transform.localScale = scale; + if (mat != null) go.GetComponent().sharedMaterial = mat; + return go; + } + } +} +""" + +CITY_GENERATOR_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Procedural city generator: spawns a grid of buildings of varying + /// heights with emissive windows, street lights and parked vehicles. + /// Designed to be paired with . + /// + public class CityGenerator : MonoBehaviour + { + [Header("Grid")] + public int gridX = 8; + public int gridZ = 8; + public float blockSpacing = 30f; + + [Header("Buildings")] + public int buildingsPerBlock = 1; + public float minBuildingHeight = 8f; + public float maxBuildingHeight = 40f; + public float buildingFootprint = 8f; + public Color wallColor = new Color(0.4f, 0.4f, 0.42f); + public Color windowColor = new Color(1.0f, 0.85f, 0.4f); + + [Header("Street Lights")] + public bool spawnStreetLights = true; + public float lightSpacing = 15f; + public Color lightColor = new Color(1.0f, 0.85f, 0.5f); + public float lightIntensity = 1.5f; + public float lightRange = 15f; + + private readonly System.Collections.Generic.List _lights = new System.Collections.Generic.List(); + + private void Start() + { + Generate(); + } + + public void Generate() + { + Material wallMat = BuildingGenerator.MakeWallMaterial(wallColor); + Material winMat = BuildingGenerator.MakeWindowMaterial(windowColor); + + float totalX = (gridX - 1) * blockSpacing; + float totalZ = (gridZ - 1) * blockSpacing; + Vector3 origin = transform.position - new Vector3(totalX * 0.5f, 0f, totalZ * 0.5f); + + for (int x = 0; x < gridX; x++) + { + for (int z = 0; z < gridZ; z++) + { + Vector3 block = new Vector3(origin.x + x * blockSpacing, 0f, origin.z + z * blockSpacing); + SpawnBlock(block, wallMat, winMat); + } + } + + if (spawnStreetLights) SpawnStreetLights(origin, totalX, totalZ); + } + + private void SpawnBlock(Vector3 center, Material wallMat, Material winMat) + { + for (int i = 0; i < buildingsPerBlock; i++) + { + float h = Random.Range(minBuildingHeight, maxBuildingHeight); + Vector3 size = new Vector3(buildingFootprint, h, buildingFootprint); + Vector3 pos = center + new Vector3( + Random.Range(-4f, 4f), 0f, Random.Range(-4f, 4f)); + BuildingGenerator.Generate($"Building_{center.x}_{center.z}_{i}", pos, size, wallMat, winMat); + } + } + + private void SpawnStreetLights(Vector3 origin, float totalX, float totalZ) + { + Material poleMat = BuildingGenerator.MakeWallMaterial(new Color(0.1f, 0.1f, 0.1f)); + + for (float x = origin.x; x <= origin.x + totalX + 0.1f; x += lightSpacing) + { + for (float z = origin.z; z <= origin.z + totalZ + 0.1f; z += lightSpacing) + { + SpawnStreetLight(new Vector3(x, 0f, z), poleMat); + } + } + } + + private void SpawnStreetLight(Vector3 pos, Material poleMat) + { + GameObject pole = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + pole.name = "StreetLight"; + pole.transform.SetParent(transform, false); + pole.transform.position = pos + Vector3.up * 4f; + pole.transform.localScale = new Vector3(0.2f, 4f, 0.2f); + pole.GetComponent().sharedMaterial = poleMat; + + GameObject bulb = GameObject.CreatePrimitive(PrimitiveType.Sphere); + bulb.transform.SetParent(pole.transform, false); + bulb.transform.localPosition = Vector3.up * 0.9f; + bulb.transform.localScale = Vector3.one * 0.4f; + bulb.GetComponent().sharedMaterial = BuildingGenerator.MakeWindowMaterial(lightColor); + + Light l = bulb.AddComponent(); + l.type = LightType.Point; + l.color = lightColor; + l.intensity = lightIntensity; + l.range = lightRange; + _lights.Add(l); + } + + public Light[] StreetLights => _lights.ToArray(); + } +} +""" + + +# =========================================================================== # +# SCENE TEMPLATE +# =========================================================================== # +def _scene_yaml(scene_name: str = "MainScene") -> str: + """Return a minimal but valid .unity scene YAML file. + + The scene contains a Main Camera and a Directional Light. The agent's + runtime scripts are wired up via the bootstrap GameObject so the user + can simply hit Play. + """ + return f"""%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {{fileID: 0}} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 9 + m_Fog: 0 + m_FogColor: {{r: 0.5, g: 0.5, b: 0.5, a: 1}} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {{r: 0.4, g: 0.4, b: 0.4, a: 1}} + m_AmbientEquatorColor: {{r: 0.2, g: 0.2, b: 0.2, a: 1}} + m_AmbientGroundColor: {{r: 0.1, g: 0.1, b: 0.1, a: 1}} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {{r: 0.2, g: 0.2, b: 0.2, a: 1}} + m_SkyboxMaterial: {{fileID: 0}} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {{fileID: 0}} + m_SpotCookie: {{fileID: 0}} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {{fileID: 0}} + m_Sun: {{fileID: 0}} + m_IndirectSpecularColor: {{r: 0, g: 0, b: 0, a: 1}} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 12 + m_GIWorkflowMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 +--- !u!196 &4 +NavMeshSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_AutoGenerate: 0 +--- !u!1 &100 +GameObject: + m_ObjectHideFlags: 0 + m_Name: Main Camera + m_IsActive: 1 + m_Component: + - component: {{fileID: 101}} + - component: {{fileID: 102}} + m_Layer: 0 +--- !u!4 &101 +Transform: + m_GameObject: {{fileID: 100}} + m_LocalPosition: {{x: 0, y: 5, z: -10}} + m_LocalRotation: {{x: 0, y: 0, z: 0, w: 1}} + m_LocalScale: {{x: 1, y: 1, z: 1}} +--- !u!20 &102 +Camera: + m_GameObject: {{fileID: 100}} + m_ClearFlags: 1 + m_BackGroundColor: {{r: 0.4, g: 0.6, b: 0.9, a: 1}} + m_Orthographic: 0 + m_FieldOfView: 60 + m_NearClipPlane: 0.3 + m_FarClipPlane: 1000 +--- !u!1 &200 +GameObject: + m_ObjectHideFlags: 0 + m_Name: Directional Light + m_IsActive: 1 + m_Component: + - component: {{fileID: 201}} + - component: {{fileID: 202}} + m_Layer: 0 +--- !u!4 &201 +Transform: + m_GameObject: {{fileID: 200}} + m_LocalRotation: {{x: 0.4, y: -0.3, z: 0.2, w: 0.8}} + m_LocalPosition: {{x: 0, y: 50, z: 0}} + m_LocalScale: {{x: 1, y: 1, z: 1}} +--- !u!108 &202 +Light: + m_GameObject: {{fileID: 200}} + m_Type: 1 + m_Color: {{r: 1, g: 1, b: 1, a: 1}} + m_Intensity: 1 + m_Shadows: + m_Type: 2 +""" + + +# =========================================================================== # +# TOOL CLASSES +# =========================================================================== # +def _common_schema(description: str, props: Dict[str, Any] = None) -> Dict[str, Any]: + props = props or {} + return { + "type": "object", + "description": description, + "properties": { + "project_name": {"type": "string", "description": "Target Unity project name (sub-folder of output_dir)."}, + **props, + }, + "required": [], + } + + +def _write_script(transport: UnityTransport, project_name: str, filename: str, code: str) -> ToolResult: + transport._ensure_project(project_name) + wf = transport.write_csharp(filename, code) + return ToolResult( + ok=True, + tool="write_csharp_script", + summary=f"Wrote {filename} ({wf.size_bytes} bytes)", + files=[str(wf.absolute_path)], + data={"filename": filename, "size": wf.size_bytes}, + ) + + +@register_tool +class CreatePlayerControllerTool(ToolBase): + name = "create_player_controller" + description = ( + "Generate a complete PlayerController.cs (WASD + camera-follow + " + "physics + jump) for a third-person character." + ) + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "PlayerController.cs", PLAYER_CONTROLLER_CS) + r.tool = self.name + return r + + +@register_tool +class CreateVehicleControllerTool(ToolBase): + name = "create_vehicle_controller" + description = "Generate VehicleController.cs with arcade car physics (motor, steering, braking, suspension)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "VehicleController.cs", VEHICLE_CONTROLLER_CS) + r.tool = self.name + return r + + +@register_tool +class CreateProceduralCityTool(ToolBase): + name = "create_procedural_city" + description = ( + "Generate CityGenerator.cs that spawns 50+ buildings with emissive " + "windows, street lights and parked props at runtime." + ) + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + # CityGenerator depends on BuildingGenerator, so emit both. + self._ensure_project(project_name) + files = [] + wf = self.transport.write_csharp("BuildingGenerator.cs", BUILDING_GENERATOR_CS) + files.append(str(wf.absolute_path)) + wf = self.transport.write_csharp("CityGenerator.cs", CITY_GENERATOR_CS) + files.append(str(wf.absolute_path)) + return ToolResult( + ok=True, tool=self.name, + summary=f"Wrote CityGenerator.cs + BuildingGenerator.cs", + files=files, + ) + + +@register_tool +class CreateThirdPersonCameraTool(ToolBase): + name = "create_third_person_camera" + description = "Generate ThirdPersonCamera.cs with smooth follow + sphere-cast collision." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "ThirdPersonCamera.cs", THIRD_PERSON_CAMERA_CS) + r.tool = self.name + return r + + +@register_tool +class CreateFPSControllerTool(ToolBase): + name = "create_fps_controller" + description = "Generate FPSController.cs with mouse-look + WASD + jump using CharacterController." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "FPSController.cs", FPS_CONTROLLER_CS) + r.tool = self.name + return r + + +@register_tool +class CreateDayNightCycleTool(ToolBase): + name = "create_day_night_cycle" + description = "Generate DayNightCycle.cs that rotates a directional sun and lerps sky/ambient colors." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "DayNightCycle.cs", DAY_NIGHT_CYCLE_CS) + r.tool = self.name + return r + + +@register_tool +class CreateAiNpcTool(ToolBase): + name = "create_ai_npc" + description = "Generate NPCController.cs with NavMeshAgent wandering + aggro-chase state machine." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "NPCController.cs", NPC_CONTROLLER_CS) + r.tool = self.name + return r + + +@register_tool +class CreatePickupSystemTool(ToolBase): + name = "create_pickup_system" + description = "Generate PickupSystem.cs + Pickup.cs (collectibles + score + pickup sound)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "PickupSystem.cs", PICKUP_SYSTEM_CS) + r.tool = self.name + return r + + +@register_tool +class CreateHealthSystemTool(ToolBase): + name = "create_health_system" + description = "Generate HealthSystem.cs + HealthBar.cs (damage, regen, death event, UI)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "HealthSystem.cs", HEALTH_SYSTEM_CS) + r.tool = self.name + return r + + +@register_tool +class CreateWeaponSystemTool(ToolBase): + name = "create_weapon_system" + description = "Generate WeaponSystem.cs (raycast hitscan, ammo, reload, muzzle flash, damage)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "WeaponSystem.cs", WEAPON_SYSTEM_CS) + r.tool = self.name + return r + + +@register_tool +class CreateAudioManagerTool(ToolBase): + name = "create_audio_manager" + description = "Generate AudioManager.cs singleton with SFX + music channels and master volume." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "AudioManager.cs", AUDIO_MANAGER_CS) + r.tool = self.name + return r + + +@register_tool +class CreateUIManagerTool(ToolBase): + name = "create_ui_manager" + description = "Generate UIManager.cs HUD (TextMeshPro score/health/message + health fill bar)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "UIManager.cs", UI_MANAGER_CS) + r.tool = self.name + return r + + +@register_tool +class CreateTerrainGeneratorTool(ToolBase): + name = "create_terrain_generator" + description = "Generate TerrainGenerator.cs that builds a Perlin-noise heightmap + splat textures." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "TerrainGenerator.cs", TERRAIN_GENERATOR_CS) + r.tool = self.name + return r + + +@register_tool +class CreateWaterShaderTool(ToolBase): + name = "create_water_shader" + description = "Generate WaterMaterial.cs that builds an animated Standard-material water surface." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "WaterMaterial.cs", WATER_SHADER_CS) + r.tool = self.name + return r + + +@register_tool +class CreateParticleEffectsTool(ToolBase): + name = "create_particle_effects" + description = "Generate ParticleSpawner.cs (burst spawning + radial explosion helper with damage)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "ParticleSpawner.cs", PARTICLE_EFFECTS_CS) + r.tool = self.name + return r + + +@register_tool +class CreateSaveSystemTool(ToolBase): + name = "create_save_system" + description = "Generate SaveSystem.cs JSON save/load to Application.persistentDataPath." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "SaveSystem.cs", SAVE_SYSTEM_CS) + r.tool = self.name + return r + + +@register_tool +class CreateInventorySystemTool(ToolBase): + name = "create_inventory_system" + description = "Generate InventorySystem.cs + InventoryItem (stacks, capacity, events)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "InventorySystem.cs", INVENTORY_SYSTEM_CS) + r.tool = self.name + return r + + +@register_tool +class CreateQuestSystemTool(ToolBase): + name = "create_quest_system" + description = "Generate QuestSystem.cs + Quest (state machine, progress, completion events)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "QuestSystem.cs", QUEST_SYSTEM_CS) + r.tool = self.name + return r + + +@register_tool +class CreateBuildingGeneratorTool(ToolBase): + name = "create_building_generator" + description = "Generate BuildingGenerator.cs (procedural walls + emissive windows + collider)." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "BuildingGenerator.cs", BUILDING_GENERATOR_CS) + r.tool = self.name + return r + + +@register_tool +class CreateRoadNetworkTool(ToolBase): + name = "create_road_network" + description = "Generate RoadNetwork.cs that lays out a grid of road meshes + intersections." + input_schema = _common_schema(description) + + def run(self, project_name: str = None, **kw) -> ToolResult: + r = _write_script(self.transport, project_name, "RoadNetwork.cs", ROAD_NETWORK_CS) + r.tool = self.name + return r + + +# --------------------------------------------------------------------------- # +# Project scaffolding tools +# --------------------------------------------------------------------------- # +@register_tool +class SetupUnityProjectTool(ToolBase): + name = "setup_unity_project" + description = ( + "Create the complete Unity project structure: Assets/, Packages/manifest.json, " + "ProjectSettings/*, and an asmdef. Call this first when starting a new project." + ) + input_schema = _common_schema( + description, + { + "include_asmdef": {"type": "boolean", "description": "Write a UnityAgent.Scripts.asmdef into Assets/Scripts."}, + "dependencies": {"type": "object", "description": "Optional override for Packages/manifest.json dependencies."}, + }, + ) + + def run(self, project_name: str = None, include_asmdef: bool = True, + dependencies: Dict[str, str] = None, **kw) -> ToolResult: + self._ensure_project(project_name) + files = [] + + files.append(str(self.transport.write_packages_manifest(dependencies).absolute_path)) + files.append(str(self.transport.write_project_version().absolute_path)) + files.append(str(self.transport.write_project_settings().absolute_path)) + if include_asmdef: + files.append(str(self.transport.write_assembly_definition("UnityAgent.Scripts").absolute_path)) + + # TagManager.asset -- ensures the "Player" tag exists. + tag_manager = ( + "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n--- !u!78 &1\n" + "TagManager:\n serializedVersion: 2\n tags:\n - Player\n - Enemy\n - Pickup\n" + " layers:\n - Default\n - TransparentFX\n - Ignore Raycast\n - Water\n - UI\n" + ) + files.append(str(self.transport.write_config("ProjectSettings/TagManager.asset", tag_manager).absolute_path)) + + return ToolResult( + ok=True, tool=self.name, + summary=f"Scaffolded Unity project '{self.transport.project_name}'.", + files=files, + ) + + +@register_tool +class WriteCSharpScriptTool(ToolBase): + name = "write_csharp_script" + description = ( + "Write any C# script to Assets/Scripts/. Provide filename (with or without .cs) " + "and the full code body. A .meta file is generated automatically." + ) + input_schema = { + "type": "object", + "description": "Write any C# script.", + "properties": { + "project_name": {"type": "string"}, + "filename": {"type": "string", "description": "Script file name, e.g. 'GameManager.cs'."}, + "code": {"type": "string", "description": "Full C# source code."}, + }, + "required": ["filename", "code"], + } + + def run(self, filename: str, code: str, project_name: str = None, **kw) -> ToolResult: + if not filename or not code: + return self.result(False, "filename and code are required", error="missing arguments") + self._ensure_project(project_name) + wf = self.transport.write_csharp(filename, code) + return self.result(True, f"Wrote {filename} ({wf.size_bytes} bytes)", + files=[str(wf.absolute_path)], + data={"filename": filename, "size": wf.size_bytes}) + + +@register_tool +class WriteSceneFileTool(ToolBase): + name = "write_scene_file" + description = "Write a .unity scene file to Assets/Scenes/ (defaults to a minimal MainScene)." + input_schema = { + "type": "object", + "description": "Write a scene file.", + "properties": { + "project_name": {"type": "string"}, + "scene_name": {"type": "string", "description": "Scene name (with or without .unity)."}, + "yaml": {"type": "string", "description": "Optional custom scene YAML body. Defaults to a minimal scene."}, + }, + "required": [], + } + + def run(self, scene_name: str = "MainScene", yaml: str = None, project_name: str = None, **kw) -> ToolResult: + self._ensure_project(project_name) + body = yaml if yaml is not None else _scene_yaml(scene_name) + wf = self.transport.write_scene(scene_name, body) + return self.result(True, f"Wrote scene {scene_name} ({wf.size_bytes} bytes)", + files=[str(wf.absolute_path)], + data={"scene_name": scene_name, "size": wf.size_bytes}) + + +# --------------------------------------------------------------------------- # +# Mega tool: full game +# --------------------------------------------------------------------------- # +@register_tool +class GenerateCompleteGameTool(ToolBase): + name = "generate_complete_game" + description = ( + "Generate a FULL playable game in one call: scaffolds the project, " + "writes a complete set of C# scripts and a scene. Defaults to an " + "open-world city game with player + vehicle + camera + day/night + " + "NPCs + city + roads + UI + audio + save." + ) + input_schema = _common_schema( + description, + { + "game_name": {"type": "string", "description": "Name of the game (defaults to 'OpenWorldCity')."}, + "preset": {"type": "string", "enum": ["open_world_city", "fps_arena", "minimal"], + "description": "Game preset to generate."}, + }, + ) + + PRESETS = {"open_world_city", "fps_arena", "minimal"} + + def run(self, game_name: str = "OpenWorldCity", preset: str = "open_world_city", + project_name: str = None, **kw) -> ToolResult: + if preset not in self.PRESETS: + return self.result(False, f"Unknown preset '{preset}'.", + error=f"preset must be one of {sorted(self.PRESETS)}") + target = project_name or game_name + self._ensure_project(target) + files: List[str] = [] + + # 1. Scaffolding. + scaffold = SetupUnityProjectTool(self.settings, self.transport).run(project_name=target) + files.extend(scaffold.files) + + # 2. Always emit the bootstrap + UI + audio + save scripts. + common_scripts = [ + ("AudioManager.cs", AUDIO_MANAGER_CS), + ("UIManager.cs", UI_MANAGER_CS), + ("SaveSystem.cs", SAVE_SYSTEM_CS), + ("HealthSystem.cs", HEALTH_SYSTEM_CS), + ("PickupSystem.cs", PICKUP_SYSTEM_CS), + ("BuildingGenerator.cs", BUILDING_GENERATOR_CS), + ] + for name, code in common_scripts: + wf = self.transport.write_csharp(name, code) + files.append(str(wf.absolute_path)) + + # 3. Preset-specific scripts. + if preset == "open_world_city": + preset_scripts = [ + ("PlayerController.cs", PLAYER_CONTROLLER_CS), + ("VehicleController.cs", VEHICLE_CONTROLLER_CS), + ("ThirdPersonCamera.cs", THIRD_PERSON_CAMERA_CS), + ("DayNightCycle.cs", DAY_NIGHT_CYCLE_CS), + ("NPCController.cs", NPC_CONTROLLER_CS), + ("CityGenerator.cs", CITY_GENERATOR_CS), + ("RoadNetwork.cs", ROAD_NETWORK_CS), + ("GameManager.cs", _GAME_BOOTSTRAP_CS), + ] + elif preset == "fps_arena": + preset_scripts = [ + ("FPSController.cs", FPS_CONTROLLER_CS), + ("WeaponSystem.cs", WEAPON_SYSTEM_CS), + ("NPCController.cs", NPC_CONTROLLER_CS), + ("ParticleSpawner.cs", PARTICLE_EFFECTS_CS), + ("DayNightCycle.cs", DAY_NIGHT_CYCLE_CS), + ("GameManager.cs", _GAME_BOOTSTRAP_CS), + ] + else: # minimal + preset_scripts = [ + ("PlayerController.cs", PLAYER_CONTROLLER_CS), + ("ThirdPersonCamera.cs", THIRD_PERSON_CAMERA_CS), + ("GameManager.cs", _GAME_BOOTSTRAP_CS), + ] + + for name, code in preset_scripts: + wf = self.transport.write_csharp(name, code) + files.append(str(wf.absolute_path)) + + # 4. Scene. + scene_yaml = _scene_yaml("MainScene") + wf = self.transport.write_scene("MainScene", scene_yaml) + files.append(str(wf.absolute_path)) + + return self.result( + True, + f"Generated complete game '{game_name}' (preset={preset}) with {len(files)} files.", + files=files, + data={"game_name": game_name, "preset": preset, + "project_root": str(self.transport.project_root)}, + ) + + +# Bootstrap script that wires everything together at runtime. It creates the +# key GameObjects (player, camera, city, lights, etc.) procedurally so the +# generated scene can be minimal. +_GAME_BOOTSTRAP_CS = """\ +using UnityEngine; + +namespace UnityAgent +{ + /// + /// Bootstrap that wires up the generated systems on scene load. The + /// scene only needs an empty GameObject with this component attached. + /// + public class GameManager : MonoBehaviour + { + [Header("Player")] + public GameObject playerPrefab; + public Vector3 spawnPosition = new Vector3(0f, 2f, 0f); + + [Header("City")] + public int cityGridX = 8; + public int cityGridZ = 8; + + private void Start() + { + SpawnPlayer(); + SpawnCity(); + SpawnLights(); + SetupCamera(); + SetupAudio(); + SetupUI(); + } + + private void SpawnPlayer() + { + GameObject player = playerPrefab != null + ? Instantiate(playerPrefab, spawnPosition, Quaternion.identity) + : CreatePrimitivePlayer(); + player.tag = "Player"; + player.name = "Player"; + + var pc = player.GetComponent(); + if (pc == null) pc = player.AddComponent(); + var rb = player.GetComponent(); + if (rb == null) rb = player.AddComponent(); + var col = player.GetComponent(); + if (col == null) col = player.AddComponent(); + col.height = 2f; col.radius = 0.4f; col.center = new Vector3(0f, 1f, 0f); + rb.constraints = RigidbodyConstraints.FreezeRotation; + } + + private GameObject CreatePrimitivePlayer() + { + GameObject go = GameObject.CreatePrimitive(PrimitiveType.Capsule); + go.transform.position = spawnPosition; + return go; + } + + private void SpawnCity() + { + GameObject city = new GameObject("City"); + var gen = city.AddComponent(); + gen.gridX = cityGridX; + gen.gridZ = cityGridZ; + + GameObject roads = new GameObject("Roads"); + roads.transform.SetParent(city.transform, false); + var net = roads.AddComponent(); + net.gridSize = Mathf.Max(cityGridX, cityGridZ); + } + + private void SpawnLights() + { + GameObject sunGo = new GameObject("Sun"); + var sun = sunGo.AddComponent(); + sun.type = LightType.Directional; + sun.intensity = 1.2f; + sun.shadows = LightShadows.Soft; + + var dnc = sunGo.AddComponent(); + dnc.sun = sun; + // Bind street lights from the city generator after one frame. + StartCoroutine(BindStreetLights(dnc)); + } + + private System.Collections.IEnumerator BindStreetLights(DayNightCycle dnc) + { + yield return null; // allow CityGenerator.Start() to run + var cg = FindObjectOfType(); + if (cg != null) dnc.streetLights = cg.StreetLights; + } + + private void SetupCamera() + { + Camera cam = Camera.main; + if (cam == null) + { + var go = new GameObject("Main Camera"); + cam = go.AddComponent(); + go.tag = "MainCamera"; + go.AddComponent(); + } + var tpc = cam.GetComponent(); + if (tpc == null) tpc = cam.gameObject.AddComponent(); + var player = GameObject.FindGameObjectWithTag("Player"); + tpc.target = player != null ? player.transform : null; + } + + private void SetupAudio() + { + var go = new GameObject("AudioManager"); + go.AddComponent(); + } + + private void SetupUI() + { + var go = new GameObject("UIManager"); + go.AddComponent(); + } + } +} +""" + + +# --------------------------------------------------------------------------- # +# Convenience exports +# --------------------------------------------------------------------------- # +ALL_TOOLS = [ + CreatePlayerControllerTool, + CreateVehicleControllerTool, + CreateProceduralCityTool, + CreateThirdPersonCameraTool, + CreateFPSControllerTool, + CreateDayNightCycleTool, + CreateAiNpcTool, + CreatePickupSystemTool, + CreateHealthSystemTool, + CreateWeaponSystemTool, + CreateAudioManagerTool, + CreateUIManagerTool, + CreateTerrainGeneratorTool, + CreateWaterShaderTool, + CreateParticleEffectsTool, + CreateSaveSystemTool, + CreateInventorySystemTool, + CreateQuestSystemTool, + CreateBuildingGeneratorTool, + CreateRoadNetworkTool, + SetupUnityProjectTool, + WriteCSharpScriptTool, + WriteSceneFileTool, + GenerateCompleteGameTool, +] diff --git a/unity_agent/transport/__init__.py b/unity_agent/transport/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea0372a2d4d5f19e206e2b4f4e3d4c5f6aaeb92c --- /dev/null +++ b/unity_agent/transport/__init__.py @@ -0,0 +1,17 @@ +"""Transport layer for the Unity agent. + +The transport layer is responsible for taking generated C# source code, Unity +YAML scene files and project metadata and writing them to disk in the layout +that the Unity editor expects:: + + // + Assets/ + Scripts/*.cs + Scenes/*.unity + Packages/manifest.json + ProjectSettings/... +""" + +from unity_agent.transport.unity_transport import UnityTransport + +__all__ = ["UnityTransport"] diff --git a/unity_agent/transport/unity_transport.py b/unity_agent/transport/unity_transport.py new file mode 100644 index 0000000000000000000000000000000000000000..8b2d25683654fb224efdbab8ee9870ecd4740037 --- /dev/null +++ b/unity_agent/transport/unity_transport.py @@ -0,0 +1,291 @@ +"""Unity project file writer. + +The :class:`UnityTransport` is the *only* component in the system that touches +the filesystem. Tools generate strings (C# code, YAML scene files, JSON +manifests) and hand them to the transport, which writes them to the correct +location inside the project directory and maintains an inventory of every file +it has written. + +The directory layout produced matches what Unity expects when you click +"Open Project" in the Unity Hub:: + + ProjectRoot/ + Assets/ + Scripts/.cs + Scenes/.unity + Packages/ + manifest.json + ProjectSettings/ + ProjectSettings.asset + ProjectVersion.txt +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +from unity_agent.config import Settings + +log = logging.getLogger("unity_agent.transport") + + +# --------------------------------------------------------------------------- # +# .meta file generation +# --------------------------------------------------------------------------- # +def make_meta(guid: Optional[str] = None) -> str: + """Return a valid Unity ``.meta`` file body for the given GUID. + + Unity uses ``.meta`` files to track assets across machines. Every meta + file needs a unique 32-hex-character GUID. We generate one from a hash + of the current time + random bytes when the caller does not supply one. + """ + if guid is None: + seed = f"{time.time_ns()}-{os.urandom(8).hex()}".encode() + guid = hashlib.md5(seed).hexdigest()[:32] + return ( + "fileFormatVersion: 2\n" + f"guid: {guid}\n" + "MonoImporter:\n" + " externalObjects: {}\n" + " serializedVersion: 2\n" + " defaultReferences: []\n" + " executionOrder: 0\n" + " icon: {instanceID: 0}\n" + " userData: \n" + " assetBundleName: \n" + " assetBundleVariant: \n" + ) + + +# --------------------------------------------------------------------------- # +# File record +# --------------------------------------------------------------------------- # +@dataclass +class WrittenFile: + relative_path: str # path relative to project root + absolute_path: Path + size_bytes: int + kind: str # "csharp", "scene", "config", "meta", "other" + + +@dataclass +class TransportStats: + files: List[WrittenFile] = field(default_factory=list) + + def add(self, wf: WrittenFile) -> None: + self.files.append(wf) + + @property + def total_bytes(self) -> int: + return sum(f.size_bytes for f in self.files) + + def by_kind(self, kind: str) -> List[WrittenFile]: + return [f for f in self.files if f.kind == kind] + + +# --------------------------------------------------------------------------- # +# Transport +# --------------------------------------------------------------------------- # +class UnityTransport: + """Filesystem writer for Unity projects. + + Parameters + ---------- + settings: + Global :class:`Settings`. The transport uses ``output_dir`` as the + parent for all projects. + project_name: + Optional project name. If provided, all paths are resolved relative + to ``/``. If ``None`` you must call + :meth:`open_project` before writing any files. + """ + + ASSETS_DIR = "Assets" + SCRIPTS_DIR = "Assets/Scripts" + SCENES_DIR = "Assets/Scenes" + PACKAGES_DIR = "Packages" + PROJECT_SETTINGS_DIR = "ProjectSettings" + + def __init__(self, settings: Settings, project_name: Optional[str] = None) -> None: + self.settings = settings + self.project_name = project_name + self.stats = TransportStats() + self._project_root: Optional[Path] = None + if project_name is not None: + self.open_project(project_name) + + # ------------------------------------------------------------------ # + # Project lifecycle + # ------------------------------------------------------------------ # + def open_project(self, project_name: str) -> Path: + """Create / open a project directory and return its absolute path.""" + self.project_name = project_name + self._project_root = self.settings.project_path(project_name) + if self.settings.auto_create_dirs: + for sub in ( + self.ASSETS_DIR, + self.SCRIPTS_DIR, + self.PACKAGES_DIR, + self.PROJECT_SETTINGS_DIR, + ): + (self._project_root / sub).mkdir(parents=True, exist_ok=True) + log.info("Opened Unity project at %s", self._project_root) + return self._project_root + + @property + def project_root(self) -> Path: + if self._project_root is None: + raise RuntimeError("No project open. Call open_project() first.") + return self._project_root + + # ------------------------------------------------------------------ # + # Low-level write + # ------------------------------------------------------------------ # + def _write( + self, + relative_path: str, + content: str, + kind: str, + *, + write_meta: bool = False, + ) -> WrittenFile: + full = self.project_root / relative_path + full.parent.mkdir(parents=True, exist_ok=True) + if full.exists() and not self.settings.overwrite: + raise FileExistsError(full) + full.write_text(content, encoding="utf-8") + wf = WrittenFile( + relative_path=relative_path, + absolute_path=full, + size_bytes=len(content.encode("utf-8")), + kind=kind, + ) + self.stats.add(wf) + if write_meta: + meta_path = relative_path + ".meta" + meta_content = make_meta() + meta_full = self.project_root / meta_path + meta_full.write_text(meta_content, encoding="utf-8") + self.stats.add( + WrittenFile( + relative_path=meta_path, + absolute_path=meta_full, + size_bytes=len(meta_content), + kind="meta", + ) + ) + return wf + + # ------------------------------------------------------------------ # + # High-level helpers + # ------------------------------------------------------------------ # + def write_csharp(self, filename: str, code: str) -> WrittenFile: + """Write a C# script to ``Assets/Scripts/`` (with .meta).""" + if not filename.endswith(".cs"): + filename += ".cs" + rel = f"{self.SCRIPTS_DIR}/{filename}" + return self._write(rel, code, "csharp", write_meta=True) + + def write_scene(self, scene_name: str, yaml: str) -> WrittenFile: + if not scene_name.endswith(".unity"): + scene_name += ".unity" + rel = f"{self.SCENES_DIR}/{scene_name}" + return self._write(rel, yaml, "scene", write_meta=True) + + def write_config(self, relative_path: str, content: str) -> WrittenFile: + return self._write(relative_path, content, "config", write_meta=False) + + def write_raw(self, relative_path: str, content: str, kind: str = "other") -> WrittenFile: + return self._write(relative_path, content, kind, write_meta=False) + + # ------------------------------------------------------------------ # + # Manifest / settings + # ------------------------------------------------------------------ # + def write_packages_manifest(self, dependencies: Optional[Dict[str, str]] = None) -> WrittenFile: + deps = dependencies or { + "com.unity.ai.navigation": "1.1.5", + "com.unity.cinemachine": "2.9.7", + "com.unity.collab-proxy": "2.2.0", + "com.unity.ide.rider": "3.0.27", + "com.unity.ide.visualstudio": "2.0.22", + "com.unity.inputsystem": "1.7.0", + "com.unity.postprocessing": "3.4.0", + "com.unity.textmeshpro": "3.0.6", + "com.unity.ugui": "1.0.0", + "com.unity.modules.ai": "1.0.0", + "com.unity.modules.animation": "1.0.0", + "com.unity.modules.audio": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0", + "com.unity.modules.particlesystem": "1.0.0", + "com.unity.modules.physics": "1.0.0", + "com.unity.modules.physics2d": "1.0.0", + "com.unity.modules.terrain": "1.0.0", + "com.unity.modules.terrainphysics": "1.0.0", + "com.unity.modules.ui": "1.0.0", + } + manifest = { + "dependencies": deps, + } + body = json.dumps(manifest, indent=2) + return self.write_config(f"{self.PACKAGES_DIR}/manifest.json", body) + + def write_project_version(self) -> WrittenFile: + return self.write_config( + f"{self.PROJECT_SETTINGS_DIR}/ProjectVersion.txt", + f"m_EditorVersion: {self.settings.unity_version}\n", + ) + + def write_project_settings(self) -> WrittenFile: + body = ( + "%YAML 1.1\n" + "%TAG !u! tag:unity3d.com,2011:\n" + "--- !u!129 &1\n" + "PlayerSettings:\n" + " m_ObjectHideFlags: 0\n" + " serializedVersion: 26\n" + f" companyName: {self.settings.company_name}\n" + f" productName: {self.settings.product_name}\n" + " defaultCursor: {instanceID: 0}\n" + " m_Version: 1\n" + ) + return self.write_config(f"{self.PROJECT_SETTINGS_DIR}/ProjectSettings.asset", body) + + def write_assembly_definition(self, name: str = "UnityAgent.Scripts") -> WrittenFile: + asmdef = { + "name": name, + "rootNamespace": "UnityAgent", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": False, + "autoReferenced": True, + "defineConstraints": [], + "versionDefines": [], + } + body = json.dumps(asmdef, indent=2) + if not name.endswith(".asmdef"): + name += ".asmdef" + return self._write(f"{self.SCRIPTS_DIR}/{name}", body, "config", write_meta=True) + + # ------------------------------------------------------------------ # + # Reporting + # ------------------------------------------------------------------ # + def summary(self) -> str: + lines = [ + f"Unity project: {self.project_root}", + f"Total files written: {len(self.stats.files)}", + f"Total size: {self.stats.total_bytes:,} bytes", + ] + kinds = {} + for f in self.stats.files: + kinds[f.kind] = kinds.get(f.kind, 0) + 1 + for k, v in sorted(kinds.items()): + lines.append(f" {k}: {v}") + return "\n".join(lines)