diff --git a/Assets/AdaEngine.icon/icon.json b/Assets/AdaEngine.icon/icon.json index 2534e79fa..cb1612709 100644 --- a/Assets/AdaEngine.icon/icon.json +++ b/Assets/AdaEngine.icon/icon.json @@ -60,4 +60,4 @@ ], "squares" : "shared" } -} +} \ No newline at end of file diff --git a/Demos/Fold/Sources/FoldGame/FoldPose.swift b/Demos/Fold/Sources/FoldGame/FoldPose.swift index 8387b637d..cb951d3ac 100644 --- a/Demos/Fold/Sources/FoldGame/FoldPose.swift +++ b/Demos/Fold/Sources/FoldGame/FoldPose.swift @@ -48,8 +48,8 @@ public struct FoldPose: Resource, Codable, Equatable, Sendable { @MainActor public static func registerRuntimeType() { RuntimeTypeRegistry.registerResource(Self.self, names: ["FoldPose"]) RuntimeResourceReflectionRegistry.register(Self.self, fields: ["angle", "showsOuter"].map { key in - unsafe EditorComponentFieldDescriptor( - key: key, label: key, kind: .readOnly, isEditable: false, accepts: { _ in false }, + unsafe ReflectedComponentField( + key: key, label: key, kind: .readOnly, isWritable: false, accepts: { _ in false }, read: { _ in nil }, write: { _, _ in nil }, readPointer: { pointer in let pose = unsafe pointer.assumingMemoryBound(to: Self.self).pointee return key == "angle" ? .double(Double(pose.playableAngle)) : .bool(pose.showsOuter) diff --git a/Demos/Fold/Sources/FoldGame/ShadowLevel.swift b/Demos/Fold/Sources/FoldGame/ShadowLevel.swift index 4fb811c39..4db0b2be8 100644 --- a/Demos/Fold/Sources/FoldGame/ShadowLevel.swift +++ b/Demos/Fold/Sources/FoldGame/ShadowLevel.swift @@ -79,7 +79,7 @@ public struct ShadowPlayerInput: Resource, Sendable { @MainActor public static func registerRuntimeType() { RuntimeTypeRegistry.registerResource(Self.self, names: ["ShadowPlayerInput"]) RuntimeResourceReflectionRegistry.register(Self.self, fields: ["moveX", "jump", "flip", "transfer", "restart"].map { key in - unsafe EditorComponentFieldDescriptor(key: key, label: key, kind: key == "moveX" ? .float : .int, isEditable: true, + unsafe ReflectedComponentField(key: key, label: key, kind: key == "moveX" ? .float : .int, isWritable: true, read: { _ in nil }, write: { _, _ in nil }, readPointer: { pointer in let value = unsafe pointer.assumingMemoryBound(to: Self.self).pointee switch key { @@ -91,12 +91,12 @@ public struct ShadowPlayerInput: Resource, Sendable { } }, writePointer: { pointer, field in let value = unsafe pointer.assumingMemoryBound(to: Self.self) - if key == "moveX" { return unsafe EditorComponentReflection.write(field, to: &value.pointee.moveX) } + if key == "moveX" { return unsafe ComponentReflection.write(field, to: &value.pointee.moveX) } switch key { - case "jump": return unsafe EditorComponentReflection.write(field, to: &value.pointee.jump) - case "flip": return unsafe EditorComponentReflection.write(field, to: &value.pointee.flip) - case "transfer": return unsafe EditorComponentReflection.write(field, to: &value.pointee.transfer) - default: return unsafe EditorComponentReflection.write(field, to: &value.pointee.restart) + case "jump": return unsafe ComponentReflection.write(field, to: &value.pointee.jump) + case "flip": return unsafe ComponentReflection.write(field, to: &value.pointee.flip) + case "transfer": return unsafe ComponentReflection.write(field, to: &value.pointee.transfer) + default: return unsafe ComponentReflection.write(field, to: &value.pointee.restart) } }) }) @@ -113,7 +113,7 @@ public struct ShadowProgress: Resource, Sendable { @MainActor public static func registerRuntimeType() { RuntimeTypeRegistry.registerResource(Self.self, names: ["ShadowProgress"]) RuntimeResourceReflectionRegistry.register(Self.self, fields: ["checkpoint", "completed", "outer", "message"].map { key in - unsafe EditorComponentFieldDescriptor(key: key, label: key, kind: .readOnly, isEditable: false, accepts: { _ in false }, + unsafe ReflectedComponentField(key: key, label: key, kind: .readOnly, isWritable: false, accepts: { _ in false }, read: { _ in nil }, write: { _, _ in nil }, readPointer: { pointer in let value = unsafe pointer.assumingMemoryBound(to: Self.self).pointee switch key { diff --git a/Demos/MedievalArena/.ada/project.json b/Demos/MedievalArena/.ada/project.json new file mode 100644 index 000000000..32556b833 --- /dev/null +++ b/Demos/MedievalArena/.ada/project.json @@ -0,0 +1,111 @@ +{ + "ai": { + "mcp": { + "allowedResourceRoots": [], + "enabled": true + } + }, + "build": { + "excludedFiles": [], + "includedFiles": [], + "system": "adascript", + "targets": [] + }, + "editor": { + "startupScene": "Assets/Scenes/Main.ascn" + }, + "engine": {}, + "inputActions": [ + { + "bindings": [ + { "key": { "_0": "w" } }, + { "key": { "_0": "126" } } + ], + "deadZone": 0.2, + "name": "MoveUp" + }, + { + "bindings": [ + { "key": { "_0": "s" } }, + { "key": { "_0": "125" } } + ], + "deadZone": 0.2, + "name": "MoveDown" + }, + { + "bindings": [ + { "key": { "_0": "a" } }, + { "key": { "_0": "123" } } + ], + "deadZone": 0.2, + "name": "MoveLeft" + }, + { + "bindings": [ + { "key": { "_0": "d" } }, + { "key": { "_0": "124" } } + ], + "deadZone": 0.2, + "name": "MoveRight" + }, + { + "bindings": [ + { "key": { "_0": " " } } + ], + "deadZone": 0.2, + "name": "Attack" + } + ], + "paths": { + "assets": "Assets", + "resourceRoots": [ + "Assets" + ], + "run": { + "workingDirectory": "." + }, + "sources": "Sources" + }, + "project": { + "displayName": "Medieval Arena", + "name": "MedievalArena" + }, + "run": { + "arguments": [], + "destination": "macos", + "environment": {}, + "workingDirectory": "." + }, + "runtime": { + "entry": { + "scene": "Assets/Scenes/Main.ascn" + }, + "moduleName": "MedievalArenaGame", + "plugins": { + "disable": [], + "enable": [ + "multiplayer" + ], + "preset": "game2d", + "presetVersion": 1, + "settings": { + "multiplayer": { + "buildIdentifier": "1", + "gameIdentifier": "org.adaengine.medieval-arena", + "host": "::1", + "peerIndex": 1, + "port": 37778, + "role": "host" + } + } + }, + "window": { + "isResizable": true, + "size": { + "height": 640, + "width": 960 + } + } + }, + "schemaVersion": 3 +} diff --git a/Demos/MedievalArena/.gitignore b/Demos/MedievalArena/.gitignore new file mode 100644 index 000000000..8153a9a46 --- /dev/null +++ b/Demos/MedievalArena/.gitignore @@ -0,0 +1,4 @@ +/.build-codex/ +/dist/ +/.ada/workspace/ +.DS_Store diff --git a/Demos/MedievalArena/Assets/License.txt b/Demos/MedievalArena/Assets/License.txt new file mode 100644 index 000000000..6d7df791e --- /dev/null +++ b/Demos/MedievalArena/Assets/License.txt @@ -0,0 +1,22 @@ + + + Tiny Dungeon (1.0) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 05-07-2022 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/Demos/MedievalArena/Assets/Scenes/Main.ascn b/Demos/MedievalArena/Assets/Scenes/Main.ascn new file mode 100644 index 000000000..49b61fc19 --- /dev/null +++ b/Demos/MedievalArena/Assets/Scenes/Main.ascn @@ -0,0 +1,753 @@ +format: ada.scene +schemaVersion: 1 +scene: + id: medieval-arena + name: Medieval Arena +entities: +- id: arena-tilemap + name: Arena Tile Map + enabled: true + components: + AdaTransform.Transform: + position: + - 0 + - 0 + - 0 + scale: + - 1 + - 1 + - 1 + rotation: + - 0 + - 0 + - 0 + - 1 + AdaTilemap.TileMapComponent: + atlasColors: + - alpha: 1 + red: 4.8e-1 + blue: 2.1e-1 + green: 2.5e-1 + - alpha: 1 + green: 4.8e-1 + blue: 6.2e-1 + red: 3.8e-1 + cells: + - - -9 + - -5 + - 1 + - - -8 + - -5 + - 1 + - - -7 + - -5 + - 1 + - - -6 + - -5 + - 1 + - - -5 + - -5 + - 1 + - - -4 + - -5 + - 1 + - - -3 + - -5 + - 1 + - - -2 + - -5 + - 1 + - - -1 + - -5 + - 1 + - - 0 + - -5 + - 1 + - - 1 + - -5 + - 1 + - - 2 + - -5 + - 1 + - - 3 + - -5 + - 1 + - - 4 + - -5 + - 1 + - - 5 + - -5 + - 1 + - - 6 + - -5 + - 1 + - - 7 + - -5 + - 1 + - - 8 + - -5 + - 1 + - - 9 + - -5 + - 1 + - - -9 + - -4 + - 1 + - - -8 + - -4 + - 0 + - - -7 + - -4 + - 0 + - - -6 + - -4 + - 0 + - - -5 + - -4 + - 0 + - - -4 + - -4 + - 0 + - - -3 + - -4 + - 0 + - - -2 + - -4 + - 0 + - - -1 + - -4 + - 0 + - - 0 + - -4 + - 0 + - - 1 + - -4 + - 0 + - - 2 + - -4 + - 0 + - - 3 + - -4 + - 0 + - - 4 + - -4 + - 0 + - - 5 + - -4 + - 0 + - - 6 + - -4 + - 0 + - - 7 + - -4 + - 0 + - - 8 + - -4 + - 0 + - - 9 + - -4 + - 1 + - - -9 + - -3 + - 1 + - - -8 + - -3 + - 0 + - - -7 + - -3 + - 0 + - - -6 + - -3 + - 0 + - - -5 + - -3 + - 0 + - - -4 + - -3 + - 0 + - - -3 + - -3 + - 0 + - - -2 + - -3 + - 0 + - - -1 + - -3 + - 0 + - - 0 + - -3 + - 0 + - - 1 + - -3 + - 0 + - - 2 + - -3 + - 0 + - - 3 + - -3 + - 0 + - - 4 + - -3 + - 0 + - - 5 + - -3 + - 0 + - - 6 + - -3 + - 0 + - - 7 + - -3 + - 0 + - - 8 + - -3 + - 0 + - - 9 + - -3 + - 1 + - - -9 + - -2 + - 1 + - - -8 + - -2 + - 0 + - - -7 + - -2 + - 0 + - - -6 + - -2 + - 0 + - - -5 + - -2 + - 0 + - - -4 + - -2 + - 0 + - - -3 + - -2 + - 0 + - - -2 + - -2 + - 0 + - - -1 + - -2 + - 0 + - - 0 + - -2 + - 0 + - - 1 + - -2 + - 0 + - - 2 + - -2 + - 0 + - - 3 + - -2 + - 0 + - - 4 + - -2 + - 0 + - - 5 + - -2 + - 0 + - - 6 + - -2 + - 0 + - - 7 + - -2 + - 0 + - - 8 + - -2 + - 0 + - - 9 + - -2 + - 1 + - - -9 + - -1 + - 1 + - - -8 + - -1 + - 0 + - - -7 + - -1 + - 0 + - - -6 + - -1 + - 0 + - - -5 + - -1 + - 0 + - - -4 + - -1 + - 0 + - - -3 + - -1 + - 0 + - - -2 + - -1 + - 0 + - - -1 + - -1 + - 0 + - - 0 + - -1 + - 0 + - - 1 + - -1 + - 0 + - - 2 + - -1 + - 0 + - - 3 + - -1 + - 0 + - - 4 + - -1 + - 0 + - - 5 + - -1 + - 0 + - - 6 + - -1 + - 0 + - - 7 + - -1 + - 0 + - - 8 + - -1 + - 0 + - - 9 + - -1 + - 1 + - - -9 + - 0 + - 1 + - - -8 + - 0 + - 0 + - - -7 + - 0 + - 0 + - - -6 + - 0 + - 0 + - - -5 + - 0 + - 0 + - - -4 + - 0 + - 0 + - - -3 + - 0 + - 0 + - - -2 + - 0 + - 0 + - - -1 + - 0 + - 0 + - - 0 + - 0 + - 0 + - - 1 + - 0 + - 0 + - - 2 + - 0 + - 0 + - - 3 + - 0 + - 0 + - - 4 + - 0 + - 0 + - - 5 + - 0 + - 0 + - - 6 + - 0 + - 0 + - - 7 + - 0 + - 0 + - - 8 + - 0 + - 0 + - - 9 + - 0 + - 1 + - - -9 + - 1 + - 1 + - - -8 + - 1 + - 0 + - - -7 + - 1 + - 0 + - - -6 + - 1 + - 0 + - - -5 + - 1 + - 0 + - - -4 + - 1 + - 0 + - - -3 + - 1 + - 0 + - - -2 + - 1 + - 0 + - - -1 + - 1 + - 0 + - - 0 + - 1 + - 0 + - - 1 + - 1 + - 0 + - - 2 + - 1 + - 0 + - - 3 + - 1 + - 0 + - - 4 + - 1 + - 0 + - - 5 + - 1 + - 0 + - - 6 + - 1 + - 0 + - - 7 + - 1 + - 0 + - - 8 + - 1 + - 0 + - - 9 + - 1 + - 1 + - - -9 + - 2 + - 1 + - - -8 + - 2 + - 0 + - - -7 + - 2 + - 0 + - - -6 + - 2 + - 0 + - - -5 + - 2 + - 0 + - - -4 + - 2 + - 0 + - - -3 + - 2 + - 0 + - - -2 + - 2 + - 0 + - - -1 + - 2 + - 0 + - - 0 + - 2 + - 0 + - - 1 + - 2 + - 0 + - - 2 + - 2 + - 0 + - - 3 + - 2 + - 0 + - - 4 + - 2 + - 0 + - - 5 + - 2 + - 0 + - - 6 + - 2 + - 0 + - - 7 + - 2 + - 0 + - - 8 + - 2 + - 0 + - - 9 + - 2 + - 1 + - - -9 + - 3 + - 1 + - - -8 + - 3 + - 0 + - - -7 + - 3 + - 0 + - - -6 + - 3 + - 0 + - - -5 + - 3 + - 0 + - - -4 + - 3 + - 0 + - - -3 + - 3 + - 0 + - - -2 + - 3 + - 0 + - - -1 + - 3 + - 0 + - - 0 + - 3 + - 0 + - - 1 + - 3 + - 0 + - - 2 + - 3 + - 0 + - - 3 + - 3 + - 0 + - - 4 + - 3 + - 0 + - - 5 + - 3 + - 0 + - - 6 + - 3 + - 0 + - - 7 + - 3 + - 0 + - - 8 + - 3 + - 0 + - - 9 + - 3 + - 1 + - - -9 + - 4 + - 1 + - - -8 + - 4 + - 0 + - - -7 + - 4 + - 0 + - - -6 + - 4 + - 0 + - - -5 + - 4 + - 0 + - - -4 + - 4 + - 0 + - - -3 + - 4 + - 0 + - - -2 + - 4 + - 0 + - - -1 + - 4 + - 0 + - - 0 + - 4 + - 0 + - - 1 + - 4 + - 0 + - - 2 + - 4 + - 0 + - - 3 + - 4 + - 0 + - - 4 + - 4 + - 0 + - - 5 + - 4 + - 0 + - - 6 + - 4 + - 0 + - - 7 + - 4 + - 0 + - - 8 + - 4 + - 0 + - - 9 + - 4 + - 1 + - - -9 + - 5 + - 1 + - - -8 + - 5 + - 1 + - - -7 + - 5 + - 1 + - - -6 + - 5 + - 1 + - - -5 + - 5 + - 1 + - - -4 + - 5 + - 1 + - - -3 + - 5 + - 1 + - - -2 + - 5 + - 1 + - - -1 + - 5 + - 1 + - - 0 + - 5 + - 1 + - - 1 + - 5 + - 1 + - - 2 + - 5 + - 1 + - - 3 + - 5 + - 1 + - - 4 + - 5 + - 1 + - - 5 + - 5 + - 1 + - - 6 + - 5 + - 1 + - - 7 + - 5 + - 1 + - - 8 + - 5 + - 1 + - - 9 + - 5 + - 1 + tileDisplaySize: + - 48 + - 48 +- id: host-spawn + name: Host Spawn + enabled: true + components: + AdaRender.Visibility: + value: hidden + AdaTransform.Transform: + rotation: + - 0 + - 0 + - 0 + - 1 + position: + - -72 + - 0 + - 2 + scale: + - 1 + - 1 + - 1 +- id: peer-spawn + name: Peer Spawn + enabled: true + components: + AdaTransform.Transform: + position: + - 72 + - 0 + - 2 + rotation: + - 0 + - 0 + - 0 + - 1 + scale: + - 1 + - 1 + - 1 + AdaRender.Visibility: + value: hidden +- id: north-spawn + name: North Spawn + enabled: true + components: + AdaRender.Visibility: + value: hidden + AdaTransform.Transform: + rotation: + - 0 + - 0 + - 0 + - 1 + scale: + - 1 + - 1 + - 1 + position: + - 0 + - 100 + - 2 +- id: south-spawn + name: South Spawn + enabled: true + components: + AdaTransform.Transform: + rotation: + - 0 + - 0 + - 0 + - 1 + position: + - 0 + - -100 + - 2 + scale: + - 1 + - 1 + - 1 + AdaRender.Visibility: + value: hidden +editor: + selectedEntity: arena-tilemap + expandedEntities: + - arena-tilemap + - host-spawn + - north-spawn + - south-spawn + - peer-spawn diff --git a/Demos/MedievalArena/Assets/Tiles/tile_0000.png b/Demos/MedievalArena/Assets/Tiles/tile_0000.png new file mode 100644 index 000000000..6bd64a797 Binary files /dev/null and b/Demos/MedievalArena/Assets/Tiles/tile_0000.png differ diff --git a/Demos/MedievalArena/Assets/Tiles/tile_0014.png b/Demos/MedievalArena/Assets/Tiles/tile_0014.png new file mode 100644 index 000000000..bec979d97 Binary files /dev/null and b/Demos/MedievalArena/Assets/Tiles/tile_0014.png differ diff --git a/Demos/MedievalArena/Assets/Tiles/tile_0096.png b/Demos/MedievalArena/Assets/Tiles/tile_0096.png new file mode 100644 index 000000000..ed3029baa Binary files /dev/null and b/Demos/MedievalArena/Assets/Tiles/tile_0096.png differ diff --git a/Demos/MedievalArena/Assets/Tiles/tile_0097.png b/Demos/MedievalArena/Assets/Tiles/tile_0097.png new file mode 100644 index 000000000..b0106b2d4 Binary files /dev/null and b/Demos/MedievalArena/Assets/Tiles/tile_0097.png differ diff --git a/Demos/MedievalArena/Assets/Tiles/tile_0098.png b/Demos/MedievalArena/Assets/Tiles/tile_0098.png new file mode 100644 index 000000000..339537161 Binary files /dev/null and b/Demos/MedievalArena/Assets/Tiles/tile_0098.png differ diff --git a/Demos/MedievalArena/Assets/Tiles/tile_0100.png b/Demos/MedievalArena/Assets/Tiles/tile_0100.png new file mode 100644 index 000000000..be9422cd4 Binary files /dev/null and b/Demos/MedievalArena/Assets/Tiles/tile_0100.png differ diff --git a/Demos/MedievalArena/Assets/Tiles/tile_0103.png b/Demos/MedievalArena/Assets/Tiles/tile_0103.png new file mode 100644 index 000000000..23f289dc9 Binary files /dev/null and b/Demos/MedievalArena/Assets/Tiles/tile_0103.png differ diff --git a/Demos/MedievalArena/README.md b/Demos/MedievalArena/README.md new file mode 100644 index 000000000..e8457e36d --- /dev/null +++ b/Demos/MedievalArena/README.md @@ -0,0 +1,38 @@ +# Medieval Arena + +Portable AdaScript multiplayer sample for AdaEditor and AdaPlayer. The project +contains no Swift package and no native game sources. The precompiled +`multiplayer` capability only transports detached command/snapshot payloads; +all Medieval Arena rules stay in this directory: + +- `ArenaState.ada` owns player state and game constants; +- `ArenaInput.ada` maps configured actions into local/network input; +- `ArenaGameplay.ada` runs authoritative movement, sword hit tests, damage, + three hearts, defeat/respawn, and publishes snapshots; +- `ArenaPresentation.ada` creates player/heart visuals and sword animation; +- `Assets/Scenes/Main.ascn` owns the populated TileMap and spawn markers. + +## Run + +Open this directory in AdaEditor and press Play. The checked-in configuration is +the authoritative Host on local TCP port `37778`. + +To run a Peer from a second copy of the project, change only these values in +`.ada/project.json`: + +```json +"multiplayer": { + "host": "::1", + "peerIndex": 2, + "port": 37778, + "role": "peer" +} +``` + +Use the Host's LAN address instead of `::1` for another Mac on the same network. + +Controls: `WASD` or arrow keys to move, `Space` to swing the sword. The Host owns +the world, players have three hearts, and defeated players respawn after two +seconds. + +Tiles are from Kenney Tiny Dungeon and are licensed CC0 1.0. diff --git a/Demos/MedievalArena/Sources/ArenaGameplay.ada b/Demos/MedievalArena/Sources/ArenaGameplay.ada new file mode 100644 index 000000000..77a91f302 --- /dev/null +++ b/Demos/MedievalArena/Sources/ArenaGameplay.ada @@ -0,0 +1,124 @@ +import { ArenaGame } from "./ArenaState.ada"; + +@after(id: "arena.input") +@before(id: "arena.presentation") +@system(scheduler: "update", id: "arena.gameplay") +class ArenaGameplaySystem { + @res var multiplayer: AdaScriptMultiplayerState; + + func update(context) { + if (multiplayer.role != "host") return; + + ArenaGame.ensurePlayer(multiplayer.localPeerID, 0); + var variant = 1; + for (var peer in multiplayer.peerIDs) { + ArenaGame.ensurePlayer(peer, variant); + variant += 1; + } + + var inputs = [:]; + inputs[multiplayer.localPeerID] = [ArenaGame.moveX, ArenaGame.moveY, ArenaGame.attackSequence]; + for (var envelope in multiplayer.receivedCommands) { + if (envelope.count >= 3) { + var payload = envelope[2]; + if (payload.count >= 3) inputs[envelope[0]] = payload; + } + } + + stepPlayer(multiplayer.localPeerID, inputs[multiplayer.localPeerID], context.deltaTime); + for (var peer in multiplayer.peerIDs) { + var peerInput = inputs[peer]; + if (peerInput != null) stepPlayer(peer, peerInput, context.deltaTime); + } + + ArenaGame.snapshotClock += context.deltaTime; + if (ArenaGame.snapshotClock >= 0.05) { + ArenaGame.snapshotClock = 0.0; + publishSnapshot(); + } + } + + func stepPlayer(peer, input, deltaTime) { + var player = ArenaGame.players[peer]; + if (player == null) return; + + if (player[2] <= 0) { + player[6] -= deltaTime; + if (player[6] <= 0.0) { + player[2] = 3; + player[6] = 0.0; + if (player[7] == 0) { player[0] = -72.0; player[1] = 0.0; } + if (player[7] == 1) { player[0] = 72.0; player[1] = 0.0; } + if (player[7] == 2) { player[0] = 0.0; player[1] = 100.0; } + if (player[7] == 3) { player[0] = 0.0; player[1] = -100.0; } + } + ArenaGame.players[peer] = player; + return; + } + + var moveX = ArenaGame.clamp(input[0], -1.0, 1.0); + var moveY = ArenaGame.clamp(input[1], -1.0, 1.0); + if (moveX != 0.0 && moveY != 0.0) { + moveX *= 0.707106; + moveY *= 0.707106; + } + player[0] = ArenaGame.clamp(player[0] + moveX * 175.0 * deltaTime, -410.0, 410.0); + player[1] = ArenaGame.clamp(player[1] + moveY * 175.0 * deltaTime, -220.0, 220.0); + if (moveX < -0.01) player[3] = 2; + if (moveX > 0.01) player[3] = 3; + if (moveY < -0.01) player[3] = 1; + if (moveY > 0.01) player[3] = 0; + + var shouldAttack = input[2] > player[5]; + if (shouldAttack) { + player[5] = input[2]; + player[4] += 1; + } + ArenaGame.players[peer] = player; + if (shouldAttack) applyAttack(peer); + } + + func applyAttack(attackerPeer) { + var attacker = ArenaGame.players[attackerPeer]; + for (var targetPeer in ArenaGame.players.keys()) { + if (targetPeer != attackerPeer) { + var target = ArenaGame.players[targetPeer]; + if (target[2] > 0) { + var dx = target[0] - attacker[0]; + var dy = target[1] - attacker[1]; + var inReach = dx * dx + dy * dy <= 3844.0; + var inFront = false; + if (attacker[3] == 0 && dy > 10.0) inFront = true; + if (attacker[3] == 1 && dy < -10.0) inFront = true; + if (attacker[3] == 2 && dx < -10.0) inFront = true; + if (attacker[3] == 3 && dx > 10.0) inFront = true; + if (inReach && inFront) { + target[2] -= 1; + if (target[2] <= 0) { + target[2] = 0; + target[6] = 2.0; + } + ArenaGame.players[targetPeer] = target; + } + } + } + } + } + + func publishSnapshot() { + var snapshot = []; + for (var peer in ArenaGame.players.keys()) { + var player = ArenaGame.players[peer]; + snapshot.push(peer); + snapshot.push(player[0]); + snapshot.push(player[1]); + snapshot.push(player[2]); + snapshot.push(player[3]); + snapshot.push(player[4]); + snapshot.push(player[6]); + snapshot.push(player[7]); + } + multiplayer.publishedSnapshot = snapshot; + multiplayer.publishedSnapshotSequence += 1; + } +} diff --git a/Demos/MedievalArena/Sources/ArenaInput.ada b/Demos/MedievalArena/Sources/ArenaInput.ada new file mode 100644 index 000000000..f3d329013 --- /dev/null +++ b/Demos/MedievalArena/Sources/ArenaInput.ada @@ -0,0 +1,21 @@ +import { ArenaGame } from "./ArenaState.ada"; + +@before(id: "arena.gameplay") +@system(scheduler: "update", id: "arena.input") +class ArenaInputSystem { + @res var input: Input; + @res var multiplayer: AdaScriptMultiplayerState; + + func update(context) { + ArenaGame.moveX = input.getActionStrength("MoveRight") - input.getActionStrength("MoveLeft"); + ArenaGame.moveY = input.getActionStrength("MoveUp") - input.getActionStrength("MoveDown"); + if (input.isActionJustPressed("Attack")) { + ArenaGame.attackSequence += 1; + } + + if (multiplayer.role == "peer") { + multiplayer.outgoingCommand = [ArenaGame.moveX, ArenaGame.moveY, ArenaGame.attackSequence]; + multiplayer.outgoingCommandSequence += 1; + } + } +} diff --git a/Demos/MedievalArena/Sources/ArenaPresentation.ada b/Demos/MedievalArena/Sources/ArenaPresentation.ada new file mode 100644 index 000000000..827124110 --- /dev/null +++ b/Demos/MedievalArena/Sources/ArenaPresentation.ada @@ -0,0 +1,118 @@ +import { ArenaGame } from "./ArenaState.ada"; + +@after(id: "arena.gameplay") +@system(scheduler: "update", id: "arena.presentation") +class ArenaPresentationSystem { + @res var multiplayer: AdaScriptMultiplayerState; + @query(Transform, Sprite) var visuals; + + func update(context) { + var snapshot = multiplayer.receivedSnapshot; + if (multiplayer.role == "host") snapshot = multiplayer.publishedSnapshot; + + var index = 0; + while (index + 7 < snapshot.count) { + var peer = snapshot[index]; + var state = [ + snapshot[index + 1], snapshot[index + 2], snapshot[index + 3], + snapshot[index + 4], snapshot[index + 5], snapshot[index + 6], + snapshot[index + 7] + ]; + ensureVisuals(peer, state, context); + presentAttack(peer, state, context); + ArenaGame.players[peer] = [ + state[0], state[1], state[2], state[3], state[4], 0, state[5], state[6] + ]; + index += 8; + } + + for (var row in visuals) { + updateVisual(row, context); + } + } + + func ensureVisuals(peer, state, context) { + if (ArenaGame.playerEntities[peer] == null) { + ArenaGame.playerEntities[peer] = spawnVisual( + Vector3(state[0], state[1], 4.0), + context + ); + } + if (ArenaGame.heartEntities[peer] == null) { + ArenaGame.heartEntities[peer] = [ + spawnVisual(Vector3(state[0] - 17.0, state[1] + 36.0, 7.0), context), + spawnVisual(Vector3(state[0], state[1] + 36.0, 7.0), context), + spawnVisual(Vector3(state[0] + 17.0, state[1] + 36.0, 7.0), context) + ]; + } + } + + func spawnVisual(position, context) { + return context.world.spawn([ + Transform(position: position, scale: Vector3(2, 2, 2)), + Sprite() + ]); + } + + func presentAttack(peer, state, context) { + var previous = ArenaGame.lastPresentedAttack[peer]; + if (previous != null && previous != state[4]) { + var oldSword = ArenaGame.swords[peer]; + if (oldSword != null) context.world.commands.despawn(oldSword[0]); + var sword = spawnVisual(Vector3(state[0], state[1], 8.0), context); + ArenaGame.swords[peer] = [sword, 0.18, state[3], state[0], state[1]]; + } + ArenaGame.lastPresentedAttack[peer] = state[4]; + } + + func updateVisual(row, context) { + for (var peer in ArenaGame.players.keys()) { + var player = ArenaGame.players[peer]; + if (row.id == ArenaGame.playerEntities[peer]) { + row.transform.position = [player[0], player[1], 4.0]; + row.transform.scale = [42.0, 46.0, 1.0]; + row.sprite.flipX = player[3] == 2; + var alpha = 1.0; + if (player[2] <= 0) alpha = 0.25; + row.sprite.tintColor = ArenaGame.playerColor(player[7], alpha); + } + + var hearts = ArenaGame.heartEntities[peer]; + if (hearts != null) { + var heartIndex = 0; + while (heartIndex < 3) { + if (row.id == hearts[heartIndex]) { + row.transform.position = [player[0] + (heartIndex - 1) * 17.0, player[1] + 36.0, 7.0]; + row.transform.scale = [11.0, 11.0, 1.0]; + if (heartIndex < player[2]) row.sprite.tintColor = [1.0, 0.16, 0.22, 1.0]; + else row.sprite.tintColor = [0.25, 0.25, 0.3, 0.65]; + } + heartIndex += 1; + } + } + + var sword = ArenaGame.swords[peer]; + if (sword != null) { + if (row.id == sword[0]) { + sword[1] -= context.deltaTime; + var offsetX = 0.0; + var offsetY = 0.0; + if (sword[2] == 0) offsetY = 34.0; + if (sword[2] == 1) offsetY = -34.0; + if (sword[2] == 2) offsetX = -34.0; + if (sword[2] == 3) offsetX = 34.0; + row.transform.position = [sword[3] + offsetX, sword[4] + offsetY, 8.0]; + if (sword[2] < 2) row.transform.scale = [8.0, 38.0, 1.0]; + else row.transform.scale = [38.0, 8.0, 1.0]; + row.sprite.tintColor = [0.92, 0.94, 1.0, sword[1] / 0.18]; + if (sword[1] <= 0.0) { + context.world.commands.despawn(sword[0]); + ArenaGame.swords.remove(peer); + } else { + ArenaGame.swords[peer] = sword; + } + } + } + } + } +} diff --git a/Demos/MedievalArena/Sources/ArenaState.ada b/Demos/MedievalArena/Sources/ArenaState.ada new file mode 100644 index 000000000..35f31d25a --- /dev/null +++ b/Demos/MedievalArena/Sources/ArenaState.ada @@ -0,0 +1,42 @@ +// All state and rules below belong to Medieval Arena, not to AdaEngine. +class ArenaGame { + static var moveX = 0.0; + static var moveY = 0.0; + static var attackSequence = 0; + static var snapshotClock = 0.0; + + // peer -> [x, y, health, facing, attackSequence, consumedInput, respawn, variant] + static var players = [:]; + static var playerEntities = [:]; + static var heartEntities = [:]; + static var swords = [:]; + static var lastPresentedAttack = [:]; + + static func ensurePlayer(peer, variant) { + if (players[peer] != null) return; + var x = 0.0; + var y = 0.0; + if (variant == 0) x = -72.0; + if (variant == 1) x = 72.0; + if (variant == 2) y = 100.0; + if (variant == 3) y = -100.0; + if (variant == 4) { x = -180.0; y = 90.0; } + if (variant >= 5) { x = 180.0; y = -90.0; } + var facing = 3; + if (variant != 0) facing = 2; + players[peer] = [x, y, 3, facing, 0, 0, 0.0, variant]; + } + + static func clamp(value, lower, upper) { + if (value < lower) return lower; + if (value > upper) return upper; + return value; + } + + static func playerColor(variant, alpha) { + if (variant % 4 == 0) return [0.25, 0.72, 1.0, alpha]; + if (variant % 4 == 1) return [1.0, 0.62, 0.24, alpha]; + if (variant % 4 == 2) return [0.45, 0.92, 0.42, alpha]; + return [0.86, 0.42, 1.0, alpha]; + } +} diff --git a/Documentation/ArchitectureDecisions/0011-multiplayer-runtime-boundaries.md b/Documentation/ArchitectureDecisions/0011-multiplayer-runtime-boundaries.md new file mode 100644 index 000000000..49b0cabcb --- /dev/null +++ b/Documentation/ArchitectureDecisions/0011-multiplayer-runtime-boundaries.md @@ -0,0 +1,52 @@ +# ADR-0011: Keep multiplayer transport-independent and optional + +- Status: Accepted +- Date: 2026-09-20 +- Implementation: Partial (foundation shipped) + +## Context + +AdaEngine needs one multiplayer model for a player-hosted game, a headless +authoritative process, local networking, and internet relay. RealityKit's scene +synchronization is convenient but Apple- and RealityKit-specific. Bevy's +networking ecosystem instead separates transport, messages, replication, and +presentation correction. + +Networking callbacks must not retain or mutate `World`: AdaECS mutation is +valid only inside scheduled systems with declared access. Multiplayer must also +remain optional for offline games and replaceable for projects with a custom +backend. + +## Decision + +`AdaMultiplayer` is a separate SwiftPM library and an ordinary AdaEngine +`Plugin`. It is not part of `DefaultPlugins` and is not a SwiftPM build plugin. + +The runtime has four layers: + +1. `MultiplayerTransport` moves opaque bytes and publishes `Sendable` events. +2. A versioned binary envelope performs compatibility and framing. +3. Typed RPC and replication registries map stable wire identifiers to Codable + values; Swift type names are never wire identity. +4. ECS systems drain received packets, mutate the world, capture authoritative + state, and apply presentation interpolation. + +`networkReceive`, `networkSend`, and `networkInterpolate` are canonical main +scheduler stages. Transport callbacks only enqueue values. Type-erased world +mutation runs with exclusive system access. + +Third parties may supply a transport, codec, replication policy, or ordinary +AdaEngine plugin that registers components and messages. These extensions +receive scoped capabilities and never an unscheduled transport-to-`World` +escape hatch. + +## Consequences + +- Offline applications pay only for empty scheduler stages unless they install + `MultiplayerPlugin`. +- Platform transports can evolve independently of game protocol semantics. +- A custom plugin must install after `MultiplayerPlugin` so its registrations + enter the world-scoped registry before the first connection handshake. +- The foundation currently includes the registry, sessions, in-memory + transport, WebSocket client, replication, and RPC. LAN QUIC and WASI runtime + proof remain required before this ADR is fully implemented. diff --git a/Documentation/ArchitectureDecisions/0012-host-authoritative-replication-and-rpc.md b/Documentation/ArchitectureDecisions/0012-host-authoritative-replication-and-rpc.md new file mode 100644 index 000000000..a202d573f --- /dev/null +++ b/Documentation/ArchitectureDecisions/0012-host-authoritative-replication-and-rpc.md @@ -0,0 +1,59 @@ +# ADR-0012: Use host-authoritative replication and typed RPC + +- Status: Accepted +- Date: 2026-09-20 +- Implementation: Partial (foundation shipped) + +## Context + +The first multiplayer version must support responsive cooperative scenes +without committing AdaEngine to deterministic rollback. It must nevertheless +preserve enough protocol state to add prediction, reconciliation, unreliable +snapshots, and host migration later without replacing entity identity or RPC. + +Automatically serializing every Codable ECS component would leak local render, +editor, and runtime state. Requiring every entity field to be wired manually +would lose the RealityKit-like authoring experience. + +## Decision + +The topology is a logical star. One Host owns the authoritative world. Peers +send input, commands, and requests to Host; only Host emits replicated state. +Peer-to-peer messages are routed and authorized through Host. + +`ReplicatedEntity` opts an entity into replication. The host assigns its stable +`NetworkEntityID`; local `Entity.ID` never crosses the network. All component +types on that entity that were registered with a stable identifier and version +are synchronized automatically. Other components remain local. + +The protocol carries spawn, component set/removal, and despawn operations. +Late joins receive a baseline followed by ordered deltas. Snapshot rate is +independent of fixed simulation rate and defaults to 20 Hz. Registered +interpolation functions update presentation state after authoritative capture, +so interpolated values are not sent back as host state. + +RPC is typed: + +- `NetworkCommand` is normally peer-to-host and one-way; +- `NetworkEvent` is normally host-to-peer and one-way; +- `NetworkRequest` has an associated Codable response, correlation identifier, + timeout, and cancellation. + +Messages are explicitly registered with direction, version, payload limit, and +stable identifier. Incoming messages become scheduler-scoped +`RemoteCommands`, `RemoteEvents`, or `RemoteRequests`; the protocol never calls +a Swift method selected by a remote string. + +Every frame reserves protocol major/minor, session epoch, sequence, simulation +tick, type version, and correlation identity. The handshake includes game, +build, and schema identities. These fields are normative even where v1 uses +epoch zero and reliable ordered delivery. + +## Deferred behavior + +- Client prediction, input replay, reconciliation, rollback, lag compensation, + spatial interest management, and component field deltas are later plugins. +- Host migration will use a new epoch and checkpoint. In v1, losing Host after + the disconnect grace period ends the session. +- Snapshot capture may optimize with direct ECS change ticks, but the observable + wire result must remain component-level deltas with baseline recovery. diff --git a/Documentation/ArchitectureDecisions/0013-multiplayer-transports-and-cloud-relay.md b/Documentation/ArchitectureDecisions/0013-multiplayer-transports-and-cloud-relay.md new file mode 100644 index 000000000..a086cb109 --- /dev/null +++ b/Documentation/ArchitectureDecisions/0013-multiplayer-transports-and-cloud-relay.md @@ -0,0 +1,53 @@ +# ADR-0013: Use Apple LAN networking and an AdaEngine Cloud relay + +- Status: Accepted +- Date: 2026-09-20 +- Implementation: Partial (foundation shipped) + +## Context + +Apple clients need local discovery without a Cloud dependency. Browser clients +cannot accept arbitrary inbound LAN connections and deployed HTTPS games cannot +reliably connect to an untrusted local WebSocket certificate. Internet player +hosting also cannot assume port forwarding or public addresses. + +## Decision + +Apple-to-Apple LAN sessions use Network.framework discovery and a reliable +connection owned by `AppleLocalTransport`. QUIC is the target protocol because +it supports secure reliable streams and a later best-effort datagram channel. +The local session identity is pinned and shown through an application-provided +verification UI. Multipeer Connectivity and RealityKit synchronization are not +runtime dependencies. + +Internet sessions use `CloudWebSocketTransport`. All participants, including a +player Host, create an outbound WSS connection to `MultiplayerRelay`. Web uses +the same route and never attempts direct LAN hosting in v1. + +AdaEngine Cloud exposes: + +- authenticated session creation for an account Host; +- guest join by an eight-character code; +- single-use 60-second connection tickets sent in the first WebSocket message, + never in the URL; +- Redis-backed room, code, and ticket TTLs; +- one relay process per environment in v1. + +The relay validates session membership, frame size, rate, and star-topology +targets. It decodes only the routing envelope and does not persist gameplay +payloads. TLS terminates at Cloud; end-to-end encryption is not promised by v1. +Slow consumers have bounded queues and are disconnected rather than allowing +unbounded memory growth. + +Multiplayer follows the existing Cloud regional rollout. Create requires an +authenticated account in an allowed region; guest join and connect require an +allowed region but no account. A feature flag can disable all multiplayer +routes independently. + +## Consequences + +- Host loss closes the room; the relay never becomes authoritative simulation. +- Native Linux, Windows, and Android receive the transport protocol and can + provide adapters, but no built-in production adapter is required in v1. +- Horizontal relay scale requires session affinity or explicit room sharding + and is deferred until the single-replica service has load evidence. diff --git a/Documentation/ArchitectureDecisions/README.md b/Documentation/ArchitectureDecisions/README.md index fc8f01945..87083c87b 100644 --- a/Documentation/ArchitectureDecisions/README.md +++ b/Documentation/ArchitectureDecisions/README.md @@ -32,3 +32,11 @@ describes the intended design even when its implementation is still planned. | [ADR-0008](0008-adascript-projects-on-ipados.md) | Accepted | Partial (project foundation shipped) | Portable AdaScript projects, iPadOS runtime sessions, Files/iCloud, and Git ownership | | [ADR-0009](0009-adascript-runtime-configuration.md) | Accepted | Partial (foundation shipped) | Declarative entry plans, plugin presets, typed settings, and runtime-window configuration | | [ADR-0010](0010-adascript-native-adaui-extension-registry.md) | Accepted | Planned | Versioned descriptors and host factories for native AdaUI views and modifiers used by AdaScript | + +## Multiplayer decisions + +| ADR | Status | Implementation | Decision | +| --- | --- | --- | --- | +| [ADR-0011](0011-multiplayer-runtime-boundaries.md) | Accepted | Partial (foundation shipped) | Optional transport-independent multiplayer runtime and plugin extension model | +| [ADR-0012](0012-host-authoritative-replication-and-rpc.md) | Accepted | Partial (foundation shipped) | Host-authoritative marker replication, interpolation, and typed RPC | +| [ADR-0013](0013-multiplayer-transports-and-cloud-relay.md) | Accepted | Partial (foundation shipped) | Apple LAN transport and region-gated AdaEngine Cloud WebSocket relay | diff --git a/Editor/Package.resolved b/Editor/Package.resolved index 03ae8af2c..3cc1dad41 100644 --- a/Editor/Package.resolved +++ b/Editor/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "d97edbf3643673a5cca3062d60d9a795952df0433b04227430bf4fc0d910bd43", + "originHash" : "939640b6db05051a19f55a093cc1a3c3472c136454dd84a76438e3ac713e71c1", "pins" : [ { "identity" : "gravity-lang", "kind" : "remoteSourceControl", "location" : "https://github.com/AdaEngine/gravity-lang.git", "state" : { - "revision" : "664dfb05430be6303dc5da05210476d8b91de7f4" + "revision" : "ecfb4a53d719163ff84f84bc245315140c075229", + "version" : "0.9.9" } }, { @@ -50,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark.git", "state" : { - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", - "version" : "0.8.0" + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" } }, { @@ -86,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-markdown.git", "state" : { - "revision" : "3c6f9523da3a1ec2fd829673e472d95b8097a3b8", - "version" : "0.8.0" + "revision" : "25cb61d3482054b09ae76ca4f281b1bfe7fe5a43", + "version" : "0.9.0" } }, { diff --git a/Editor/Package.swift b/Editor/Package.swift index c1e835be8..79654a8f0 100644 --- a/Editor/Package.swift +++ b/Editor/Package.swift @@ -72,6 +72,7 @@ let package = Package( .product(name: "AdaDebugging", package: "AdaDebugging"), .product(name: "AdaPlayerConnect", package: "AdaPlayerConnect"), .product(name: "AdaEngine", package: "AdaEngine"), + .product(name: "AdaMultiplayer", package: "AdaEngine"), .product(name: "AdaScriptCompilerCore", package: "AdaEngine"), .product(name: "Math", package: "AdaEngine"), .product(name: "AdaMCPPlugin", package: "AdaMCP"), diff --git a/Editor/Platforms/AdaEditor-macOS.entitlements b/Editor/Platforms/AdaEditor-macOS.entitlements index 4daa3e62c..dd58e3440 100644 --- a/Editor/Platforms/AdaEditor-macOS.entitlements +++ b/Editor/Platforms/AdaEditor-macOS.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.network.server + diff --git a/Editor/Sources/AdaEditor/AdaEditorApp.swift b/Editor/Sources/AdaEditor/AdaEditorApp.swift index 457c70771..1f89db1ee 100644 --- a/Editor/Sources/AdaEditor/AdaEditorApp.swift +++ b/Editor/Sources/AdaEditor/AdaEditorApp.swift @@ -26,6 +26,7 @@ enum AdaApplicationEntry { struct AdaEditorApp: App { init() { + EditorComponentRegistry.registerBuiltIns() _ = EditorProjectOpenURLRouter.shared EditorAchievementBootstrap.install() EditorCloudSettingsView.installSync() diff --git a/Editor/Sources/AdaEditor/EditorComponentRegistry.swift b/Editor/Sources/AdaEditor/EditorComponentRegistry.swift index eb73c86cc..4cf531ab7 100644 --- a/Editor/Sources/AdaEditor/EditorComponentRegistry.swift +++ b/Editor/Sources/AdaEditor/EditorComponentRegistry.swift @@ -42,7 +42,7 @@ enum EditorComponentFieldKind: Equatable, Sendable { } extension EditorComponentFieldKind { - init(reflectedKind: EditorFieldKind) { + init(reflectedKind: ReflectedFieldKind) { switch reflectedKind { case .bool: self = .bool @@ -220,7 +220,7 @@ enum EditorComponentRegistry { static var descriptors: [EditorComponentDescriptor] { let overrideNames = Set(overrideDescriptors.map(\.typeName)) let reflectedDescriptors = - EditorComponentReflectionRegistry + ComponentReflectionRegistry .allDescriptors() .filter { !overrideNames.contains($0.typeName) } .map(editorDescriptor(from:)) @@ -257,10 +257,23 @@ enum EditorComponentRegistry { static func registerBuiltIns() { DisplayLayout.registerRuntimeType() RuntimeTypeRegistry.registerComponent(CompanionPanel.self, names: ["CompanionPanel"]) - RuntimeTypeRegistry.registerComponent(Transform.self, names: ["Transform"]) + RuntimeTypeRegistry.registerComponent( + Transform.self, + names: ["Transform"], + makeDefault: { Transform() } + ) RuntimeTypeRegistry.registerComponent(GlobalTransform.self, names: ["GlobalTransform"]) RuntimeTypeRegistry.registerComponent(Camera.self, names: ["Camera"]) - RuntimeTypeRegistry.registerComponent(Sprite.self, names: ["Sprite"]) + RuntimeTypeRegistry.registerComponent( + Sprite.self, + names: ["Sprite"], + makeDefault: { Sprite(texture: Texture2D.whiteTexture) } + ) + RuntimeTypeRegistry.registerComponent( + NoFrustumCulling.self, + names: ["NoFrustumCulling", String(reflecting: NoFrustumCulling.self)], + makeDefault: { NoFrustumCulling() } + ) RuntimeTypeRegistry.registerComponent(Visibility.self, names: ["Visibility"]) RuntimeTypeRegistry.registerComponent(BoundingComponent.self, names: ["BoundingComponent"]) RuntimeTypeRegistry.registerComponent(Light2D.self, names: ["Light2D"]) @@ -278,20 +291,20 @@ enum EditorComponentRegistry { RuntimeTypeRegistry.registerComponent(Environment3D.self, names: ["Environment3D"]) RuntimeTypeRegistry.registerComponent(TileMapComponent.self, names: ["TileMapComponent"]) - EditorComponentReflectionRegistry.register(Transform.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(GlobalTransform.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(Camera.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(Sprite.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(Visibility.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(BoundingComponent.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(Light2D.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(LightOccluder2D.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(LightModulate2D.editorComponentDescriptor) - EditorComponentReflectionRegistry.register(SceneInstance.editorComponentDescriptor) + ComponentReflectionRegistry.register(Transform.componentDescriptor) + ComponentReflectionRegistry.register(GlobalTransform.componentDescriptor) + ComponentReflectionRegistry.register(Camera.componentDescriptor) + ComponentReflectionRegistry.register(Sprite.componentDescriptor) + ComponentReflectionRegistry.register(Visibility.componentDescriptor) + ComponentReflectionRegistry.register(BoundingComponent.componentDescriptor) + ComponentReflectionRegistry.register(Light2D.componentDescriptor) + ComponentReflectionRegistry.register(LightOccluder2D.componentDescriptor) + ComponentReflectionRegistry.register(LightModulate2D.componentDescriptor) + ComponentReflectionRegistry.register(SceneInstance.componentDescriptor) } static func descriptor(named typeName: String) -> EditorComponentDescriptor? { - overrideDescriptorsByName[typeName] ?? EditorComponentReflectionRegistry.descriptor(named: typeName).map(editorDescriptor(from:)) + overrideDescriptorsByName[typeName] ?? ComponentReflectionRegistry.descriptor(named: typeName).map(editorDescriptor(from:)) } static func addableDescriptors(for entity: EditorSceneEntity?) -> [EditorComponentDescriptor] { @@ -322,7 +335,7 @@ enum EditorComponentRegistry { return value as? any Component } - private static func editorDescriptor(from descriptor: AdaECS.EditorComponentDescriptor) -> EditorComponentDescriptor { + private static func editorDescriptor(from descriptor: AdaECS.ReflectedComponentDescriptor) -> EditorComponentDescriptor { EditorComponentDescriptor( typeName: descriptor.typeName, displayName: descriptor.displayName, @@ -334,7 +347,7 @@ enum EditorComponentRegistry { key: $0.key, label: $0.label, kind: EditorComponentFieldKind(reflectedKind: $0.kind), - isEditable: $0.isEditable + isEditable: $0.isWritable ) }, makeDefaultPayload: { [:] }, diff --git a/Editor/Sources/AdaEditor/EditorMenuBar.swift b/Editor/Sources/AdaEditor/EditorMenuBar.swift index 0bad96a3c..4224cb447 100644 --- a/Editor/Sources/AdaEditor/EditorMenuBar.swift +++ b/Editor/Sources/AdaEditor/EditorMenuBar.swift @@ -13,8 +13,9 @@ import AdaEngine enum EditorMenuCommand: CaseIterable { case checkForUpdates case showSettings + case debugOverlayOff, debugOverlayRedraw, debugOverlayLayoutBounds, debugOverlayHitTestTarget, debugOverlayFocusedNode case newFile, newProject, openProject, importAssets, save, saveAll, closeEditor - case undo, redo, cut, copy, paste, selectAll, findInProject + case undo, redo, cut, copy, paste, selectAll, findInFile, findInProject case navigateBack, navigateForward, showProjectNavigator, showInspector, showBuildOutput, showProblems, enterFullScreen case refreshProjectFiles, revealProject, openProjectInTerminal, showProjectSettings, showProjectDependencies, showPackageTasks case build, run, runTests, stop, clean, updateDependencies @@ -126,6 +127,12 @@ enum EditorMenuBar { if EditorDistribution.current == .standalone { menu.add(item("Check for Updates…", command: .checkForUpdates)) } + menu.add(MenuItem.separator) + menu.add(item("Disable Debug Overlay", command: .debugOverlayOff)) + menu.add(item("Debug Overlay: Redraw", command: .debugOverlayRedraw)) + menu.add(item("Debug Overlay: Layout Bounds", command: .debugOverlayLayoutBounds)) + menu.add(item("Debug Overlay: Hit Test Target", command: .debugOverlayHitTestTarget)) + menu.add(item("Debug Overlay: Focused Node", command: .debugOverlayFocusedNode)) return menu } @@ -159,6 +166,7 @@ enum EditorMenuBar { item("Paste", command: .paste, key: .v), item("Select All", command: .selectAll, key: .a), MenuItem.separator, + item("Find in File", command: .findInFile, key: .f), item("Find in Project", command: .findInProject, key: .f, modifiers: [.main, .shift]), ] ) diff --git a/Editor/Sources/AdaEditor/EditorTileMapComponentDescriptor.swift b/Editor/Sources/AdaEditor/EditorTileMapComponentDescriptor.swift index 53afd24e8..383d7b250 100644 --- a/Editor/Sources/AdaEditor/EditorTileMapComponentDescriptor.swift +++ b/Editor/Sources/AdaEditor/EditorTileMapComponentDescriptor.swift @@ -6,12 +6,21 @@ extension EditorComponentRegistry { displayName: "Tile Map", category: "2D", description: "Displays an editable tile map with a configurable tile size.", - requiredComponentTypeNames: [EditorBuiltInComponentType.transform], + requiredComponentTypeNames: [ + EditorBuiltInComponentType.transform, + String(reflecting: NoFrustumCulling.self), + ], fields: [ - EditorComponentField(key: "tileDisplaySize", label: "Tile Size", kind: .vector2) + EditorComponentField(key: "tileDisplaySize", label: "Tile Size", kind: .vector2), + EditorComponentField(key: "atlasColors", label: "Inline Atlas Colors", kind: .readOnly), + EditorComponentField(key: "cells", label: "Inline Cells", kind: .readOnly), ], makeDefaultPayload: { - ["tileDisplaySize": .array([.double(16), .double(16)])] + [ + "tileDisplaySize": .array([.double(16), .double(16)]), + "atlasColors": .array([]), + "cells": .array([]), + ] }, decode: { payload in let size: Vector2 @@ -23,8 +32,33 @@ extension EditorComponentRegistry { } else { size = Vector2(16, 16) } + let tileMap = TileMap() + if case let .array(colorValues) = payload["atlasColors"], !colorValues.isEmpty { + var atlas = Image(width: colorValues.count, height: 1, color: .white) + for (index, colorValue) in colorValues.enumerated() { + atlas.setPixel(in: [Float(index), 0], color: colorValue.colorValue ?? .white) + } + let source = TextureAtlasTileSource(from: atlas, size: [1, 1]) + source.name = "Inline scene atlas" + for index in colorValues.indices { + source.createTile(for: [index, 0]) + } + let sourceID = tileMap.tileSet.addTileSource(source) + if case let .array(cells) = payload["cells"] { + for cell in cells { + guard case let .array(values) = cell, values.count >= 3 else { + continue + } + tileMap.layers[0].setCell( + at: [Int(values[0].doubleValue ?? 0), Int(values[1].doubleValue ?? 0)], + sourceId: sourceID, + atlasCoordinates: [Int(values[2].doubleValue ?? 0), 0] + ) + } + } + } return TileMapComponent( - tileMap: TileMap(), + tileMap: tileMap, tileDisplaySize: Size(width: size.x, height: size.y) ) } diff --git a/Editor/Sources/AdaEditor/Multiplayer/AdaScriptMultiplayerPlugin.swift b/Editor/Sources/AdaEditor/Multiplayer/AdaScriptMultiplayerPlugin.swift new file mode 100644 index 000000000..e582a3203 --- /dev/null +++ b/Editor/Sources/AdaEditor/Multiplayer/AdaScriptMultiplayerPlugin.swift @@ -0,0 +1,59 @@ +import AdaEngine +import AdaMultiplayer +import Foundation + +struct EditorAdaScriptMultiplayerPlugin: Plugin { + private let settings: AdaProjectMultiplayerSettings + + init(settings: AdaProjectMultiplayerSettings) { + self.settings = settings + } + + @MainActor + func setup(in app: borrowing AppWorlds) { + let role: NetworkRole = settings.role == "peer" ? .peer : .host + let localPeerID = Self.peerID(role: role, index: settings.peerIndex) + let transport = LocalTCPTransport( + host: settings.host, + port: UInt16(settings.port), + log: { message in + RuntimeLogStore.shared.append( + level: "info", + label: "AdaScript.Multiplayer", + message: message + ) + } + ) + let configuration = MultiplayerConfiguration( + role: role, + sessionID: Self.sessionID, + localPeerID: localPeerID, + compatibility: NetworkCompatibility( + gameIdentifier: settings.gameIdentifier, + buildIdentifier: settings.buildIdentifier + ), + snapshotsPerSecond: 20, + disconnectGracePeriod: 2 + ) + + MultiplayerPlugin(configuration: configuration, transport: transport).setup(in: app) + AdaScriptMultiplayerBridgePlugin(configuration: configuration).setup(in: app) + + RuntimeLogStore.shared.append( + level: "info", + label: "AdaScript.Multiplayer", + message: "launch role=\(role.rawValue) peer=\(localPeerID.rawValue.uuidString)" + ) + } + + private static let sessionID = SessionID( + rawValue: UUID(uuid: (0x4d, 0x45, 0x44, 0x49, 0x45, 0x56, 0x41, 0x4c, 0x80, 0, 0, 0, 0, 0, 0, 1)) + ) + + private static func peerID(role: NetworkRole, index: Int) -> PeerID { + let suffix = UInt8(clamping: role == .host ? 1 : max(1, index)) + return PeerID( + rawValue: UUID(uuid: (0x4d, 0x41, 0x50, 0x45, 0x45, 0x52, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, suffix)) + ) + } +} diff --git a/Editor/Sources/AdaEditor/ProjectRuntimeConfiguration.swift b/Editor/Sources/AdaEditor/ProjectRuntimeConfiguration.swift index 47643e63a..b60645bb3 100644 --- a/Editor/Sources/AdaEditor/ProjectRuntimeConfiguration.swift +++ b/Editor/Sources/AdaEditor/ProjectRuntimeConfiguration.swift @@ -26,6 +26,7 @@ public struct AdaProjectRuntimePluginID: Codable, Equatable, Hashable, RawRepres public static let core3D = Self(rawValue: "core3d") public static let light2D = Self(rawValue: "light2d") public static let mesh2D = Self(rawValue: "mesh2d") + public static let multiplayer = Self(rawValue: "multiplayer") public static let model3D = Self(rawValue: "model3d") public static let physics2D = Self(rawValue: "physics2d") public static let physics3D = Self(rawValue: "physics3d") @@ -35,7 +36,7 @@ public struct AdaProjectRuntimePluginID: Codable, Equatable, Hashable, RawRepres public static let knownValues: Set = [ .audio, .core2D, .core3D, .light2D, .mesh2D, .model3D, - .physics2D, .physics3D, .sprite, .tilemap, .upscale, + .multiplayer, .physics2D, .physics3D, .sprite, .tilemap, .upscale, ] } @@ -61,25 +62,72 @@ public struct AdaProjectPhysics2DSettings: Codable, Equatable, Sendable { } } +public struct AdaProjectMultiplayerSettings: Codable, Equatable, Sendable { + public var buildIdentifier: String + public var gameIdentifier: String + public var host: String + public var peerIndex: Int + public var port: Int + public var role: String + + public init( + role: String = "host", + host: String = "::1", + port: Int = 37_777, + peerIndex: Int = 1, + gameIdentifier: String = "org.adaengine.adascript-game", + buildIdentifier: String = "1" + ) { + self.buildIdentifier = buildIdentifier + self.gameIdentifier = gameIdentifier + self.role = role + self.host = host + self.port = port + self.peerIndex = peerIndex + } + + private enum CodingKeys: String, CodingKey { + case buildIdentifier, gameIdentifier, host, peerIndex, port, role + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + buildIdentifier = try container.decodeIfPresent(String.self, forKey: .buildIdentifier) ?? "1" + gameIdentifier = try container.decodeIfPresent(String.self, forKey: .gameIdentifier) ?? "org.adaengine.adascript-game" + host = try container.decodeIfPresent(String.self, forKey: .host) ?? "::1" + peerIndex = try container.decodeIfPresent(Int.self, forKey: .peerIndex) ?? 1 + port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 37_777 + role = try container.decodeIfPresent(String.self, forKey: .role) ?? "host" + } +} + public struct AdaProjectRuntimePluginSettings: Codable, Equatable, Sendable { + public var multiplayer: AdaProjectMultiplayerSettings public var physics2D: AdaProjectPhysics2DSettings - public init(physics2D: AdaProjectPhysics2DSettings = AdaProjectPhysics2DSettings()) { + public init( + physics2D: AdaProjectPhysics2DSettings = AdaProjectPhysics2DSettings(), + multiplayer: AdaProjectMultiplayerSettings = AdaProjectMultiplayerSettings() + ) { self.physics2D = physics2D + self.multiplayer = multiplayer } private enum CodingKeys: String, CodingKey { + case multiplayer case physics2D = "physics2d" } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) physics2D = try container.decodeIfPresent(AdaProjectPhysics2DSettings.self, forKey: .physics2D) ?? AdaProjectPhysics2DSettings() + multiplayer = try container.decodeIfPresent(AdaProjectMultiplayerSettings.self, forKey: .multiplayer) ?? AdaProjectMultiplayerSettings() } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(physics2D, forKey: .physics2D) + try container.encode(multiplayer, forKey: .multiplayer) } } diff --git a/Editor/Sources/AdaEditor/ProjectSystem+RuntimeValidation.swift b/Editor/Sources/AdaEditor/ProjectSystem+RuntimeValidation.swift index f51eb9033..5495986d1 100644 --- a/Editor/Sources/AdaEditor/ProjectSystem+RuntimeValidation.swift +++ b/Editor/Sources/AdaEditor/ProjectSystem+RuntimeValidation.swift @@ -34,6 +34,25 @@ extension ProjectSystem { message: "Physics2D gravity must contain two finite numbers." ) } + let multiplayer = plugins.settings.multiplayer + guard ["host", "peer"].contains(multiplayer.role) else { + throw .invalidField( + path: "runtime.plugins.settings.multiplayer.role", + message: "Multiplayer role must be host or peer." + ) + } + guard (1...65_535).contains(multiplayer.port) else { + throw .invalidField( + path: "runtime.plugins.settings.multiplayer.port", + message: "Multiplayer port must be between 1 and 65535." + ) + } + guard (1...255).contains(multiplayer.peerIndex) else { + throw .invalidField( + path: "runtime.plugins.settings.multiplayer.peerIndex", + message: "Multiplayer peerIndex must be between 1 and 255." + ) + } } static func validateRuntimeWindow(_ window: AdaProjectRuntimeWindow) throws(ProjectSystemError) { diff --git a/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptRuntimePluginResolver.swift b/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptRuntimePluginResolver.swift index be317e9e5..3e979a04f 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptRuntimePluginResolver.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorAdaScriptRuntimePluginResolver.swift @@ -38,6 +38,7 @@ enum EditorAdaScriptRuntimePluginCatalog { .init(dependencies: [.core2D, .mesh2D, .sprite], displayName: "2D Lighting", id: .light2D), .init(dependencies: [.core2D], displayName: "2D Physics", id: .physics2D), .init(dependencies: [.core3D], displayName: "3D Physics", id: .physics3D), + .init(dependencies: [.core2D, .sprite], displayName: "Multiplayer", id: .multiplayer), .init(dependencies: [.core2D, .mesh2D, .sprite], displayName: "Tilemaps", id: .tilemap), .init(dependencies: [], displayName: "Audio", id: .audio), .init(dependencies: [], displayName: "Upscaling", id: .upscale), @@ -62,6 +63,7 @@ enum EditorAdaScriptRuntimePluginCatalog { ] ), .init("Platform", plugins: [.audio, .upscale]), + .init("Networking", plugins: [.multiplayer]), ] static func presetPlugins(_ preset: AdaProjectRuntimePluginPreset) -> Set { @@ -77,6 +79,7 @@ enum EditorAdaScriptRuntimePluginCatalog { } struct EditorAdaScriptResolvedRuntimePlugins: Equatable, Sendable { + let multiplayer: AdaProjectMultiplayerSettings let pluginIDs: [AdaProjectRuntimePluginID] let physics2DGravity: [Double] @@ -144,6 +147,7 @@ enum EditorAdaScriptRuntimePluginResolver { .map(\.id) .filter(resolved.contains) return EditorAdaScriptResolvedRuntimePlugins( + multiplayer: configuration.settings.multiplayer, pluginIDs: orderedPluginIDs, physics2DGravity: configuration.settings.physics2D.gravity ) diff --git a/Editor/Sources/AdaEditor/Tooling/EditorScriptableObjectCatalog.swift b/Editor/Sources/AdaEditor/Tooling/EditorScriptableObjectCatalog.swift index fabeef03d..a3b51719b 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorScriptableObjectCatalog.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorScriptableObjectCatalog.swift @@ -198,7 +198,7 @@ enum EditorScriptableObjectCatalogLoader { } } - private static func editorFieldValue(_ value: AdaScriptSchemaField.Value) -> EditorFieldValue { + private static func editorFieldValue(_ value: AdaScriptSchemaField.Value) -> ReflectedFieldValue { switch value { case let .bool(value): .bool(value) case let .double(value): .double(value) diff --git a/Editor/Sources/AdaEditor/Tooling/GravityLanguageService.swift b/Editor/Sources/AdaEditor/Tooling/GravityLanguageService.swift index ab1bf044f..5e9e6828e 100644 --- a/Editor/Sources/AdaEditor/Tooling/GravityLanguageService.swift +++ b/Editor/Sources/AdaEditor/Tooling/GravityLanguageService.swift @@ -1,8 +1,8 @@ +import AdaEngine import Foundation import GravityLanguageCore struct EditorGravityLanguageService: Sendable { - private static let languageService = GravityLanguageService() private static let annotationLabels: Set = [ "access", "component", "environment", "export", "previewable", "query", "res", "resource", "scriptable", "state", "system", "tool", "view", @@ -14,13 +14,13 @@ struct EditorGravityLanguageService: Sendable { ) -> [EditorCompletionItem] { let lspPosition = lspPosition(from: position, in: text) return completionItems( - languageService.completions(text: text, position: lspPosition), + languageService().completions(text: text, position: lspPosition), text: text ) } static func semanticTokens(text: String) -> [EditorSemanticToken] { - languageService.semanticTokens(text: text) + languageService().semanticTokens(text: text) .compactMap { token in guard token.range.start.line == token.range.end.line else { return nil @@ -39,7 +39,7 @@ struct EditorGravityLanguageService: Sendable { static func hover(text: String, position: EditorSourceLocation) -> EditorSymbolHover? { guard - let hover = languageService.hover( + let hover = languageService().hover( text: text, position: lspPosition(from: position, in: text) ) @@ -83,6 +83,7 @@ struct EditorGravityLanguageService: Sendable { text: String, position: EditorSourceLocation ) -> EditorSymbolHover? { + workspace.setHostConstructors(hostConstructors()) workspace.change(uri: uri, text: text, version: nil) guard let hover = workspace.hover(uri: uri, position: lspPosition(from: position, in: text)) else { return nil @@ -91,6 +92,7 @@ struct EditorGravityLanguageService: Sendable { } static func diagnostics(workspace: GravityWorkspace, fileURL: URL, text: String) -> [EditorDiagnostic] { + workspace.setHostConstructors(hostConstructors()) let uri = fileURL.standardizedFileURL.absoluteString workspace.change(uri: uri, text: text, version: nil) return (workspace.analysis(for: uri)?.diagnostics ?? []) @@ -111,6 +113,7 @@ struct EditorGravityLanguageService: Sendable { text: String, position: EditorSourceLocation ) -> [EditorCompletionItem] { + workspace.setHostConstructors(hostConstructors()) workspace.change(uri: uri, text: text, version: nil) return completionItems( workspace.completions(uri: uri, position: lspPosition(from: position, in: text)), @@ -118,6 +121,19 @@ struct EditorGravityLanguageService: Sendable { ) } + private static func hostConstructors() -> [GravityHostConstructor] { + return RuntimeTypeRegistry.registeredRuntimeComponentConstructors().map { constructor in + GravityHostConstructor( + name: constructor.name, + parameters: constructor.parameters.map(\.name) + ) + } + } + + private static func languageService() -> GravityLanguageService { + GravityLanguageService(hostConstructors: hostConstructors()) + } + private static func completionItems( _ completions: [GravityCompletion], text: String diff --git a/Editor/Sources/AdaEditor/Tooling/SourceKitLSPClient.swift b/Editor/Sources/AdaEditor/Tooling/SourceKitLSPClient.swift index 9d4d1e455..1ac7b44fe 100644 --- a/Editor/Sources/AdaEditor/Tooling/SourceKitLSPClient.swift +++ b/Editor/Sources/AdaEditor/Tooling/SourceKitLSPClient.swift @@ -121,6 +121,8 @@ struct EditorSourceSymbolTarget: Equatable, Hashable, Sendable { var filePath: String var range: EditorSourceRange var selectionRange: EditorSourceRange + var content: String? + var documentation: String? } struct EditorSourceReference: Equatable, Hashable, Sendable { @@ -289,6 +291,11 @@ actor SourceKitLSPClient { ]) ]), "capabilities": .object([ + "experimental": .object([ + "sourcekit/workspace/getReferenceDocument": .object([ + "supported": .bool(true) + ]) + ]), "workspace": .object([ "workspaceFolders": .bool(true) ]), @@ -440,7 +447,27 @@ actor SourceKitLSPClient { method: "textDocument/definition", params: textDocumentPositionParams(fileURL: fileURL, position: position) ) - return Self.decodeDefinitionTargets(from: response) + var targets = Self.decodeDefinitionTargets(from: response) + for index in targets.indices where !Self.isFileURI(targets[index].uri) { + guard let content = try? await referenceDocument(uri: targets[index].uri) else { + continue + } + targets[index].content = content + targets[index].range = Self.editorRange(fromLSPRange: targets[index].range, in: content) + targets[index].selectionRange = Self.editorRange(fromLSPRange: targets[index].selectionRange, in: content) + } + return targets + } + + private func referenceDocument(uri: String) async throws -> String? { + let response = try await connection.request( + method: "sourcekit/workspace/getReferenceDocument", + params: .object(["uri": .string(uri)]) + ) + guard case let .object(object)? = response, case let .string(content)? = object["content"] else { + return nil + } + return content } func references(fileURL: URL, position: EditorSourceLocation, includeDeclaration: Bool = true) async throws -> [EditorSourceReference] { @@ -914,6 +941,10 @@ actor SourceKitLSPClient { return url.path.removingPercentEncoding ?? url.path } + private static func isFileURI(_ uri: String) -> Bool { + URL(string: uri)?.scheme?.lowercased() == "file" + } + private static let semanticTokenTypes = [ "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift index 4db2acd6f..91b3820f7 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorAdaScriptProjectRuntimeView.swift @@ -131,6 +131,8 @@ struct EditorAdaScriptProjectRuntimeView: View { switch pluginID { case .audio: app.addPlugin(AudioPlugin()) + case .multiplayer: + app.addPlugin(EditorAdaScriptMultiplayerPlugin(settings: artifact.plugins.multiplayer)) case .tilemap: app.addPlugin(TileMapPlugin()) default: diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift index 40eaab489..7c5e2ef5c 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorCenterWorkbench.swift @@ -383,6 +383,15 @@ extension EditorCenterWorkbench { onTextSelection: onTextSelection, onChatSelection: onChatSelection, sourceContextMenuItems: sourceContextMenuItems, + onFileSearchQueryChange: { documentID, query in + viewModel.updateFileSearchQuery(documentID: documentID, query: query) + }, + onMoveFileSearchSelection: { documentID, delta in + viewModel.moveFileSearchSelection(documentID: documentID, delta: delta) + }, + onDismissFileSearch: { documentID in + viewModel.dismissFileSearch(documentID: documentID) + }, debugger: debugger ) } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift index 43e257dbe..8ccbda13a 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorCodeFileView.swift @@ -5,6 +5,8 @@ import Synchronization import TreeSitterSwift struct EditorCodeFileView: View { + static let fileSearchFieldIdentifier = "AdaEditor.FileSearch.Query" + let document: EditorTextDocument let text: Binding let fontSize: Double @@ -22,13 +24,23 @@ struct EditorCodeFileView: View { let onTextSelection: ((EditorTextDocument, EditorSourceRange?, String?) -> Void)? let onChatSelection: ((EditorTextDocument, EditorSourceRange, String) -> Void)? let sourceContextMenuItems: ((EditorTextDocument, EditorSourceLocation) -> [TextEditorContextMenuItem])? + var onFileSearchQueryChange: ((String, String) -> Void)? + var onMoveFileSearchSelection: ((String, Int) -> Void)? + var onDismissFileSearch: ((String) -> Void)? var debugger: EditorDebugger? + @State private var caretViewportRect: Rect? @Environment(\.theme) private var theme var body: some View { VStack(alignment: .leading, spacing: 0) { codeHeader + if let documentation = document.symbolDocumentation, !documentation.isEmpty { + symbolDocumentation(documentation) + } + if document.fileSearch.isPresented { + fileSearchBar + } if let path = document.absolutePath, debugger?.modifiedSources.contains(path) == true { Text("Source changed after launch. Restart debugging to use this version.") .font(.system(size: 11)) @@ -96,6 +108,82 @@ extension EditorCodeFileView { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + private func symbolDocumentation(_ documentation: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Documentation") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(theme.editorColors.blue) + Text( + EditorSourceHoverPresentation.attributedText( + EditorSourceHoverPresentation.displayText(from: documentation), + language: document.language, + palette: colorPalette, + font: AdaEditorCodeFont.font(family: fontFamily, weight: fontWeight, size: 11), + keywordFont: AdaEditorCodeFont.font(family: fontFamily, weight: keywordFontWeight, size: 11) + ) + ) + .lineLimit(5) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.editorColors.surface) + .overlay(anchor: .bottomLeading) { + RectangleShape().fill(theme.editorColors.border.opacity(0.65)).frame(height: 1) + } + .accessibilityIdentifier("AdaEditor.SymbolDocumentation") + } + + private var fileSearchBar: some View { + let matches = EditorFileSearch.matches(in: document.content, query: document.fileSearch.query) + let selectedNumber = matches.isEmpty ? 0 : document.fileSearch.selectedIndex + 1 + + return HStack(spacing: 8) { + TextField( + "Find in file", + text: Binding( + get: { document.fileSearch.query }, + set: { onFileSearchQueryChange?(document.id, $0) } + ), + onSubmit: { onMoveFileSearchSelection?(document.id, 1) } + ) + .textFieldStyle(PlainTextFieldStyle()) + .font(AdaEditorCodeFont.font(size: 12)) + .foregroundColor(theme.editorColors.text) + .frame(minWidth: 160, maxWidth: 320) + .accessibilityIdentifier(Self.fileSearchFieldIdentifier) + + Text("\(selectedNumber) / \(matches.count)") + .font(.system(size: 11)) + .foregroundColor(theme.editorColors.muted) + .frame(width: 58, alignment: .trailing) + + Button("↑") { onMoveFileSearchSelection?(document.id, -1) } + .buttonStyle(DefaultButtonStyle()) + .disabled(matches.isEmpty) + .accessibilityIdentifier("AdaEditor.FileSearch.Previous") + Button("↓") { onMoveFileSearchSelection?(document.id, 1) } + .buttonStyle(DefaultButtonStyle()) + .disabled(matches.isEmpty) + .accessibilityIdentifier("AdaEditor.FileSearch.Next") + Button("×") { onDismissFileSearch?(document.id) } + .buttonStyle(DefaultButtonStyle()) + .accessibilityIdentifier("AdaEditor.FileSearch.Close") + Spacer() + } + .padding(.horizontal, 12) + .frame(height: 34) + .background(theme.editorColors.surface) + .overlay(anchor: .bottomLeading) { + RectangleShape().fill(theme.editorColors.border.opacity(0.65)).frame(height: 1) + } + .keyboardShortcuts([ + KeyboardShortcutAction(.escape) { onDismissFileSearch?(document.id) }, + KeyboardShortcutAction(.enter, modifiers: .shift) { onMoveFileSearchSelection?(document.id, -1) }, + ]) + .accessibilityIdentifier("AdaEditor.FileSearch.Bar") + } + private func completionList(width: Float, height: Float) -> some View { let rowWidth = Swift.max(Float.zero, width - EditorCompletionPopupLayout.horizontalPadding * 2) let listHeight = Swift.max(Float.zero, height - EditorCompletionPopupLayout.verticalPadding * 2) @@ -148,8 +236,7 @@ extension EditorCodeFileView { GeometryReader { geometry in let popupFrame = EditorCompletionPopupLayout.frame( viewportSize: geometry.size, - caretPosition: document.completionPosition, - fontSize: fontSize, + caretRect: caretViewportRect, itemCount: document.completionItems.count ) @@ -197,19 +284,12 @@ extension EditorCodeFileView { return TextEditorSourceInteraction( lineMarkers: debugLineMarkers, + gutterHoverColor: supportsBreakpoints ? Color.red.opacity(0.38) : nil, executionLine: debugExecutionLine, - onGutterClick: { line in - guard let path = document.absolutePath, document.language == .swift || document.language == .ada else { - return - } - debugger?.toggleBreakpoint(path: path, line: line + 1) - }, + onGutterClick: gutterClickAction, highlightedRanges: document.symbolHighlights.map(\.textEditorRange), - sourceHighlights: document.diagnostics.map { diagnostic in - TextEditorSourceHighlight( - range: diagnostic.range.textEditorRange, - color: diagnosticColor(for: diagnostic.severity) - ) + sourceHighlights: fileSearchHighlights + document.diagnostics.map { diagnostic in + TextEditorSourceHighlight(range: diagnostic.range.textEditorRange, color: diagnosticColor(for: diagnostic.severity)) }, hoveredRange: document.sourceHoverRange?.textEditorRange, focusedRange: document.focusedRange?.textEditorRange, @@ -225,6 +305,9 @@ extension EditorCodeFileView { } onGoToDefinition?(document, EditorSourceLocation(textEditorPosition: position)) }, + onCaretViewportRectChange: { _, rect in + caretViewportRect = rect + }, onCaretChange: { position, currentText in guard supportsLanguageTooling else { return @@ -285,6 +368,25 @@ extension EditorCodeFileView { } } + private var supportsBreakpoints: Bool { + document.absolutePath != nil && (document.language == .swift || document.language == .ada) + } + + private var gutterClickAction: ((Int) -> Void)? { + guard supportsBreakpoints, let path = document.absolutePath else { + return nil + } + return { line in debugger?.toggleBreakpoint(path: path, line: line + 1) } + } + + private var fileSearchHighlights: [TextEditorSourceHighlight] { + guard document.fileSearch.isPresented else { + return [] + } + return EditorFileSearch.matches(in: document.content, query: document.fileSearch.query) + .map { TextEditorSourceHighlight(range: $0.textEditorRange, color: Color.yellow.opacity(0.22)) } + } + private var debugExecutionLine: Int? { guard let debugger, let path = document.absolutePath, !debugger.modifiedSources.contains(path) else { return nil @@ -589,8 +691,7 @@ struct EditorCompletionPopupLayout { static func frame( viewportSize: Size, - caretPosition: EditorSourceLocation?, - fontSize: Double, + caretRect: Rect?, itemCount: Int ) -> Rect { let availableWidth = max(0, viewportSize.width - viewportInset * 2) @@ -599,13 +700,10 @@ struct EditorCompletionPopupLayout { let availableRowCount = max(1, Int((availableHeight - verticalPadding * 2) / rowHeight)) let rowCount = min(maximumVisibleRowCount, availableRowCount, max(1, itemCount)) let height = min(availableHeight, Float(rowCount) * rowHeight + verticalPadding * 2) - let lineHeight = max(18, Float(fontSize) * 1.45) - let characterAdvance = max(6, Float(fontSize) * 0.58) - let position = caretPosition ?? EditorSourceLocation(line: 0, character: 0) - let desiredX = Float(82) + Float(max(0, position.character)) * characterAdvance - let caretTop = Float(18) + Float(max(0, position.line)) * lineHeight - let desiredYBelow = caretTop + lineHeight - let desiredYAbove = caretTop - height + let caretRect = caretRect ?? Rect(x: viewportInset, y: viewportInset, width: 0, height: 18) + let desiredX = caretRect.minX + let desiredYBelow = caretRect.maxY + let desiredYAbove = caretRect.minY - height let desiredY = if desiredYBelow + height <= viewportSize.height - viewportInset { desiredYBelow diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorFileSearch.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorFileSearch.swift new file mode 100644 index 000000000..01f966a63 --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorFileSearch.swift @@ -0,0 +1,122 @@ +@_spi(AdaEngine) import AdaEngine +import Foundation + +struct EditorFileSearchState: Equatable, Sendable { + var isPresented = false + var query = "" + var selectedIndex = 0 +} + +enum EditorFileSearch { + static func matches(in text: String, query: String) -> [EditorSourceRange] { + guard !query.isEmpty else { + return [] + } + + var matches: [EditorSourceRange] = [] + var searchRange = text.startIndex.. EditorSourceLocation { + let prefix = text[.. Bool { + switch activeDocument { + case let .text(document)?, let .ui(document)?: + return presentFileSearch(in: document) + default: + return false + } + } + + private func presentFileSearch(in document: EditorTextDocument) -> Bool { + updateTextDocument(id: document.id) { updatedDocument in + updatedDocument.fileSearch.isPresented = true + if updatedDocument.fileSearch.query.isEmpty, + let selectedText = updatedDocument.selectedText, + !selectedText.isEmpty, + !selectedText.contains("\n") { + updatedDocument.fileSearch.query = selectedText + } + updateFileSearchSelection(in: &updatedDocument, selectedIndex: updatedDocument.fileSearch.selectedIndex) + } + return true + } + + func updateFileSearchQuery(documentID: String, query: String) { + updateTextDocument(id: documentID) { document in + document.fileSearch.query = query + updateFileSearchSelection(in: &document, selectedIndex: 0) + } + } + + func moveFileSearchSelection(documentID: String, delta: Int) { + updateTextDocument(id: documentID) { document in + let matches = EditorFileSearch.matches(in: document.content, query: document.fileSearch.query) + guard !matches.isEmpty else { + document.fileSearch.selectedIndex = 0 + document.focusedRange = nil + return + } + let nextIndex = (document.fileSearch.selectedIndex + delta) % matches.count + updateFileSearchSelection( + in: &document, + selectedIndex: nextIndex >= 0 ? nextIndex : nextIndex + matches.count + ) + } + } + + func dismissFileSearch(documentID: String) { + updateTextDocument(id: documentID) { document in + document.fileSearch.isPresented = false + document.focusedRange = nil + } + } + + func refreshFileSearchSelection(documentID: String) { + updateTextDocument(id: documentID) { document in + guard document.fileSearch.isPresented else { + return + } + updateFileSearchSelection(in: &document, selectedIndex: document.fileSearch.selectedIndex) + } + } + + private func updateFileSearchSelection(in document: inout EditorTextDocument, selectedIndex: Int) { + let matches = EditorFileSearch.matches(in: document.content, query: document.fileSearch.query) + guard !matches.isEmpty else { + document.fileSearch.selectedIndex = 0 + document.focusedRange = nil + return + } + let clampedIndex = min(max(0, selectedIndex), matches.count - 1) + document.fileSearch.selectedIndex = clampedIndex + document.focusedRange = matches[clampedIndex] + } +} diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift index 0825317a2..b628ef19c 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorView.swift @@ -331,17 +331,8 @@ struct EditorView: View { private var editorKeyboardShortcuts: [KeyboardShortcutAction] { EditorHistoryShortcuts.actions { EditorMenuCommandRouter.shared.perform($0) } + [ - KeyboardShortcutAction(.r, modifiers: .command) { - viewModel.toggleDebugOverlay(.redraw) - }, - KeyboardShortcutAction(.d, modifiers: .command) { - viewModel.toggleDebugOverlay(.layoutBounds) - }, - KeyboardShortcutAction(.h, modifiers: .command) { - viewModel.toggleDebugOverlay(.hitTestTarget) - }, KeyboardShortcutAction(.f, modifiers: .command) { - viewModel.toggleDebugOverlay(.focusedNode) + _ = viewModel.handleMenuCommand(.findInFile) }, KeyboardShortcutAction(.s, modifiers: .command) { viewModel.saveActiveDocument() diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift index 6ffd09f09..66db1f49f 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift @@ -244,7 +244,8 @@ extension EditorViewModel { } if let projectURL, let settings = projectSettings, - settings.build.system == .adaScript { + settings.build.system == .adaScript, + selectedRunDestination == .macOS { let projectName = settings.project.displayName ?? settings.project.name ?? project?.name ?? "AdaScript Project" buildAdaScriptProject( settings, @@ -255,6 +256,13 @@ extension EditorViewModel { } return } + if projectSettings?.build.system == .adaScript, selectedRunDestination == .web { + let message = "Web run is not available for AdaScript projects yet." + workspaceStatus = .failed(message) + footer.setWorkspaceFooterTitle(workspaceStatus.title) + appendOutput(message) + return + } switch selectedRunDestination { case .player: runOnAdaPlayer() @@ -409,10 +417,11 @@ extension EditorViewModel { appendOutput(message) } + @discardableResult func launchAdaScriptProject( _ artifact: EditorAdaScriptProjectBuildArtifact, projectName: String - ) { + ) -> Bool { do { let runtimeView = try EditorAdaScriptProjectRuntimeView(artifact: artifact) let windowManager = try requireWindowManager() @@ -443,16 +452,21 @@ extension EditorViewModel { self.workspaceStatus = .ready self.footer.setWorkspaceFooterTitle(self.workspaceStatus.title) self.appendOutput("AdaScript project \(windowTitle) stopped.") + if self.debugger.status.hasPrefix("AdaScript runtime is running") { + self.debugger.status = "AdaScript debug run stopped." + } } window.showWindow(makeFocused: true) adaScriptRuntimeWindow = window workspaceStatus = .running("Run \(windowTitle)") footer.setWorkspaceFooterTitle(workspaceStatus.title) appendOutput("Running AdaScript project \(windowTitle) in a separate window scene.") + return true } catch { workspaceStatus = .failed(error.localizedDescription) footer.setWorkspaceFooterTitle(workspaceStatus.title) appendOutput("AdaScript launch failed: \(error.localizedDescription)") + return false } } @@ -522,15 +536,7 @@ extension EditorViewModel { } func runFromToolbar() { - if selectedRunDestination == .player { - runSelectedTarget() - return - } - if workbench.activeSceneDocument != nil { - runActiveSceneInEditor() - } else { - runSelectedTarget() - } + runSelectedTarget() } func stopFromToolbar() { @@ -615,6 +621,16 @@ extension EditorViewModel { EditorUpdateCenter.shared.checkForUpdates() case .showSettings: presentSettings(.general) + case .debugOverlayOff: + showsDebugOverlay = nil + case .debugOverlayRedraw: + toggleDebugOverlay(.redraw) + case .debugOverlayLayoutBounds: + toggleDebugOverlay(.layoutBounds) + case .debugOverlayHitTestTarget: + toggleDebugOverlay(.hitTestTarget) + case .debugOverlayFocusedNode: + toggleDebugOverlay(.focusedNode) case .newFile: presentNewFileDialog() case .newProject: @@ -630,6 +646,14 @@ extension EditorViewModel { refreshSourceControl() reloadScriptableObjectSupport() } + case .findInFile: + guard workbench.presentFileSearch() else { + return false + } + Task { @MainActor in + await Task.yield() + _ = EditorSearchShortcutMonitor.shared.focusSearchField(identifier: EditorCodeFileView.fileSearchFieldIdentifier) + } case .findInProject: presentTextSearch() case .navigateBack: diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Debugging.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Debugging.swift index cc1e020e0..7d7b223e2 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Debugging.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Debugging.swift @@ -74,7 +74,27 @@ extension EditorViewModel { } if settings.build.system.isAdaScript { debugger.selectedLanguage = .adaScript - debugger.status = "AdaScript debug runtime is not available in this build." + guard selectedRunDestination == .macOS else { + debugger.status = "AdaScript debugging currently runs in the local macOS runtime. Select macOS as the destination." + return + } + let projectName = settings.project.displayName ?? settings.project.name ?? project?.name ?? "AdaScript Project" + debugger.status = "Preparing AdaScript debug run…" + debugger.launchedBreakpoints = debugger.breakpoints + buildAdaScriptProject( + settings, + at: projectURL, + statusTitle: "Prepare AdaScript Debug Run" + ) { [weak self] artifact in + guard let self else { + return + } + if self.launchAdaScriptProject(artifact, projectName: projectName) { + self.debugger.status = "AdaScript runtime is running. VM pause, stepping, and breakpoint suspension are not available yet." + } else { + self.debugger.status = "AdaScript debug run failed to launch. See Output for details." + } + } return } guard EditorDistribution.current.supportsSwiftProjects else { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Operations.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Operations.swift index e0044bba7..970f74953 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Operations.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Operations.swift @@ -396,6 +396,11 @@ extension EditorViewModel { } else { workspaceOutputIsGame = false } + if case .runWeb = kind { + pendingWebRunURL = URL(string: "http://127.0.0.1:8080") + } else { + pendingWebRunURL = nil + } workspaceStatus = .running(statusTitle) buildActivity = EditorBuildActivity(title: statusTitle) pendingWorkspaceStandardOutput = "" @@ -418,6 +423,7 @@ extension EditorViewModel { if EditorNotificationCenter.shared.activities.all.first(where: { $0.id == notificationRunID })?.state == .cancelled { self.flushPendingWorkspaceOutput() self.workspaceOutputIsGame = false + self.pendingWebRunURL = nil self.workspaceStatus = .ready self.buildActivity = nil self.notificationWorkspaceRunID = nil @@ -437,6 +443,7 @@ extension EditorViewModel { self.appendOutput(result) } self.workspaceOutputIsGame = false + self.pendingWebRunURL = nil self.buildActivity?.finish(succeeded: result.succeeded) self.replaceBuildDiagnostics(with: EditorDiagnostic.diagnostics(from: result, projectURL: projectURL)) self.showProblemsIfNeeded() @@ -455,16 +462,30 @@ extension EditorViewModel { didReceiveStreamingWorkspaceOutput = true switch event.stream { case .standardOutput: + openWebRunDestinationIfReady(from: pendingWorkspaceStandardOutput + event.text) let update = Self.streamingOutput(event.text, pending: pendingWorkspaceStandardOutput) pendingWorkspaceStandardOutput = update.pending appendStreamingLines(update.lines) case .standardError: + openWebRunDestinationIfReady(from: pendingWorkspaceStandardError + event.text) let update = Self.streamingOutput(event.text, pending: pendingWorkspaceStandardError) pendingWorkspaceStandardError = update.pending appendStreamingLines(update.lines) } } + private func openWebRunDestinationIfReady(from output: String) { + guard let pendingWebRunURL, output.contains("Serving "), output.contains(pendingWebRunURL.absoluteString) else { + return + } + self.pendingWebRunURL = nil + guard externalURLOpener(pendingWebRunURL) else { + appendOutput("Unable to open \(pendingWebRunURL.absoluteString) in the default browser.") + return + } + appendOutput("Opened \(pendingWebRunURL.absoluteString) in the default browser.") + } + func appendStreamingLines(_ lines: [String]) { for line in lines { buildActivity?.consume(line) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift index 3c7a0f28e..fa2a68dd1 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift @@ -401,8 +401,9 @@ extension EditorViewModel { } static func shouldSkipProjectTreeURL(_ url: URL) -> Bool { - let skippedNames: Set = [".ada", ".build", ".DS_Store", ".git", ".swiftpm", "DerivedData"] - return skippedNames.contains(url.lastPathComponent) + let name = url.lastPathComponent + let skippedNames: Set = [".ada", ".DS_Store", ".git", ".swiftpm", "DerivedData"] + return skippedNames.contains(name) || name == ".build" || name.hasPrefix(".build-") } static func isSymbolicLink(at url: URL) -> Bool { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+SourceTooling.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+SourceTooling.swift index c8f42cabd..6b8022f90 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+SourceTooling.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+SourceTooling.swift @@ -341,19 +341,27 @@ extension EditorViewModel { guard let self else { return } - let targets = await self.workspaceService.definition( + async let targetsRequest = self.workspaceService.definition( fileURL: fileURL, language: document.language, text: document.content, position: position ) + async let hoverRequest = self.workspaceService.hover( + fileURL: fileURL, + language: document.language, + text: document.content, + position: position + ) + let (targets, hover) = await (targetsRequest, hoverRequest) await MainActor.run { - guard let target = targets.first else { + guard var target = targets.first else { self.appendOutput("No definition found at \(document.relativePath):\(position.line + 1):\(position.character + 1)") return } + target.documentation = hover?.contents self.openSourceTarget(target) } } @@ -486,48 +494,77 @@ extension EditorViewModel { let filePath = target.filePath if case let .text(document)? = workbench.openDocuments.first(where: { document in if case let .text(textDocument) = document { - return textDocument.absolutePath == filePath + return textDocument.sourceURI == target.uri || textDocument.absolutePath == filePath } return false }) { workbench.updateTextDocument(id: document.id) { document in document.focusedRange = target.selectionRange document.symbolHighlights = [target.selectionRange] + document.symbolDocumentation = target.documentation } workbench.selectDocument(id: document.id) return } let fileURL = URL(fileURLWithPath: filePath, isDirectory: false) + let isReferenceDocument = isGeneratedSwiftInterface(target: target, fileURL: fileURL) let content: String let errorMessage: String? - do { - content = try String(contentsOf: fileURL, encoding: .utf8) + if let referenceContent = target.content { + content = referenceContent errorMessage = nil - } catch { - content = "" - errorMessage = error.localizedDescription + } else { + do { + content = try String(contentsOf: fileURL, encoding: .utf8) + errorMessage = nil + } catch { + content = "" + errorMessage = error.localizedDescription + } } - let relativePath = relativeProjectPath(for: filePath) - let isSymbolicLink = Self.isSymbolicLink(at: fileURL) + let relativePath = isReferenceDocument ? generatedInterfacePath(for: target.uri) : relativeProjectPath(for: filePath) + let isSymbolicLink = !isReferenceDocument && Self.isSymbolicLink(at: fileURL) let textDocument = EditorTextDocument( - id: "text:\(relativePath)", - title: fileURL.lastPathComponent, + id: isReferenceDocument ? "interface:\(target.uri)" : "text:\(relativePath)", + title: isReferenceDocument ? generatedInterfaceTitle(for: target.uri) : fileURL.lastPathComponent, relativePath: relativePath, - absolutePath: filePath, - language: EditorSourceLanguage.detect(fileName: fileURL.lastPathComponent), + absolutePath: isReferenceDocument ? nil : filePath, + sourceURI: target.uri, + language: isReferenceDocument ? .swift : EditorSourceLanguage.detect(fileName: fileURL.lastPathComponent), content: content, lastSavedContent: errorMessage == nil ? content : nil, - isReadOnly: isSymbolicLink || errorMessage != nil, + isReadOnly: isReferenceDocument || isSymbolicLink || errorMessage != nil, errorMessage: errorMessage, - statusMessage: isSymbolicLink ? "Read-only: symbolic link" : errorMessage == nil ? nil : "Read-only: unable to read as UTF-8", + statusMessage: isReferenceDocument + ? "Read-only: generated Swift interface" + : isSymbolicLink ? "Read-only: symbolic link" : errorMessage == nil ? nil : "Read-only: unable to read as UTF-8", symbolHighlights: [target.selectionRange], - focusedRange: target.selectionRange + focusedRange: target.selectionRange, + symbolDocumentation: target.documentation ) let workbenchDocument = EditorWorkbenchDocument.text(textDocument) workbench.open(workbenchDocument) - refreshSemanticTokens(for: workbenchDocument) + if !isReferenceDocument { + refreshSemanticTokens(for: workbenchDocument) + } + } + + func generatedInterfaceTitle(for uri: String) -> String { + let name = URL(string: uri)?.lastPathComponent.removingPercentEncoding + return name?.isEmpty == false ? name ?? "Swift Interface" : "Swift Interface" + } + + func generatedInterfacePath(for uri: String) -> String { + "Generated Interfaces/\(generatedInterfaceTitle(for: uri))" + } + + func isGeneratedSwiftInterface(target: EditorSourceSymbolTarget, fileURL: URL) -> Bool { + let scheme = URL(string: target.uri)?.scheme?.lowercased() + return scheme == "sourcekit-lsp" + || fileURL.pathExtension.lowercased() == "swiftinterface" + || fileURL.path.contains("/sourcekit-lsp/GeneratedInterfaces/") } func relativeProjectPath(for filePath: String) -> String { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift index 5cf719f3a..acb662a0b 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift @@ -92,6 +92,8 @@ final class EditorViewModel { @ObservationIgnored let adaScriptPreviewBuilder: EditorAdaScriptPreviewBuilder @ObservationIgnored + let externalURLOpener: @MainActor (URL) -> Bool + @ObservationIgnored let previewLibrary = EditorPreviewDynamicLibrary() @ObservationIgnored var workspaceTask: Task? @@ -104,6 +106,8 @@ final class EditorViewModel { @ObservationIgnored var adaScriptRuntimeWindow: UIWindow? @ObservationIgnored + var pendingWebRunURL: URL? + @ObservationIgnored var completionTask: Task? @ObservationIgnored var autosaveTasks: [String: Task] = [:] @@ -133,6 +137,7 @@ final class EditorViewModel { sourceControlService: any GitRepositoryServicing = GitRepositoryService(), previewBuilder: EditorPreviewBuilder = EditorPreviewBuilder(), adaScriptPreviewBuilder: EditorAdaScriptPreviewBuilder = EditorAdaScriptPreviewBuilder(), + externalURLOpener: @escaping @MainActor (URL) -> Bool = EditorPlatformFileActions.openInDefaultApplication, toolbar: EditorToolbarViewModel = EditorToolbarViewModel(), toolStrip: EditorToolStripViewModel = EditorToolStripViewModel(), projectSidebar: EditorProjectSidebarViewModel? = nil, @@ -185,6 +190,7 @@ final class EditorViewModel { self.fileManager = fileManager self.previewBuilder = previewBuilder self.adaScriptPreviewBuilder = adaScriptPreviewBuilder + self.externalURLOpener = externalURLOpener self.autosaveDelay = autosaveDelay self.toolbar = toolbar self.toolStrip = toolStrip diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift index e4938aad8..ed6bb92a9 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModels.swift @@ -239,6 +239,7 @@ struct EditorTextDocument: Equatable, Sendable { var title: String var relativePath: String var absolutePath: String? + var sourceURI: String? var language: EditorSourceLanguage var content: String var lastSavedContent: String? @@ -257,6 +258,8 @@ struct EditorTextDocument: Equatable, Sendable { var focusedRange: EditorSourceRange? var selectionRange: EditorSourceRange? var selectedText: String? + var symbolDocumentation: String? + var fileSearch = EditorFileSearchState() } struct EditorSceneDocument: Equatable, Sendable { diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift index 93189ac40..340601ac5 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorWorkbenchViewModel+Documents.swift @@ -42,6 +42,7 @@ extension EditorWorkbenchViewModel { document.isDirty = true document.statusMessage = "Edited" } + self.refreshFileSearchSelection(documentID: documentID) } ) } diff --git a/Editor/Sources/AdaEditor/UI/ProjectOpeningView.swift b/Editor/Sources/AdaEditor/UI/ProjectOpeningView.swift index 9227ab178..b745166f5 100644 --- a/Editor/Sources/AdaEditor/UI/ProjectOpeningView.swift +++ b/Editor/Sources/AdaEditor/UI/ProjectOpeningView.swift @@ -59,6 +59,7 @@ enum ProjectOpeningAccessibility { static let projectName = "AdaEditor.Launcher.ProjectName" static let location = "AdaEditor.Launcher.Location" static let landingContent = "AdaEditor.Launcher.LandingContent" + static let operationError = "AdaEditor.Launcher.OperationError" static let packageToggle = "AdaEditor.Launcher.PackageToggle" static let gitToggle = "AdaEditor.Launcher.GitToggle" static let createActions = "AdaEditor.Launcher.CreateActions" @@ -745,7 +746,28 @@ struct ProjectOpeningView: View { .font(.system(size: 12)) .foregroundColor(LauncherColor.muted) .padding(.top, 6) - .padding(.bottom, 26) + .padding(.bottom, viewModel.operationErrorMessage == nil ? 26 : 16) + + if let errorMessage = viewModel.operationErrorMessage { + VStack(alignment: .leading, spacing: 6) { + Text("UNABLE TO OPEN PROJECT") + .font(.system(size: 10)) + .foregroundColor(LauncherColor.accentOrange) + Text(errorMessage) + .font(.system(size: 12)) + .foregroundColor(.white) + .lineLimit(4) + } + .padding(12) + .frame(maxWidth: 460, alignment: .leading) + .background(RoundedRectangleShape(cornerRadius: 10).fill(LauncherColor.input)) + .overlay { + RoundedRectangleShape(cornerRadius: 10) + .stroke(LauncherColor.accentOrange.opacity(0.45), lineWidth: 1) + } + .accessibilityIdentifier(ProjectOpeningAccessibility.operationError) + .padding(.bottom, 20) + } VStack(alignment: .center, spacing: 14) { Button { diff --git a/Editor/Sources/AdaEditor/UI/ProjectOpeningViewModel.swift b/Editor/Sources/AdaEditor/UI/ProjectOpeningViewModel.swift index a5f5c469a..fac8ebe8c 100644 --- a/Editor/Sources/AdaEditor/UI/ProjectOpeningViewModel.swift +++ b/Editor/Sources/AdaEditor/UI/ProjectOpeningViewModel.swift @@ -54,6 +54,7 @@ final class ProjectOpeningViewModel { var selectedTemplate = EditorProjectTemplate.adaScript var selectedProject: EditorProjectReference? var statusMessage: String = "Select a recent Ada project, create a blank one, or open an existing project." + var operationErrorMessage: String? var validationDiagnostics: [ProjectOpeningDiagnostic] = [] var projectToOpenInEditor: EditorProjectReference? var projectToOpenInEditorToken = 0 @@ -469,10 +470,12 @@ final class ProjectOpeningViewModel { validationDiagnostics = [] statusMessage = "\(prefix): \(error.localizedDescription)" } + operationErrorMessage = statusMessage } private func clearValidationDiagnostics() { validationDiagnostics = [] + operationErrorMessage = nil } static func abbreviatedPath(_ path: String) -> String { diff --git a/Editor/Sources/GravityLanguageCore/GravityAPICatalog.swift b/Editor/Sources/GravityLanguageCore/GravityAPICatalog.swift index f0784bf8c..1b06de570 100644 --- a/Editor/Sources/GravityLanguageCore/GravityAPICatalog.swift +++ b/Editor/Sources/GravityLanguageCore/GravityAPICatalog.swift @@ -61,8 +61,8 @@ enum GravityAPICatalog { ), GravityAPIMember( "spawn", - detail: "spawn(componentNames) -> Int — spawn an entity through deferred commands", - insertText: "spawn(componentNames)", + detail: "spawn(components) -> Int — spawn initialized components through deferred commands", + insertText: "spawn(components)", kind: .method, returnType: "Int" ), @@ -75,7 +75,18 @@ enum GravityAPICatalog { GravityAPIMember("world", detail: "Scoped AdaECS world access", kind: .property, returnType: "$AdaWorldContext"), ], "$AdaWorldContext": [ - GravityAPIMember("commands", detail: "Scoped deferred world commands", kind: .property, returnType: "$AdaCommands") + GravityAPIMember("commands", detail: "Scoped deferred world commands", kind: .property, returnType: "$AdaCommands"), + GravityAPIMember( + "spawn", + detail: "spawn(components) -> Int — spawn initialized components through deferred commands", + insertText: "spawn(components)", + kind: .method, + returnType: "Int" + ), + ], + "Vector3": [ + GravityAPIMember("ZERO", detail: "Zero three-dimensional vector", kind: .property, returnType: "Vector3"), + GravityAPIMember("zero", detail: "Zero three-dimensional vector", kind: .property, returnType: "Vector3"), ], "$AdaEditorToolContext": [ GravityAPIMember( diff --git a/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift b/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift index 771c94cf8..a6ae88237 100644 --- a/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift +++ b/Editor/Sources/GravityLanguageCore/GravityBuiltins.swift @@ -147,6 +147,13 @@ enum GravityBuiltins { GravityCompletionCandidate(detail: "Overlaying AdaUI stack", insertText: "ZStack {\n \n}", kind: .class, label: "ZStack", sortText: "18"), GravityCompletionCandidate(detail: "Flexible AdaUI space", insertText: "Spacer()", kind: .class, label: "Spacer", sortText: "18"), GravityCompletionCandidate(detail: "AdaUI divider", insertText: "Divider()", kind: .class, label: "Divider", sortText: "18"), + GravityCompletionCandidate( + detail: "Three-dimensional vector", + insertText: "Vector3(0, 0, 0)", + kind: .class, + label: "Vector3", + sortText: "18" + ), ] + keywordCandidates private static let keywordCandidates = [ diff --git a/Editor/Sources/GravityLanguageCore/GravityLanguageModels.swift b/Editor/Sources/GravityLanguageCore/GravityLanguageModels.swift index fd00efe6a..740d4a09a 100644 --- a/Editor/Sources/GravityLanguageCore/GravityLanguageModels.swift +++ b/Editor/Sources/GravityLanguageCore/GravityLanguageModels.swift @@ -120,6 +120,24 @@ public struct GravityCompletion: Equatable, Hashable, Sendable { } } +/// One runtime-provided host constructor shared by completion, hover and +/// signature help. The language core stays independent from AdaEngine runtime +/// types; AdaEditor supplies this catalog from registered components. +public struct GravityHostConstructor: Equatable, Hashable, Sendable { + public var name: String + public var parameters: [String] + + public init(name: String, parameters: [String]) { + self.name = name + self.parameters = parameters + } + + public var signature: String { + let arguments = parameters.map { "\($0):" }.joined(separator: ", ") + return "\(name)(\(arguments)) -> Component" + } +} + public enum GravitySemanticTokenKind: String, CaseIterable, Equatable, Hashable, Sendable { case type case `class` diff --git a/Editor/Sources/GravityLanguageCore/GravityLanguageService.swift b/Editor/Sources/GravityLanguageCore/GravityLanguageService.swift index cf330f357..537bc53cc 100644 --- a/Editor/Sources/GravityLanguageCore/GravityLanguageService.swift +++ b/Editor/Sources/GravityLanguageCore/GravityLanguageService.swift @@ -1,7 +1,11 @@ import Foundation public struct GravityLanguageService: Sendable { - public init() {} + private let hostConstructors: [GravityHostConstructor] + + public init(hostConstructors: [GravityHostConstructor] = []) { + self.hostConstructors = hostConstructors.sorted { $0.name < $1.name } + } public func analyze(text: String) -> GravityDocumentAnalysis { GravityDocumentAnalyzer.parse(text).analysis @@ -28,6 +32,9 @@ public struct GravityLanguageService: Sendable { let member = GravityAPICatalog.member(named: token.text, in: receiverType) { return GravityHover(contents: member.detail, range: token.range) } + if let constructor = hostConstructors.first(where: { $0.name == token.text }) { + return GravityHover(contents: constructor.signature, range: token.range) + } let symbols = parsed.analysis.symbols + parsed.analysis.symbols.flatMap(\.members) guard let symbol = symbols.first(where: { $0.name == token.text }) else { return nil @@ -46,14 +53,7 @@ public struct GravityLanguageService: Sendable { _ = openParentheses.popLast() } } - guard - let openIndex = openParentheses.last, - openIndex > 0, - tokens[openIndex - 1].kind == .identifier, - let receiverPath = Self.receiverPath(beforeMemberAt: openIndex - 1, tokens: tokens), - let receiverType = resolvedType(receiverPath: receiverPath, position: position, parsed: parsed), - let member = GravityAPICatalog.member(named: tokens[openIndex - 1].text, in: receiverType) - else { + guard let openIndex = openParentheses.last, openIndex > 0, tokens[openIndex - 1].kind == .identifier else { return nil } @@ -68,7 +68,15 @@ public struct GravityLanguageService: Sendable { activeParameter += 1 } } - return GravitySignatureHelp(activeParameter: activeParameter, label: member.detail) + if let receiverPath = Self.receiverPath(beforeMemberAt: openIndex - 1, tokens: tokens), + let receiverType = resolvedType(receiverPath: receiverPath, position: position, parsed: parsed), + let member = GravityAPICatalog.member(named: tokens[openIndex - 1].text, in: receiverType) { + return GravitySignatureHelp(activeParameter: activeParameter, label: member.detail) + } + guard let constructor = hostConstructors.first(where: { $0.name == tokens[openIndex - 1].text }) else { + return nil + } + return GravitySignatureHelp(activeParameter: activeParameter, label: constructor.signature) } public func completions( @@ -95,7 +103,9 @@ public struct GravityLanguageService: Sendable { } else if context.isAnnotation { candidates = GravityBuiltins.annotationCandidates } else { - candidates = GravityBuiltins.globalCandidates + symbols.map(GravityCompletionCandidate.init(symbol:)) + candidates = GravityBuiltins.globalCandidates + + hostConstructors.map(hostConstructorCandidate) + + symbols.map(GravityCompletionCandidate.init(symbol:)) } return @@ -126,6 +136,16 @@ public struct GravityLanguageService: Sendable { } } + private func hostConstructorCandidate(_ constructor: GravityHostConstructor) -> GravityCompletionCandidate { + GravityCompletionCandidate( + detail: constructor.signature, + insertText: "\(constructor.name)()", + kind: .class, + label: constructor.name, + sortText: "18" + ) + } + private func memberCandidates( receiverPath: [String], position: GravitySourcePosition, diff --git a/Editor/Sources/GravityLanguageCore/GravityWorkspace.swift b/Editor/Sources/GravityLanguageCore/GravityWorkspace.swift index eb4de06e9..20816d172 100644 --- a/Editor/Sources/GravityLanguageCore/GravityWorkspace.swift +++ b/Editor/Sources/GravityLanguageCore/GravityWorkspace.swift @@ -8,13 +8,21 @@ public final class GravityWorkspace { } private let fileManager: FileManager - private let languageService = GravityLanguageService() + private var languageService: GravityLanguageService private var diskDocuments: [String: Document] = [:] private var openDocuments: [String: Document] = [:] private var rootURLs: [URL] = [] - public init(fileManager: FileManager = .default) { + public init( + fileManager: FileManager = .default, + hostConstructors: [GravityHostConstructor] = [] + ) { self.fileManager = fileManager + self.languageService = GravityLanguageService(hostConstructors: hostConstructors) + } + + public func setHostConstructors(_ constructors: [GravityHostConstructor]) { + languageService = GravityLanguageService(hostConstructors: constructors) } public func configure(rootURIs: [String]) { diff --git a/Editor/Sources/GravityLanguageServerProtocol/GravityLanguageServerSession.swift b/Editor/Sources/GravityLanguageServerProtocol/GravityLanguageServerSession.swift index f296d0efe..7a1606f2d 100644 --- a/Editor/Sources/GravityLanguageServerProtocol/GravityLanguageServerSession.swift +++ b/Editor/Sources/GravityLanguageServerProtocol/GravityLanguageServerSession.swift @@ -88,6 +88,18 @@ public final class GravityLanguageServerSession { if rootURIs.isEmpty, let rootURI = params["rootUri"] as? String { rootURIs.append(rootURI) } + if let options = params["initializationOptions"] as? [String: Any], + let constructorValues = options["hostConstructors"] as? [[String: Any]] { + workspace.setHostConstructors( + constructorValues.compactMap { value in + guard let name = value["name"] as? String, + let parameters = value["parameters"] as? [String] else { + return nil + } + return GravityHostConstructor(name: name, parameters: parameters) + } + ) + } workspace.configure(rootURIs: rootURIs) isInitialized = true diff --git a/Editor/Tests/AdaEditorTests/AdaEngineStyleUITests.swift b/Editor/Tests/AdaEditorTests/AdaEngineStyleUITests.swift index 33ec07564..7fa8c5c70 100644 --- a/Editor/Tests/AdaEditorTests/AdaEngineStyleUITests.swift +++ b/Editor/Tests/AdaEditorTests/AdaEngineStyleUITests.swift @@ -578,8 +578,7 @@ struct AdaEngineStyleUITests { let viewport = Size(width: 640, height: 320) let frame = EditorCompletionPopupLayout.frame( viewportSize: viewport, - caretPosition: EditorSourceLocation(line: 200, character: 120), - fontSize: 12, + caretRect: Rect(x: 1_200, y: 2_000, width: 1.5, height: 18), itemCount: 8 ) @@ -595,13 +594,12 @@ struct AdaEngineStyleUITests { func codeCompletionPopupTracksCaret() { let frame = EditorCompletionPopupLayout.frame( viewportSize: Size(width: 900, height: 700), - caretPosition: EditorSourceLocation(line: 3, character: 8), - fontSize: 12, + caretRect: Rect(x: 128, y: 72, width: 1.5, height: 18), itemCount: 3 ) - #expect(frame.minX > 82) - #expect(frame.minY > 18) + #expect(frame.minX == 128) + #expect(frame.minY == 90) } @Test("source hover popup stays inside the editor and prefers the space above the symbol") @@ -668,8 +666,7 @@ struct AdaEngineStyleUITests { let detail = EditorCompletionPresentation.detail(for: item) let frame = EditorCompletionPopupLayout.frame( viewportSize: Size(width: 900, height: 700), - caretPosition: EditorSourceLocation(line: 1, character: 8), - fontSize: 12, + caretRect: Rect(x: 128, y: 36, width: 1.5, height: 18), itemCount: 40 ) diff --git a/Editor/Tests/AdaEditorTests/AdaScriptRuntimeConfigurationTests.swift b/Editor/Tests/AdaEditorTests/AdaScriptRuntimeConfigurationTests.swift index 601a02904..fd71aba11 100644 --- a/Editor/Tests/AdaEditorTests/AdaScriptRuntimeConfigurationTests.swift +++ b/Editor/Tests/AdaEditorTests/AdaScriptRuntimeConfigurationTests.swift @@ -123,6 +123,7 @@ struct AdaScriptRuntimeConfigurationTests { } @Test("missing entry view offers the runtime entry settings page") + @MainActor func missingEntryViewOffersRuntimeSettings() { let action = EditorViewModel.notificationAction( for: .entryViewMissing(identifier: "game.main"), diff --git a/Editor/Tests/AdaEditorTests/EditorAgentImageToolTests.swift b/Editor/Tests/AdaEditorTests/EditorAgentImageToolTests.swift index ca36d10a3..011ce99f5 100644 --- a/Editor/Tests/AdaEditorTests/EditorAgentImageToolTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorAgentImageToolTests.swift @@ -96,7 +96,8 @@ struct EditorAgentImageToolTests { #expect(result.assetReference == "@res://Textures/outlined.png") let request = try #require(await client.recordedRequests().first) #expect(request.url?.path == "/v1/images/edits") - let body = try #require(String(bytes: try #require(request.httpBody), encoding: .utf8)) + let httpBody = try #require(request.httpBody) + let body = try #require(String(bytes: httpBody, encoding: .utf8)) #expect(body.contains("name=\"image\"; filename=\"source.png\"")) #expect(body.contains("Add a gold outline")) diff --git a/Editor/Tests/AdaEditorTests/EditorCodeCompletionLayoutTests.swift b/Editor/Tests/AdaEditorTests/EditorCodeCompletionLayoutTests.swift index 05c388f7c..d802ac459 100644 --- a/Editor/Tests/AdaEditorTests/EditorCodeCompletionLayoutTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorCodeCompletionLayoutTests.swift @@ -10,8 +10,7 @@ struct EditorCodeCompletionLayoutTests { let viewport = Size(width: 440, height: 120) let frame = EditorCompletionPopupLayout.frame( viewportSize: viewport, - caretPosition: EditorSourceLocation(line: 4, character: 8), - fontSize: 12, + caretRect: Rect(x: 128, y: 82, width: 1.5, height: 18), itemCount: 40 ) let contentHeight = frame.height - EditorCompletionPopupLayout.verticalPadding * 2 @@ -23,14 +22,26 @@ struct EditorCodeCompletionLayoutTests { @Test("completion moves above a caret near the bottom edge") func completionUsesSpaceAboveBottomCaret() { - let caretTop = Float(18) + Float(4) * max(18, Float(12) * 1.45) + let caretRect = Rect(x: 128, y: 104, width: 1.5, height: 18) let frame = EditorCompletionPopupLayout.frame( viewportSize: Size(width: 440, height: 140), - caretPosition: EditorSourceLocation(line: 4, character: 8), - fontSize: 12, + caretRect: caretRect, itemCount: 2 ) - #expect(frame.maxY <= caretTop) + #expect(frame.maxY <= caretRect.minY) + } + + @Test("completion tracks the visible caret after document scrolling") + func completionTracksScrolledCaret() { + let caretRect = Rect(x: 240, y: 74, width: 1.5, height: 18) + let frame = EditorCompletionPopupLayout.frame( + viewportSize: Size(width: 900, height: 700), + caretRect: caretRect, + itemCount: 3 + ) + + #expect(frame.minX == caretRect.minX) + #expect(frame.minY == caretRect.maxY) } } diff --git a/Editor/Tests/AdaEditorTests/EditorFileSearchTests.swift b/Editor/Tests/AdaEditorTests/EditorFileSearchTests.swift new file mode 100644 index 000000000..5d1b9d05e --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorFileSearchTests.swift @@ -0,0 +1,58 @@ +import Testing + +@testable import AdaEditor + +@MainActor +struct EditorFileSearchTests { + @Test func findsCaseInsensitiveMatchesAcrossLines() { + let matches = EditorFileSearch.matches( + in: "alpha beta\nBeta gamma\nbetAlpha", + query: "beta" + ) + + #expect(matches.count == 3) + #expect(matches[0] == EditorSourceRange( + start: EditorSourceLocation(line: 0, character: 6), + end: EditorSourceLocation(line: 0, character: 10) + )) + #expect(matches[1].start == EditorSourceLocation(line: 1, character: 0)) + #expect(matches[2].start == EditorSourceLocation(line: 2, character: 0)) + } + + @Test func workbenchPresentsNavigatesAndDismissesFileSearch() throws { + let document = EditorTextDocument( + id: "main", + title: "main.ada", + relativePath: "Sources/main.ada", + language: .ada, + content: "let value = 1\nprint(value)\nvalue = 2", + errorMessage: nil + ) + let workbench = EditorWorkbenchViewModel( + openDocuments: [.text(document)], + activeDocumentID: document.id + ) + + #expect(workbench.presentFileSearch()) + workbench.updateFileSearchQuery(documentID: document.id, query: "value") + var updated = try #require(workbench.textDocument(id: document.id)) + #expect(updated.fileSearch.isPresented) + #expect(updated.fileSearch.selectedIndex == 0) + #expect(updated.focusedRange?.start == EditorSourceLocation(line: 0, character: 4)) + + workbench.moveFileSearchSelection(documentID: document.id, delta: 1) + updated = try #require(workbench.textDocument(id: document.id)) + #expect(updated.fileSearch.selectedIndex == 1) + #expect(updated.focusedRange?.start == EditorSourceLocation(line: 1, character: 6)) + + workbench.moveFileSearchSelection(documentID: document.id, delta: 1) + workbench.moveFileSearchSelection(documentID: document.id, delta: 1) + updated = try #require(workbench.textDocument(id: document.id)) + #expect(updated.fileSearch.selectedIndex == 0) + + workbench.dismissFileSearch(documentID: document.id) + updated = try #require(workbench.textDocument(id: document.id)) + #expect(!updated.fileSearch.isPresented) + #expect(updated.focusedRange == nil) + } +} diff --git a/Editor/Tests/AdaEditorTests/EditorMenuBarTests.swift b/Editor/Tests/AdaEditorTests/EditorMenuBarTests.swift index 03a246784..093ef9ef3 100644 --- a/Editor/Tests/AdaEditorTests/EditorMenuBarTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorMenuBarTests.swift @@ -32,7 +32,18 @@ struct EditorMenuBarTests { #expect(build.items.map(\.title).contains("Run Tests")) #expect(code.items.map(\.title).contains("Show Preview")) #expect(code.items.map(\.title).contains("Rebuild Preview")) - #expect(system.items.map(\.title) == (EditorDistribution.current == .standalone ? ["Settings...", "Check for Updates…"] : ["Settings..."])) + #expect(edit.items.first { $0.title == "Find in File" }?.keyEquivalent == .f) + #expect(edit.items.first { $0.title == "Find in Project" }?.keyEquivalentModifierMask == [.main, .shift]) + let systemTitles = system.items.filter { !$0.isSeparator }.map(\.title) + let expectedPrefix = EditorDistribution.current == .standalone ? ["Settings...", "Check for Updates…"] : ["Settings..."] + #expect(systemTitles.starts(with: expectedPrefix)) + #expect(Array(systemTitles.suffix(5)) == [ + "Disable Debug Overlay", + "Debug Overlay: Redraw", + "Debug Overlay: Layout Bounds", + "Debug Overlay: Hit Test Target", + "Debug Overlay: Focused Node", + ]) #expect(system.items.first?.keyEquivalent == .comma) #expect(file.items.first { $0.title == "Save" }?.keyEquivalent == .s) #expect(build.items.first { $0.title == "Build Project" }?.keyEquivalent == .b) diff --git a/Editor/Tests/AdaEditorTests/EditorPhysicsInspectorTests.swift b/Editor/Tests/AdaEditorTests/EditorPhysicsInspectorTests.swift index 189dd9b03..d3cc07e29 100644 --- a/Editor/Tests/AdaEditorTests/EditorPhysicsInspectorTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorPhysicsInspectorTests.swift @@ -24,7 +24,7 @@ struct EditorPhysicsInspectorTests { #expect(body.shapes.count == 1) #expect(body.filter.collisionBitMask == .all) #expect(!descriptor.fields.contains { $0.key == "runtimeBody" }) - #expect(descriptor.fields.allSatisfy(\.isEditable)) + #expect(descriptor.fields.allSatisfy { $0.isEditable }) #expect(descriptor.fields.first { $0.key == "mode" }?.displayValue(in: payload) == "dynamic") let legacy = payload.filter { !["fixedRotation", "gravityScale", "linearVelocity", "angularVelocity", "debugColor"].contains($0.key) } diff --git a/Editor/Tests/AdaEditorTests/EditorRealWorkspaceTests.swift b/Editor/Tests/AdaEditorTests/EditorRealWorkspaceTests.swift index db86f7408..d1b87741d 100644 --- a/Editor/Tests/AdaEditorTests/EditorRealWorkspaceTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorRealWorkspaceTests.swift @@ -64,6 +64,57 @@ struct EditorRealWorkspaceTests { #expect(!document.content.contains("Game simulation entry point")) } + @Test("SwiftPM scratch directories cannot starve project targets") + @MainActor + func projectTreeSkipsNamedSwiftPMScratchDirectories() throws { + let projectURL = try makeRealWorkspaceDirectory(named: "ScratchDirectoryTree") + defer { removeRealWorkspaceDirectory(projectURL) } + + let sourceDirectory = projectURL.appendingPathComponent("Sources", isDirectory: true) + let sceneDirectory = projectURL.appendingPathComponent("Assets/Scenes", isDirectory: true) + let scratchDirectory = projectURL.appendingPathComponent(".build-codex", isDirectory: true) + try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: sceneDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: scratchDirectory, withIntermediateDirectories: true) + try "@system class Arena {}\n".write( + to: sourceDirectory.appendingPathComponent("Arena.ada"), + atomically: true, + encoding: .utf8 + ) + try "scene: Main\n".write( + to: sceneDirectory.appendingPathComponent("Main.ascn"), + atomically: true, + encoding: .utf8 + ) + for index in 0...2_000 { + _ = FileManager.default.createFile( + atPath: scratchDirectory.appendingPathComponent("Generated-\(index).swift").path, + contents: Data() + ) + } + + var metadata = ProjectSystem.defaultProject(projectName: "ScratchDirectoryTree", buildSystem: .adaScript) + metadata.runtime.moduleName = "ArenaGame" + metadata.paths.sources = "Sources" + metadata.paths.assets = "Assets" + try ProjectSystem.saveProject(metadata, at: projectURL) + + let project = EditorProjectReference(name: "ScratchDirectoryTree", path: projectURL.path) + let viewModel = EditorViewModel(project: project) + + #expect(!viewModel.projectSidebar.items.contains { $0.relativePath.hasPrefix(".build-codex") }) + #expect( + viewModel.projectSidebar.visibleItems.map(\.relativePath) == [ + "Assets", + "Assets/Scenes", + "Assets/Scenes/Main.ascn", + "Sources", + "Sources/Arena.ada", + ] + ) + #expect(viewModel.projectSidebar.visibleItems.first { $0.relativePath == "Sources" }?.title == "ArenaGame") + } + @Test("empty real project stays empty") @MainActor func emptyProjectDoesNotShowSampleFiles() throws { diff --git a/Editor/Tests/AdaEditorTests/EditorSceneEditingTests.swift b/Editor/Tests/AdaEditorTests/EditorSceneEditingTests.swift index 28b50c932..22b776f85 100644 --- a/Editor/Tests/AdaEditorTests/EditorSceneEditingTests.swift +++ b/Editor/Tests/AdaEditorTests/EditorSceneEditingTests.swift @@ -8,7 +8,7 @@ import Testing @testable import AdaCorePipelines @testable import AdaEditor -private enum EditorReflectionMode: String, CaseIterable, EditorEnumReflectable, Codable, Sendable { +private enum EditorReflectionMode: String, CaseIterable, ReflectedEnum, Codable, Sendable { case idle case active } @@ -54,7 +54,7 @@ struct EditorSceneEditingTests { @Test("component registry adapts reflected component descriptors") func componentRegistryReflectsGeneratedDescriptors() throws { - EditorComponentReflectionRegistry.register(EditorReflectedComponent.editorComponentDescriptor) + ComponentReflectionRegistry.register(EditorReflectedComponent.componentDescriptor) let descriptor = try #require(EditorComponentRegistry.descriptor(named: String(reflecting: EditorReflectedComponent.self))) diff --git a/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift b/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift index 92c8f906d..3dcfbd770 100644 --- a/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift +++ b/Editor/Tests/AdaEditorTests/GravityLanguageSemanticTests.swift @@ -7,7 +7,10 @@ import Testing struct GravityLanguageSemanticTests { @Test("Annotated lifecycle parameters expose typed host APIs") func annotatedLifecycleCompletion() { - let service = GravityLanguageService() + let service = GravityLanguageService(hostConstructors: [ + GravityHostConstructor(name: "Health", parameters: ["current", "maximum"]), + GravityHostConstructor(name: "Transform", parameters: ["rotation", "scale", "position"]), + ]) let systemSource = """ @system(id: "movement") class MovementSystem { @@ -37,6 +40,43 @@ struct GravityLanguageSemanticTests { ) #expect(commandItems.contains { $0.label == "spawn" }) + let worldSource = """ + @system(id: "world") + class WorldSystem { + func update(context) { + context.world.sp + } + } + """ + let worldItems = service.completions( + text: worldSource, + position: GravitySourcePosition(line: 3, utf16Column: 24) + ) + #expect(worldItems.contains { $0.label == "spawn" }) + + let constructorItems = service.completions( + text: "Tran", + position: GravitySourcePosition(line: 0, utf16Column: 4) + ) + #expect(constructorItems.contains { $0.label == "Transform" }) + let healthItems = service.completions( + text: "Heal", + position: GravitySourcePosition(line: 0, utf16Column: 4) + ) + #expect(healthItems.contains { $0.label == "Health" }) + + let constructorHover = service.hover( + text: "Transform(position: Vector3.ZERO)", + position: GravitySourcePosition(line: 0, utf16Column: 2) + ) + #expect(constructorHover?.contents == "Transform(rotation:, scale:, position:) -> Component") + + let vectorItems = service.completions( + text: "Vector3.Z", + position: GravitySourcePosition(line: 0, utf16Column: 9) + ) + #expect(vectorItems.contains { $0.label == "ZERO" }) + let toolSource = """ @tool(id: "com.example.tool", permissions: []) class ExampleTool { @@ -89,7 +129,7 @@ struct GravityLanguageSemanticTests { } ) let hover = service.hover(text: source, position: GravitySourcePosition(line: 3, utf16Column: 32)) - #expect(hover?.contents.contains("spawn(componentNames)") == true) + #expect(hover?.contents.contains("spawn(components)") == true) let signature = service.signatureHelp(text: source, position: GravitySourcePosition(line: 3, utf16Column: 37)) #expect(signature?.activeParameter == 0) @@ -134,7 +174,7 @@ struct GravityLanguageSemanticTests { let hoverMessage = try #require(hoverResponse.outgoingMessages.first) let hoverResult = try #require(hoverMessage["result"] as? [String: Any]) let hoverContents = try #require(hoverResult["contents"] as? [String: String]) - #expect(hoverContents["value"]?.contains("spawn(componentNames)") == true) + #expect(hoverContents["value"]?.contains("spawn(components)") == true) } private func validateInitialization(of session: GravityLanguageServerSession) throws { diff --git a/Editor/Tests/AdaEditorTests/GravityLanguageServerTests.swift b/Editor/Tests/AdaEditorTests/GravityLanguageServerTests.swift index e450d377d..69bbd8f02 100644 --- a/Editor/Tests/AdaEditorTests/GravityLanguageServerTests.swift +++ b/Editor/Tests/AdaEditorTests/GravityLanguageServerTests.swift @@ -5,6 +5,45 @@ import Testing @Suite("AdaScript language server") struct GravityLanguageServerTests { + @Test("Initialization options publish host component constructors") + func hostConstructorInitialization() throws { + let session = GravityLanguageServerSession() + _ = session.handle([ + "id": 1, + "jsonrpc": "2.0", + "method": "initialize", + "params": [ + "initializationOptions": [ + "hostConstructors": [ + ["name": "Health", "parameters": ["current", "maximum"]] + ] + ], + "rootUri": NSNull(), + ], + ]) + let uri = "file:///tmp/HostCatalog.ada" + _ = session.handle([ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": [ + "textDocument": ["languageId": "adascript", "text": "Heal", "uri": uri, "version": 1] + ], + ]) + let completion = session.handle([ + "id": 2, + "jsonrpc": "2.0", + "method": "textDocument/completion", + "params": [ + "position": ["character": 4, "line": 0], + "textDocument": ["uri": uri], + ], + ]) + let message = try #require(completion.outgoingMessages.first) + let result = try #require(message["result"] as? [String: Any]) + let items = try #require(result["items"] as? [[String: Any]]) + #expect(items.contains { $0["label"] as? String == "Health" }) + } + @Test("Completion offers @view and AdaUI builders") func adaUIViewCompletion() { let service = GravityLanguageService() diff --git a/Editor/Tests/AdaEditorTests/GravityLiveEditorTests.swift b/Editor/Tests/AdaEditorTests/GravityLiveEditorTests.swift index e43e24082..06daa9b43 100644 --- a/Editor/Tests/AdaEditorTests/GravityLiveEditorTests.swift +++ b/Editor/Tests/AdaEditorTests/GravityLiveEditorTests.swift @@ -25,9 +25,13 @@ struct GravityLiveEditorTests { let model = EditorViewModel( project: EditorProjectReference(name: "Test", path: root.path), workspaceService: SwiftPMWorkspaceService(), - workbench: EditorWorkbenchViewModel(activeEditorTab: .code, openDocuments: [.text(document)]) + workbench: EditorWorkbenchViewModel( + activeEditorTab: document.title, + openDocuments: [.text(document)], + activeDocumentID: document.id + ) ) - model.refreshSemanticTokens(for: .text(document)) + model.refreshSemanticTokens(for: EditorWorkbenchDocument.text(document)) for _ in 0..<200 { if model.problems.contains(where: { $0.source == "adascript-lsp" }) { break @@ -47,7 +51,7 @@ struct GravityLiveEditorTests { var fixed = highlighted fixed.content = "class Main { var speed = 0 }" model.workbench.updateTextDocument(id: fixed.id) { $0.content = fixed.content } - model.refreshSemanticTokens(for: .text(fixed)) + model.refreshSemanticTokens(for: EditorWorkbenchDocument.text(fixed)) for _ in 0..<200 { if !model.problems.contains(where: { $0.source == "adascript-lsp" }) { break @@ -85,7 +89,11 @@ struct GravityLiveEditorTests { let model = EditorViewModel( project: EditorProjectReference(name: "Test", path: root.path), workspaceService: service, - workbench: EditorWorkbenchViewModel(activeEditorTab: .code, openDocuments: [.text(document)]) + workbench: EditorWorkbenchViewModel( + activeEditorTab: document.title, + openDocuments: [.text(document)], + activeDocumentID: document.id + ) ) let position = EditorSourceLocation(line: 0, character: 12) model.handleSourceHover(document: document, position: position) @@ -109,7 +117,7 @@ struct GravityLiveEditorTests { #expect(hovered.sourceHoverDescription?.contains("VladComponent") == true) let targets = await service.definition(fileURL: file, language: .ada, text: source, position: position) #expect(targets.first?.filePath == component.path) - model.handleSourceHover(document: hovered, position: nil) + model.handleSourceHover(document: hovered, position: nil as EditorSourceLocation?) guard case let .text(cleared)? = model.workbench.activeDocument else { return } diff --git a/Editor/Tests/AdaEditorTests/ProjectOpeningErrorPresentationTests.swift b/Editor/Tests/AdaEditorTests/ProjectOpeningErrorPresentationTests.swift new file mode 100644 index 000000000..f273557bb --- /dev/null +++ b/Editor/Tests/AdaEditorTests/ProjectOpeningErrorPresentationTests.swift @@ -0,0 +1,79 @@ +@_spi(AdaEngine) import AdaEngine +@_spi(Internal) import AdaUI +import Foundation +import Math +import Testing + +@testable import AdaEditor + +@Suite("Project opening error presentation", .serialized) +@MainActor +struct ProjectOpeningErrorPresentationTests { + @Test("App Store distribution explains why a SwiftPM project cannot open") + func appStoreSwiftProjectErrorIsVisible() throws { + prepareRenderer() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("ProjectOpeningError-\(UUID())") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Self.manifest.write( + to: root.appendingPathComponent("Package.swift"), + atomically: true, + encoding: .utf8 + ) + try ProjectSystem.saveProject( + ProjectSystem.defaultProject(projectName: "HybridGame", buildSystem: .swiftpm), + at: root + ) + + let store = EditorProjectStore( + storageURL: root.appendingPathComponent("recents.json"), + distribution: .appStore + ) + let model = ProjectOpeningViewModel(store: store) + model.openProject(at: root) + + #expect(model.selectedProject == nil) + #expect(model.projectToOpenInEditor == nil) + #expect(model.validationDiagnostics.isEmpty) + #expect(model.operationErrorMessage?.contains("AdaScript projects only") == true) + + let container = UIContainerView( + rootView: ProjectOpeningView(autoOpenLastProject: false, viewModel: model) + .theme(.adaEditor) + ) + container.frame = Rect(x: 0, y: 0, width: 1_024, height: 700) + container.bounds.size = container.frame.size + container.layoutIfNeeded() + + let error = try container.uiNode( + matching: .accessibilityIdentifier(ProjectOpeningAccessibility.operationError) + ) + let landing = try container.uiNode( + matching: .accessibilityIdentifier(ProjectOpeningAccessibility.landingContent) + ) + #expect(error.absoluteFrame.width > 0) + #expect(error.absoluteFrame.height > 0) + #expect(error.absoluteFrame.minX >= landing.absoluteFrame.minX) + #expect(error.absoluteFrame.maxX <= landing.absoluteFrame.maxX) + #expect(error.absoluteFrame.minY >= landing.absoluteFrame.minY) + #expect(error.absoluteFrame.maxY <= landing.absoluteFrame.maxY) + } + + private func prepareRenderer() { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + RenderWorldPlugin().setup(in: AppWorlds(main: World(name: "ProjectOpeningErrorTests"))) + } + } + + private static let manifest = """ + // swift-tools-version: 6.2 + import PackageDescription + + let package = Package( + name: "HybridGame", + products: [.executable(name: "HybridGame", targets: ["HybridGame"])], + targets: [.executableTarget(name: "HybridGame")] + ) + """ +} diff --git a/Editor/Tests/AdaEditorTests/SwiftToolingTests.swift b/Editor/Tests/AdaEditorTests/SwiftToolingTests.swift index 8cb425560..277f88475 100644 --- a/Editor/Tests/AdaEditorTests/SwiftToolingTests.swift +++ b/Editor/Tests/AdaEditorTests/SwiftToolingTests.swift @@ -152,6 +152,76 @@ struct SwiftToolingTests { #expect(await service.commands.last == .runWeb(target: "My-Game", outputPath: "dist/web", serve: true)) } + @Test("toolbar Run uses the selected Web destination even when a scene is active") + @MainActor + func toolbarRunUsesSelectedWebDestinationWithActiveScene() async throws { + let projectURL = FileManager.default.temporaryDirectory + .appendingPathComponent("ToolbarWebRun-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: projectURL) } + try ProjectSystem.saveProject(ProjectSystem.defaultProject(projectName: "Game"), at: projectURL) + let service = RecordingWorkspaceService( + outputEvents: [ + EditorProcessOutputEvent( + stream: .standardError, + text: "Serving /tmp/web at http://127.0.0.1:8080\n" + ) + ] + ) + var openedURL: URL? + let packageModel = SwiftPackageModel( + name: "Game", + products: [SwiftPackageProduct(name: "Game", type: "executable", targets: ["Game"])], + targets: [], + dependencies: [] + ) + let viewModel = EditorViewModel( + project: EditorProjectReference(name: "Game", path: projectURL.path), + workspaceService: service, + externalURLOpener: { url in + openedURL = url + return true + }, + workbench: EditorViewModel().workbench, + workspaceStatus: .ready, + packageModel: packageModel, + selectedRunProduct: "Game", + selectedRunDestination: .web + ) + #expect(viewModel.workbench.activeSceneDocument != nil) + + viewModel.runFromToolbar() + try await waitForRecordedCommands(service, count: 1) + + #expect(await service.commands == [.runWeb(target: "Game", outputPath: "dist/web", serve: true)]) + #expect(viewModel.playModeState == EditorPlayModeState.editing) + #expect(openedURL?.absoluteString == "http://127.0.0.1:8080") + } + + @Test("toolbar Run never enters editor Play Mode for an AdaScript Web destination") + @MainActor + func toolbarRunDoesNotEnterPlayModeForAdaScriptWebDestination() async throws { + let projectURL = FileManager.default.temporaryDirectory + .appendingPathComponent("ToolbarAdaScriptWebRun-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: projectURL) } + var settings = ProjectSystem.defaultProject(projectName: "Game") + settings.build.system = .adaScript + try ProjectSystem.saveProject(settings, at: projectURL) + let service = RecordingWorkspaceService() + let viewModel = EditorViewModel( + project: EditorProjectReference(name: "Game", path: projectURL.path), + workspaceService: service, + workbench: EditorViewModel().workbench, + selectedRunDestination: .web + ) + + viewModel.runFromToolbar() + await Task.yield() + + #expect(viewModel.playModeState == EditorPlayModeState.editing) + #expect(viewModel.workspaceStatus == .failed("Web run is not available for AdaScript projects yet.")) + #expect(await service.commands.isEmpty) + } + @Test("run aborts when the active dirty document cannot be saved") @MainActor func runAbortsAfterSaveFailure() async { @@ -608,6 +678,65 @@ struct SwiftToolingTests { #expect(clearedDocument.symbolHighlights.isEmpty) } + @Test("go to definition opens a read-only Swift interface with persistent documentation") + @MainActor + func goToDefinitionOpensGeneratedInterfaceAndDocumentation() async throws { + let uri = "sourcekit-lsp://generated-swift-interface/Foundation.swiftinterface?moduleName=Foundation" + let selection = EditorSourceRange( + start: EditorSourceLocation(line: 1, character: 13), + end: EditorSourceLocation(line: 1, character: 31) + ) + let interface = "/// A notification delivery center.\npublic class NotificationCenter {}\n" + let service = RecordingWorkspaceService( + hoverResponse: EditorSymbolHover(contents: "Delivers notifications to registered observers.", range: nil), + definitionResponse: [ + EditorSourceSymbolTarget( + uri: uri, + filePath: "/Foundation.swiftinterface", + range: EditorSourceRange( + start: EditorSourceLocation(line: 0, character: 0), + end: EditorSourceLocation(line: 1, character: 34) + ), + selectionRange: selection, + content: interface + ) + ] + ) + let source = EditorTextDocument( + id: "main", + title: "main.swift", + relativePath: "Sources/Game/main.swift", + absolutePath: "/tmp/Game/Sources/Game/main.swift", + language: .swift, + content: "let center = NotificationCenter.default" + ) + let viewModel = EditorViewModel( + project: EditorProjectReference(name: "Game", path: "/tmp/Game"), + workspaceService: service, + workbench: EditorWorkbenchViewModel(openDocuments: [.text(source)], activeDocumentID: source.id) + ) + + viewModel.goToDefinition(document: source, position: EditorSourceLocation(line: 0, character: 15)) + for _ in 0..<100 { + if case let .text(document)? = viewModel.workbench.activeDocument, document.sourceURI == uri { + break + } + try await Task.sleep(for: .milliseconds(5)) + } + + guard case let .text(document)? = viewModel.workbench.activeDocument else { + Issue.record("Expected generated interface document") + return + } + #expect(document.sourceURI == uri) + #expect(document.absolutePath == nil) + #expect(document.content == interface) + #expect(document.isReadOnly) + #expect(document.focusedRange == selection) + #expect(document.symbolDocumentation == "Delivers notifications to registered observers.") + #expect(document.statusMessage == "Read-only: generated Swift interface") + } + @Test("build diagnostics replacement preserves SourceKit diagnostics") @MainActor func buildDiagnosticsPreserveSourceKit() { @@ -1138,6 +1267,7 @@ struct SwiftToolingTests { case let .array(workspaceFolders)? = initializeObject["workspaceFolders"], case let .object(workspaceFolder)? = workspaceFolders.first, case let .object(capabilities)? = initializeObject["capabilities"], + case let .object(experimentalCapabilities)? = capabilities["experimental"], case let .object(workspaceCapabilities)? = capabilities["workspace"], case let .object(textDocumentCapabilities)? = capabilities["textDocument"], case let .object(semanticTokenCapabilities)? = textDocumentCapabilities["semanticTokens"] @@ -1148,6 +1278,10 @@ struct SwiftToolingTests { #expect(workspaceFolder["name"] == .string("Game")) #expect(workspaceFolder["uri"] == .string(URL(fileURLWithPath: "/tmp/Game", isDirectory: true).absoluteString)) #expect(workspaceCapabilities["workspaceFolders"] == .bool(true)) + #expect( + experimentalCapabilities["sourcekit/workspace/getReferenceDocument"] + == .object(["supported": .bool(true)]) + ) #expect(semanticTokenCapabilities["formats"] == .array([.string("relative")])) let preparationParams = try #require(requests.first { $0.method == "workspace/_sourceKitOptions" }?.params) guard case let .object(preparationObject) = preparationParams else { @@ -1292,6 +1426,40 @@ struct SwiftToolingTests { #expect(targets[1].selectionRange.start.character == 9) } + @Test("SourceKit generated interfaces are requested and attached to definitions") + func generatedInterfaceDefinition() async throws { + let uri = "sourcekit-lsp://generated-swift-interface/Foundation.swiftinterface?moduleName=Foundation" + let interface = "/// A notification delivery center.\npublic class NotificationCenter {}\n" + let connection = FakeSourceKitLSPConnection(responses: [ + "textDocument/definition": .object([ + "targetUri": .string(uri), + "targetRange": sourceRange(0, 0, 1, 34), + "targetSelectionRange": sourceRange(1, 13, 1, 31), + ]), + "sourcekit/workspace/getReferenceDocument": .object([ + "content": .string(interface) + ]), + ]) + let client = SourceKitLSPClient(connection: connection) + let projectURL = URL(fileURLWithPath: "/tmp/Game", isDirectory: true) + let fileURL = projectURL.appendingPathComponent("Sources/Game/main.swift") + try await client.start( + toolchain: SwiftToolchain(swiftExecutablePath: "/usr/bin/swift", sourceKitLSPExecutablePath: "/usr/bin/sourcekit-lsp"), + projectURL: projectURL + ) + + let targets = try await client.definition(fileURL: fileURL, position: EditorSourceLocation(line: 0, character: 4)) + let target = try #require(targets.first) + #expect(target.uri == uri) + #expect(target.content == interface) + #expect(target.selectionRange == EditorSourceRange( + start: EditorSourceLocation(line: 1, character: 13), + end: EditorSourceLocation(line: 1, character: 31) + )) + let requests = await connection.requests + #expect(requests.contains { $0.method == "sourcekit/workspace/getReferenceDocument" }) + } + @Test("LSP references hover and document highlights decode") func symbolFeatureDecoders() { let references = SourceKitLSPClient.decodeReferences( @@ -1661,14 +1829,20 @@ private actor RecordingWorkspaceService: SwiftPMWorkspaceServicing { private(set) var commands: [SwiftPMCommandKind] = [] private(set) var completionRequests: [(position: EditorSourceLocation, text: String)] = [] private let hoverResponse: EditorSymbolHover? + private let definitionResponse: [EditorSourceSymbolTarget] private let documentHighlightResponse: [EditorDocumentHighlight] + private let outputEvents: [EditorProcessOutputEvent] init( hoverResponse: EditorSymbolHover? = nil, - documentHighlightResponse: [EditorDocumentHighlight] = [] + definitionResponse: [EditorSourceSymbolTarget] = [], + documentHighlightResponse: [EditorDocumentHighlight] = [], + outputEvents: [EditorProcessOutputEvent] = [] ) { self.hoverResponse = hoverResponse + self.definitionResponse = definitionResponse self.documentHighlightResponse = documentHighlightResponse + self.outputEvents = outputEvents } nonisolated func makeCommand(_ kind: SwiftPMCommandKind, projectURL: URL, toolchain: SwiftToolchain) -> EditorProcessCommand { @@ -1696,6 +1870,22 @@ private actor RecordingWorkspaceService: SwiftPMWorkspaceServicing { func execute(_ kind: SwiftPMCommandKind, projectURL: URL) -> EditorProcessResult { commands.append(kind) + return result(for: kind, projectURL: projectURL) + } + + func execute( + _ kind: SwiftPMCommandKind, + projectURL: URL, + output: @Sendable @escaping (EditorProcessOutputEvent) async -> Void + ) async -> EditorProcessResult { + commands.append(kind) + for event in outputEvents { + await output(event) + } + return result(for: kind, projectURL: projectURL) + } + + private func result(for kind: SwiftPMCommandKind, projectURL: URL) -> EditorProcessResult { let toolchain = SwiftToolchain(swiftExecutablePath: "swift", sourceKitLSPExecutablePath: nil) return EditorProcessResult( command: makeCommand(kind, projectURL: projectURL, toolchain: toolchain), @@ -1710,7 +1900,9 @@ private actor RecordingWorkspaceService: SwiftPMWorkspaceServicing { completionRequests.append((position, text)) return [] } - func definition(fileURL _: URL, language _: EditorSourceLanguage, text _: String, position _: EditorSourceLocation) -> [EditorSourceSymbolTarget] { [] } + func definition(fileURL _: URL, language _: EditorSourceLanguage, text _: String, position _: EditorSourceLocation) -> [EditorSourceSymbolTarget] { + definitionResponse + } func references(fileURL _: URL, language _: EditorSourceLanguage, text _: String, position _: EditorSourceLocation) -> [EditorSourceReference] { [] } func hover(fileURL _: URL, language _: EditorSourceLanguage, text _: String, position _: EditorSourceLocation) -> EditorSymbolHover? { hoverResponse } func documentHighlights(fileURL _: URL, language _: EditorSourceLanguage, text _: String, position _: EditorSourceLocation) -> [EditorDocumentHighlight] { diff --git a/Editor/project.yml b/Editor/project.yml index b9193d6a8..c66ac8b3e 100644 --- a/Editor/project.yml +++ b/Editor/project.yml @@ -47,6 +47,7 @@ targets: com.apple.security.app-sandbox: true com.apple.security.files.user-selected.read-write: true com.apple.security.network.client: true + com.apple.security.network.server: true type: application platform: macOS sources: diff --git a/Package.resolved b/Package.resolved index 0151d70d1..85738cbfd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "7fde20672c286e4bff18cadfa75eb7daefe1b20bfc63b24c3a3cba1f22acff15", + "originHash" : "b83b34920567534236c7c70e03b228cee5eded94eae609ac62d22062d8406414", "pins" : [ { "identity" : "gravity-lang", "kind" : "remoteSourceControl", "location" : "https://github.com/AdaEngine/gravity-lang.git", "state" : { - "revision" : "664dfb05430be6303dc5da05210476d8b91de7f4" + "revision" : "ecfb4a53d719163ff84f84bc245315140c075229", + "version" : "0.9.9" } }, { @@ -102,21 +103,12 @@ { "identity" : "swift-service-context", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", + "location" : "https://github.com/apple/swift-service-context", "state" : { "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", "version" : "1.3.0" } }, - { - "identity" : "swift-subprocess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-subprocess.git", - "state" : { - "branch" : "0.2.1", - "revision" : "44922dfe46380cd354ca4b0208e717a3e92b13dd" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -126,15 +118,6 @@ "version" : "602.0.0" } }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version" : "1.8.1" - } - }, { "identity" : "swiftlintplugins", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index dd8071639..a2673a352 100644 --- a/Package.swift +++ b/Package.swift @@ -77,6 +77,10 @@ var products: [Product] = [ name: "AdaECS", targets: ["AdaECS"] ), + .library( + name: "AdaMultiplayer", + targets: ["AdaMultiplayer"] + ), .library( name: "AdaScripting", targets: ["AdaScripting"] @@ -335,6 +339,27 @@ var targets: [Target] = [ ], swiftSettings: swiftSettings ), + .adaTarget( + name: "AdaMultiplayer", + dependencies: [ + "AdaApp", + "AdaECS", + "AdaTransform", + "AdaUtils", + "Math", + .product( + name: "JavaScriptKit", + package: "JavaScriptKit", + condition: .when(platforms: [.wasi]) + ), + .product( + name: "JavaScriptFoundationCompat", + package: "JavaScriptKit", + condition: .when(platforms: [.wasi]) + ) + ], + swiftSettings: swiftSettings + ), .adaTarget( name: "AdaECS", dependencies: [ @@ -1135,9 +1160,16 @@ targets += [ name: "AdaECSTests", dependencies: ["AdaECS", "Math"], ), + .testTarget( + name: "AdaMultiplayerTests", + dependencies: ["AdaApp", "AdaECS", "AdaMultiplayer", "AdaTransform", "Math"], + ), .testTarget( name: "AdaScriptingTests", - dependencies: ["AdaScriptCompilerCore", "AdaScripting", "AdaApp", "AdaECS", "AdaInput", "AdaRender", "AdaScene", "AdaTransform", "AdaUI", "Math"], + dependencies: [ + "AdaScriptCompilerCore", "AdaScripting", "AdaApp", "AdaECS", "AdaInput", "AdaMultiplayer", "AdaRender", "AdaScene", "AdaSprite", "AdaTransform", "AdaUI", "Math", + .product(name: "Yams", package: "Yams"), + ], ), .testTarget( name: "AdaAssetsTests", @@ -1261,7 +1293,7 @@ let package = Package( package.dependencies += [ .package( url: "https://github.com/AdaEngine/gravity-lang.git", - revision: "664dfb05430be6303dc5da05210476d8b91de7f4" + exact: "0.9.9" ), .package(url: "https://github.com/apple/swift-collections", from: "1.3.0"), .package(url: "https://github.com/apple/swift-log", from: "1.8.0"), diff --git a/Plugins/AdaScriptGeneratorTool/AdaScriptGeneratorTool.swift b/Plugins/AdaScriptGeneratorTool/AdaScriptGeneratorTool.swift index 1732f7c17..65d93c9be 100644 --- a/Plugins/AdaScriptGeneratorTool/AdaScriptGeneratorTool.swift +++ b/Plugins/AdaScriptGeneratorTool/AdaScriptGeneratorTool.swift @@ -162,7 +162,7 @@ struct AdaScriptGeneratorTool { .joined(separator: ", ") let fields = schema.fields .map { field in - "\(swiftStringLiteral(field.name)): \(editorFieldValue(field.defaultValue))" + "\(swiftStringLiteral(field.name)): \(reflectedFieldValue(field.defaultValue))" } .joined(separator: ", ") return """ @@ -194,7 +194,7 @@ struct AdaScriptGeneratorTool { """ } - private static func editorFieldValue(_ value: AdaScriptSchemaField.Value) -> String { + private static func reflectedFieldValue(_ value: AdaScriptSchemaField.Value) -> String { switch value { case .bool(let value): ".bool(\(value))" case .double(let value): ".double(\(value))" @@ -250,21 +250,21 @@ struct AdaScriptGeneratorTool { .map { field in let fieldType = swiftType(field.defaultValue) return """ - unsafe EditorComponentFieldDescriptor( + unsafe ReflectedComponentField( key: \(swiftStringLiteral(field.name)), label: \(swiftStringLiteral(field.name)), - kind: EditorComponentReflection.kind(for: \(fieldType).self), - isEditable: EditorComponentReflection.isEditable(\(fieldType).self), - accepts: { EditorComponentReflection.accepts($0, for: \(fieldType).self) }, + kind: ComponentReflection.kind(for: \(fieldType).self), + isWritable: ComponentReflection.isWritable(\(fieldType).self), + accepts: { ComponentReflection.accepts($0, for: \(fieldType).self) }, read: { _ in nil }, write: { _, _ in nil }, readPointer: { pointer in let resource = unsafe pointer.assumingMemoryBound(to: \(typeName).self) - return EditorComponentReflection.read(unsafe resource.pointee.\(field.name)) + return ComponentReflection.read(unsafe resource.pointee.\(field.name)) }, writePointer: { pointer, value in let resource = unsafe pointer.assumingMemoryBound(to: \(typeName).self) - return unsafe EditorComponentReflection.write(value, to: &resource.pointee.\(field.name)) + return unsafe ComponentReflection.write(value, to: &resource.pointee.\(field.name)) } ) """ diff --git a/Sources/AdaApp/MainScheduler.swift b/Sources/AdaApp/MainScheduler.swift index 8bd95ad7b..93b6a3faf 100644 --- a/Sources/AdaApp/MainScheduler.swift +++ b/Sources/AdaApp/MainScheduler.swift @@ -25,8 +25,11 @@ package struct MainSchedulerPlugin: Plugin { .fixed, // Update + .networkReceive, .preUpdate, .update, + .networkSend, + .networkInterpolate, .postUpdate, // Fixed @@ -44,11 +47,14 @@ package struct MainSchedulerPlugin: Plugin { app.insertResource( DefaultSchedulerOrder( order: [ + .networkReceive, .preUpdate, .update, // Apply fixed-step writes before post-update systems derive // render state such as GlobalTransform. .fixed, + .networkSend, + .networkInterpolate, .postUpdate, ] ) @@ -112,6 +118,15 @@ public struct FixedTimeSchedulerSystem { } extension SchedulerName { + /// Receives and applies network data before gameplay systems run. + public static let networkReceive = SchedulerName(rawValue: "networkReceive") + + /// Captures authoritative state after fixed simulation and sends network data. + public static let networkSend = SchedulerName(rawValue: "networkSend") + + /// Applies client presentation interpolation before transform propagation. + public static let networkInterpolate = SchedulerName(rawValue: "networkInterpolate") + /// The scheduler that synchronizes ECS state into physics backends. public static let physicsSync = SchedulerName(rawValue: "physicsSync") diff --git a/Sources/AdaCorePipelines/CorePipelines/Core2DPlugin.swift b/Sources/AdaCorePipelines/CorePipelines/Core2DPlugin.swift index ee1516b8c..0f8736d41 100644 --- a/Sources/AdaCorePipelines/CorePipelines/Core2DPlugin.swift +++ b/Sources/AdaCorePipelines/CorePipelines/Core2DPlugin.swift @@ -30,8 +30,8 @@ public struct Core2DPlugin: Plugin { app .insertResource(RenderItems()) .insertResource(SortedRenderItems()) + .addSystem(ClearTransparent2dRenderItemsSystem.self, on: .extract) .addSystem(Transparent2DBatchingSystem.self, on: .batching) - .addSystem(ClearTransparent2dRenderItemsSystem.self, on: .preUpdate) .insertResource(RenderPipelines(configurator: QuadPipeline())) .insertResource(RenderPipelines(configurator: CirclePipeline())) .insertResource(RenderPipelines(configurator: LinePipeline())) diff --git a/Sources/AdaECS/Component/Component+Runtime.swift b/Sources/AdaECS/Component/Component+Runtime.swift index 188617e51..6d67a5959 100644 --- a/Sources/AdaECS/Component/Component+Runtime.swift +++ b/Sources/AdaECS/Component/Component+Runtime.swift @@ -20,8 +20,8 @@ extension Component { @MainActor public static func registerComponent() { ComponentStorage.addComponent(self) - if let inspectableType = self as? any EditorInspectableComponent.Type { - EditorComponentReflectionRegistry.register(inspectableType.editorComponentDescriptor) + if let inspectableType = self as? any ReflectableComponent.Type { + ComponentReflectionRegistry.register(inspectableType.componentDescriptor) } } } @@ -41,6 +41,7 @@ enum ComponentStorage { private static let lock = NSLock() nonisolated(unsafe) private static var registeredComponents: [String: any Component.Type] = [:] nonisolated(unsafe) private static var defaultFactories: [String: @Sendable () -> any Component] = [:] + nonisolated(unsafe) private static var runtimeConstructors: [String: RegisteredRuntimeComponentConstructor] = [:] /// Return registered component or try to find it by NSClassFromString (works only for objc runtime) static func getRegisteredComponent(for name: String) -> (any Component.Type)? { @@ -61,6 +62,19 @@ enum ComponentStorage { lock.withLock { unsafe defaultFactories[name] = factory } } + static func addRuntimeConstructor( + _ descriptor: RuntimeComponentConstructorDescriptor, + named name: String, + makeDefault: (@Sendable () -> any Component)? + ) { + let constructor = RegisteredRuntimeComponentConstructor( + name: name, + descriptor: descriptor, + makeDefault: makeDefault + ) + lock.withLock { unsafe runtimeConstructors[name] = constructor } + } + static func makeDefaultComponent(named name: String) -> (any Component)? { let factory = lock.withLock { unsafe defaultFactories[name] } return factory?() @@ -69,6 +83,10 @@ enum ComponentStorage { static func allRegisteredComponents() -> [String: any Component.Type] { lock.withLock { unsafe registeredComponents } } + + static func allRuntimeConstructors() -> [String: RegisteredRuntimeComponentConstructor] { + lock.withLock { unsafe runtimeConstructors } + } } // This hack can help us to find struct or classes in binary diff --git a/Sources/AdaECS/Component/ComponentReflection.swift b/Sources/AdaECS/Component/ComponentReflection.swift new file mode 100644 index 000000000..098633c2d --- /dev/null +++ b/Sources/AdaECS/Component/ComponentReflection.swift @@ -0,0 +1,525 @@ +// +// ComponentReflection.swift +// AdaEngine +// + +import AdaUtils +import Foundation +import Math + +/// The runtime shape of a reflected component field. +public enum ReflectedFieldKind: Equatable, Sendable { + case bool + case int + case float + case string + case enumeration([String]) + case vector2 + case vector3 + case vector4 + case color + case assetReference + case readOnly +} + +/// A type-erased value exchanged by runtime component and resource reflection. +public enum ReflectedFieldValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([Self]) + case object([String: Self]) +} + +/// Read and write operations for one reflected component field. +@safe +public struct ReflectedComponentField: @unchecked Sendable { + public var key: String + public var label: String + public var kind: ReflectedFieldKind + public var isWritable: Bool + /// Returns whether the reflected Swift field can represent a value without trapping or losing finiteness. + public var accepts: @Sendable (ReflectedFieldValue) -> Bool + public var read: @Sendable (any Component) -> ReflectedFieldValue? + public var write: @Sendable (any Component, ReflectedFieldValue) -> (any Component)? + /// Reads this field directly from a component column element. + package var readPointer: (@Sendable (UnsafeRawPointer) -> ReflectedFieldValue?)? + /// Writes this field directly into a component column element. + package var writePointer: (@Sendable (UnsafeMutableRawPointer, ReflectedFieldValue) -> Bool)? + + public init( + key: String, + label: String, + kind: ReflectedFieldKind, + isWritable: Bool, + accepts: @escaping @Sendable (ReflectedFieldValue) -> Bool = { _ in true }, + read: @escaping @Sendable (any Component) -> ReflectedFieldValue?, + write: @escaping @Sendable (any Component, ReflectedFieldValue) -> (any Component)? + ) { + self.key = key + self.label = label + self.kind = kind + self.isWritable = isWritable + self.accepts = accepts + self.read = read + self.write = write + unsafe self.readPointer = nil + unsafe self.writePointer = nil + } + + @unsafe + public init( + key: String, + label: String, + kind: ReflectedFieldKind, + isWritable: Bool, + accepts: @escaping @Sendable (ReflectedFieldValue) -> Bool = { _ in true }, + read: @escaping @Sendable (any Component) -> ReflectedFieldValue?, + write: @escaping @Sendable (any Component, ReflectedFieldValue) -> (any Component)?, + readPointer: (@Sendable (UnsafeRawPointer) -> ReflectedFieldValue?)? = nil, + writePointer: (@Sendable (UnsafeMutableRawPointer, ReflectedFieldValue) -> Bool)? = nil + ) { + self.key = key + self.label = label + self.kind = kind + self.isWritable = isWritable + self.accepts = accepts + self.read = read + self.write = write + unsafe self.readPointer = readPointer + unsafe self.writePointer = writePointer + } +} + +/// Runtime metadata generated for a component independently of any editor UI. +public struct ReflectedComponentDescriptor: @unchecked Sendable { + public var typeName: String + public var displayName: String + public var requiredComponentTypeNames: [String] + public var fields: [ReflectedComponentField] + + public init( + typeName: String, + displayName: String, + requiredComponentTypeNames: [String], + fields: [ReflectedComponentField] + ) { + self.typeName = typeName + self.displayName = displayName + self.requiredComponentTypeNames = requiredComponentTypeNames + self.fields = fields + } + + public init( + type _: T.Type, + displayName: String = String(describing: T.self), + requiredComponentTypeNames: [String], + fields: [ReflectedComponentField] + ) { + self.init( + typeName: String(reflecting: T.self), + displayName: displayName, + requiredComponentTypeNames: requiredComponentTypeNames, + fields: fields + ) + } + + public func readPayload(from component: any Component) -> [String: ReflectedFieldValue] { + fields.reduce(into: [:]) { result, field in + result[field.key] = field.read(component) ?? .null + } + } + + public func writing(_ value: ReflectedFieldValue, toField key: String, in component: any Component) -> (any Component)? { + fields.first { $0.key == key }?.write(component, value) + } + + @discardableResult + public func write(_ value: ReflectedFieldValue, toField key: String, in world: World, entity: Entity.ID) -> Bool { + guard + let component = world.getComponent(named: typeName, from: entity), + let updated = writing(value, toField: key, in: component) + else { + return false + } + insert(updated, in: world, entity: entity) + return true + } + + private func insert(_ component: any Component, in world: World, entity: Entity.ID) { + func insertTyped(_ component: T) { + world.insert(component, for: entity) + } + _openExistential(component, do: insertTyped) + } +} + +/// An enum whose cases can be represented by component reflection. +public protocol ReflectedEnum: CaseIterable, Sendable { + var reflectedName: String { get } + static var reflectedNames: [String] { get } + static func reflectedCase(named name: String) -> Self? +} + +extension ReflectedEnum { + public var reflectedName: String { + String(describing: self) + } + + public static var reflectedNames: [String] { + allCases.map(\.reflectedName) + } + + public static func reflectedCase(named name: String) -> Self? { + allCases.first { $0.reflectedName == name } + } +} + +/// Process-wide descriptors for components registered with the runtime. +public enum ComponentReflectionRegistry { + private static let lock = NSLock() + nonisolated(unsafe) private static var descriptors: [String: ReflectedComponentDescriptor] = [:] + + public static func register(_ descriptor: ReflectedComponentDescriptor) { + lock.lock() + defer { lock.unlock() } + unsafe descriptors[descriptor.typeName] = descriptor + } + + public static func descriptor(named typeName: String) -> ReflectedComponentDescriptor? { + lock.withLock { unsafe descriptors[typeName] } + } + + public static func allDescriptors() -> [ReflectedComponentDescriptor] { + lock.withLock { unsafe descriptors.values.sorted { $0.displayName < $1.displayName } } + } +} + +/// Converts supported Swift field types to and from reflected values. +public enum ComponentReflection { + public static func kind(for _: T.Type) -> ReflectedFieldKind { + .readOnly + } + + public static func kind(for _: Bool.Type) -> ReflectedFieldKind { .bool } + public static func kind(for _: Int.Type) -> ReflectedFieldKind { .int } + public static func kind(for _: Float.Type) -> ReflectedFieldKind { .float } + public static func kind(for _: Double.Type) -> ReflectedFieldKind { .float } + public static func kind(for _: String.Type) -> ReflectedFieldKind { .string } + public static func kind(for _: Vector2.Type) -> ReflectedFieldKind { .vector2 } + public static func kind(for _: Vector3.Type) -> ReflectedFieldKind { .vector3 } + public static func kind(for _: Vector4.Type) -> ReflectedFieldKind { .vector4 } + public static func kind(for _: Quat.Type) -> ReflectedFieldKind { .vector4 } + public static func kind(for _: Color.Type) -> ReflectedFieldKind { .color } + public static func kind(for _: T.Type) -> ReflectedFieldKind { .enumeration(T.reflectedNames) } + + public static func isWritable(_: T.Type) -> Bool { + false + } + + public static func isWritable(_: Bool.Type) -> Bool { true } + public static func isWritable(_: Int.Type) -> Bool { true } + public static func isWritable(_: Float.Type) -> Bool { true } + public static func isWritable(_: Double.Type) -> Bool { true } + public static func isWritable(_: String.Type) -> Bool { true } + public static func isWritable(_: Vector2.Type) -> Bool { true } + public static func isWritable(_: Vector3.Type) -> Bool { true } + public static func isWritable(_: Vector4.Type) -> Bool { true } + public static func isWritable(_: Quat.Type) -> Bool { true } + public static func isWritable(_: Color.Type) -> Bool { true } + public static func isWritable(_: T.Type) -> Bool { true } + + public static func accepts(_: ReflectedFieldValue, for _: T.Type) -> Bool { false } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Bool.Type) -> Bool { fieldValue.boolValue != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Int.Type) -> Bool { fieldValue.intValue != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Float.Type) -> Bool { fieldValue.validFloatValue != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Double.Type) -> Bool { fieldValue.doubleValue?.isFinite == true } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: String.Type) -> Bool { + if case .string = fieldValue { + return true + } + return false + } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Vector2.Type) -> Bool { fieldValue.validFloatArray(count: 2) != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Vector3.Type) -> Bool { fieldValue.validFloatArray(count: 3) != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Vector4.Type) -> Bool { fieldValue.validFloatArray(count: 4) != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Quat.Type) -> Bool { fieldValue.validFloatArray(count: 4) != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: Color.Type) -> Bool { fieldValue.validColorComponents != nil } + public static func accepts(_ fieldValue: ReflectedFieldValue, for _: T.Type) -> Bool { + guard case let .string(string) = fieldValue else { + return false + } + return T.reflectedCase(named: string) != nil + } + + public static func value(_ fieldValue: ReflectedFieldValue, as _: Bool.Type) -> Bool? { fieldValue.boolValue } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Int.Type) -> Int? { fieldValue.intValue } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Float.Type) -> Float? { fieldValue.validFloatValue } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Double.Type) -> Double? { + guard let value = fieldValue.doubleValue, value.isFinite else { + return nil + } + return value + } + public static func value(_ fieldValue: ReflectedFieldValue, as _: String.Type) -> String? { + guard case let .string(value) = fieldValue else { + return nil + } + return value + } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Vector2.Type) -> Vector2? { + fieldValue.validFloatArray(count: 2).map { Vector2($0[0], $0[1]) } + } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Vector3.Type) -> Vector3? { + fieldValue.validFloatArray(count: 3).map { Vector3($0[0], $0[1], $0[2]) } + } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Vector4.Type) -> Vector4? { + fieldValue.validFloatArray(count: 4).map { Vector4($0[0], $0[1], $0[2], $0[3]) } + } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Quat.Type) -> Quat? { + fieldValue.validFloatArray(count: 4).map { Quat(x: $0[0], y: $0[1], z: $0[2], w: $0[3]) } + } + public static func value(_ fieldValue: ReflectedFieldValue, as _: Color.Type) -> Color? { + fieldValue.validColorComponents.map { Color(red: $0[0], green: $0[1], blue: $0[2], alpha: $0[3]) } + } + public static func value(_ fieldValue: ReflectedFieldValue, as _: T.Type) -> T? { + guard case let .string(value) = fieldValue else { + return nil + } + return T.reflectedCase(named: value) + } + + public static func read(_ value: T) -> ReflectedFieldValue { + .string(String(describing: value)) + } + + public static func read(_ value: Bool) -> ReflectedFieldValue { .bool(value) } + public static func read(_ value: Int) -> ReflectedFieldValue { .int(value) } + public static func read(_ value: Float) -> ReflectedFieldValue { .double(Double(value)) } + public static func read(_ value: Double) -> ReflectedFieldValue { .double(value) } + public static func read(_ value: String) -> ReflectedFieldValue { .string(value) } + public static func read(_ value: Vector2) -> ReflectedFieldValue { .array([.double(Double(value.x)), .double(Double(value.y))]) } + public static func read(_ value: Vector3) -> ReflectedFieldValue { .array([.double(Double(value.x)), .double(Double(value.y)), .double(Double(value.z))]) } + public static func read(_ value: Vector4) -> ReflectedFieldValue { .array([.double(Double(value.x)), .double(Double(value.y)), .double(Double(value.z)), .double(Double(value.w))]) } + public static func read(_ value: Quat) -> ReflectedFieldValue { .array([.double(Double(value.x)), .double(Double(value.y)), .double(Double(value.z)), .double(Double(value.w))]) } + public static func read(_ value: Color) -> ReflectedFieldValue { + .object([ + "red": .double(Double(value.red)), + "green": .double(Double(value.green)), + "blue": .double(Double(value.blue)), + "alpha": .double(Double(value.alpha)), + ]) + } + public static func read(_ value: T) -> ReflectedFieldValue { .string(value.reflectedName) } + + public static func write(_: ReflectedFieldValue, to _: inout T) -> Bool { + false + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Bool) -> Bool { + guard let bool = fieldValue.boolValue else { + return false + } + value = bool + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Int) -> Bool { + guard let int = fieldValue.intValue else { + return false + } + value = int + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Float) -> Bool { + guard let float = fieldValue.validFloatValue else { + return false + } + value = float + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Double) -> Bool { + guard let double = fieldValue.doubleValue, double.isFinite else { + return false + } + value = double + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout String) -> Bool { + guard case let .string(string) = fieldValue else { + return false + } + value = string + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Vector2) -> Bool { + guard let components = fieldValue.validFloatArray(count: 2) else { + return false + } + value = Vector2(components[0], components[1]) + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Vector3) -> Bool { + guard let components = fieldValue.validFloatArray(count: 3) else { + return false + } + value = Vector3(components[0], components[1], components[2]) + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Vector4) -> Bool { + guard let components = fieldValue.validFloatArray(count: 4) else { + return false + } + value = Vector4(components[0], components[1], components[2], components[3]) + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Quat) -> Bool { + guard let components = fieldValue.validFloatArray(count: 4) else { + return false + } + value = Quat(x: components[0], y: components[1], z: components[2], w: components[3]) + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout Color) -> Bool { + guard let components = fieldValue.validColorComponents else { + return false + } + value = Color(red: components[0], green: components[1], blue: components[2], alpha: components[3]) + return true + } + + public static func write(_ fieldValue: ReflectedFieldValue, to value: inout T) -> Bool { + guard + case let .string(string) = fieldValue, + let enumValue = T.reflectedCase(named: string) + else { + return false + } + value = enumValue + return true + } +} + +extension ReflectedFieldValue { + public var validFloatValue: Float? { + guard + let value = doubleValue, + value.isFinite, + abs(value) <= Double(Float.greatestFiniteMagnitude) + else { + return nil + } + return Float(value) + } + + public func validFloatArray(count: Int) -> [Float]? { + guard + let components = numericArray(count: count), + components.allSatisfy({ $0.isFinite && abs($0) <= Double(Float.greatestFiniteMagnitude) }) + else { + return nil + } + return components.map(Float.init) + } + + public var validColorComponents: [Float]? { + guard + let components = colorComponents, + components.allSatisfy({ $0.isFinite && abs($0) <= Double(Float.greatestFiniteMagnitude) }) + else { + return nil + } + return components.map(Float.init) + } + + public var doubleValue: Double? { + switch self { + case let .int(value): + Double(value) + case let .double(value): + value + case let .string(value): + Double(value) + default: + nil + } + } + + public var intValue: Int? { + switch self { + case let .int(value): + return value + case let .double(value): + guard + value.isFinite, + value >= Double(Int.min), + value < Double(Int.max) + 1 + else { + return nil + } + return Int(value) + case let .string(value): + return Int(value) + default: + return nil + } + } + + public var boolValue: Bool? { + switch self { + case let .bool(value): + value + case .string("true"), + .string("1"): + true + case .string("false"), + .string("0"): + false + default: + nil + } + } + + public var colorComponents: [Double]? { + if let components = numericArray(count: 4) { + return components + } + guard case let .object(object) = self else { + return nil + } + return [ + object["red"]?.doubleValue ?? 0, + object["green"]?.doubleValue ?? 0, + object["blue"]?.doubleValue ?? 0, + object["alpha"]?.doubleValue ?? 1, + ] + } + + public func numericArray(count: Int) -> [Double]? { + guard case let .array(values) = self else { + return nil + } + var numbers: [Double] = [] + numbers.reserveCapacity(count) + for value in values.prefix(count) { + guard let number = value.doubleValue else { + return nil + } + numbers.append(number) + } + if numbers.count < count { + numbers.append(contentsOf: Array(repeating: 0, count: count - numbers.count)) + } + return numbers + } +} diff --git a/Sources/AdaECS/Component/EditorComponentReflection.swift b/Sources/AdaECS/Component/EditorComponentReflection.swift deleted file mode 100644 index e406c314b..000000000 --- a/Sources/AdaECS/Component/EditorComponentReflection.swift +++ /dev/null @@ -1,481 +0,0 @@ -// -// EditorComponentReflection.swift -// AdaEngine -// - -import AdaUtils -import Foundation -import Math - -public enum EditorFieldKind: Equatable, Sendable { - case bool - case int - case float - case string - case enumeration([String]) - case vector2 - case vector3 - case vector4 - case color - case assetReference - case readOnly -} - -public enum EditorFieldValue: Codable, Equatable, Sendable { - case null - case bool(Bool) - case int(Int) - case double(Double) - case string(String) - case array([Self]) - case object([String: Self]) -} - -@safe -public struct EditorComponentFieldDescriptor: @unchecked Sendable { - public var key: String - public var label: String - public var kind: EditorFieldKind - public var isEditable: Bool - /// Returns whether the reflected Swift field can represent a value without trapping or losing finiteness. - public var accepts: @Sendable (EditorFieldValue) -> Bool - public var read: @Sendable (any Component) -> EditorFieldValue? - public var write: @Sendable (any Component, EditorFieldValue) -> (any Component)? - /// Reads this field directly from a component column element. - package var readPointer: (@Sendable (UnsafeRawPointer) -> EditorFieldValue?)? - /// Writes this field directly into a component column element. - package var writePointer: (@Sendable (UnsafeMutableRawPointer, EditorFieldValue) -> Bool)? - - public init( - key: String, - label: String, - kind: EditorFieldKind, - isEditable: Bool, - accepts: @escaping @Sendable (EditorFieldValue) -> Bool = { _ in true }, - read: @escaping @Sendable (any Component) -> EditorFieldValue?, - write: @escaping @Sendable (any Component, EditorFieldValue) -> (any Component)? - ) { - self.key = key - self.label = label - self.kind = kind - self.isEditable = isEditable - self.accepts = accepts - self.read = read - self.write = write - unsafe self.readPointer = nil - unsafe self.writePointer = nil - } - - @unsafe - public init( - key: String, - label: String, - kind: EditorFieldKind, - isEditable: Bool, - accepts: @escaping @Sendable (EditorFieldValue) -> Bool = { _ in true }, - read: @escaping @Sendable (any Component) -> EditorFieldValue?, - write: @escaping @Sendable (any Component, EditorFieldValue) -> (any Component)?, - readPointer: (@Sendable (UnsafeRawPointer) -> EditorFieldValue?)? = nil, - writePointer: (@Sendable (UnsafeMutableRawPointer, EditorFieldValue) -> Bool)? = nil - ) { - self.key = key - self.label = label - self.kind = kind - self.isEditable = isEditable - self.accepts = accepts - self.read = read - self.write = write - unsafe self.readPointer = readPointer - unsafe self.writePointer = writePointer - } -} - -public struct EditorComponentDescriptor: @unchecked Sendable { - public var typeName: String - public var displayName: String - public var requiredComponentTypeNames: [String] - public var fields: [EditorComponentFieldDescriptor] - - public init( - typeName: String, - displayName: String, - requiredComponentTypeNames: [String], - fields: [EditorComponentFieldDescriptor] - ) { - self.typeName = typeName - self.displayName = displayName - self.requiredComponentTypeNames = requiredComponentTypeNames - self.fields = fields - } - - public init( - type _: T.Type, - displayName: String = String(describing: T.self), - requiredComponentTypeNames: [String], - fields: [EditorComponentFieldDescriptor] - ) { - self.init( - typeName: String(reflecting: T.self), - displayName: displayName, - requiredComponentTypeNames: requiredComponentTypeNames, - fields: fields - ) - } - - public func readPayload(from component: any Component) -> [String: EditorFieldValue] { - fields.reduce(into: [:]) { result, field in - result[field.key] = field.read(component) ?? .null - } - } - - public func writing(_ value: EditorFieldValue, toField key: String, in component: any Component) -> (any Component)? { - fields.first { $0.key == key }?.write(component, value) - } - - @discardableResult - public func write(_ value: EditorFieldValue, toField key: String, in world: World, entity: Entity.ID) -> Bool { - guard - let component = world.getComponent(named: typeName, from: entity), - let updated = writing(value, toField: key, in: component) - else { - return false - } - insert(updated, in: world, entity: entity) - return true - } - - private func insert(_ component: any Component, in world: World, entity: Entity.ID) { - func insertTyped(_ component: T) { - world.insert(component, for: entity) - } - _openExistential(component, do: insertTyped) - } -} - -public protocol EditorEnumReflectable: CaseIterable, Sendable { - var editorCaseName: String { get } - static var editorCaseNames: [String] { get } - static func editorCase(named name: String) -> Self? -} - -extension EditorEnumReflectable { - public var editorCaseName: String { - String(describing: self) - } - - public static var editorCaseNames: [String] { - allCases.map(\.editorCaseName) - } - - public static func editorCase(named name: String) -> Self? { - allCases.first { $0.editorCaseName == name } - } -} - -public enum EditorComponentReflectionRegistry { - private static let lock = NSLock() - nonisolated(unsafe) private static var descriptors: [String: EditorComponentDescriptor] = [:] - - public static func register(_ descriptor: EditorComponentDescriptor) { - lock.lock() - defer { lock.unlock() } - unsafe descriptors[descriptor.typeName] = descriptor - } - - public static func descriptor(named typeName: String) -> EditorComponentDescriptor? { - lock.withLock { unsafe descriptors[typeName] } - } - - public static func allDescriptors() -> [EditorComponentDescriptor] { - lock.withLock { unsafe descriptors.values.sorted { $0.displayName < $1.displayName } } - } -} - -public enum EditorComponentReflection { - public static func kind(for _: T.Type) -> EditorFieldKind { - .readOnly - } - - public static func kind(for _: Bool.Type) -> EditorFieldKind { .bool } - public static func kind(for _: Int.Type) -> EditorFieldKind { .int } - public static func kind(for _: Float.Type) -> EditorFieldKind { .float } - public static func kind(for _: Double.Type) -> EditorFieldKind { .float } - public static func kind(for _: String.Type) -> EditorFieldKind { .string } - public static func kind(for _: Vector2.Type) -> EditorFieldKind { .vector2 } - public static func kind(for _: Vector3.Type) -> EditorFieldKind { .vector3 } - public static func kind(for _: Vector4.Type) -> EditorFieldKind { .vector4 } - public static func kind(for _: Quat.Type) -> EditorFieldKind { .vector4 } - public static func kind(for _: Color.Type) -> EditorFieldKind { .color } - public static func kind(for _: T.Type) -> EditorFieldKind { .enumeration(T.editorCaseNames) } - - public static func isEditable(_: T.Type) -> Bool { - false - } - - public static func isEditable(_: Bool.Type) -> Bool { true } - public static func isEditable(_: Int.Type) -> Bool { true } - public static func isEditable(_: Float.Type) -> Bool { true } - public static func isEditable(_: Double.Type) -> Bool { true } - public static func isEditable(_: String.Type) -> Bool { true } - public static func isEditable(_: Vector2.Type) -> Bool { true } - public static func isEditable(_: Vector3.Type) -> Bool { true } - public static func isEditable(_: Vector4.Type) -> Bool { true } - public static func isEditable(_: Quat.Type) -> Bool { true } - public static func isEditable(_: Color.Type) -> Bool { true } - public static func isEditable(_: T.Type) -> Bool { true } - - public static func accepts(_: EditorFieldValue, for _: T.Type) -> Bool { false } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Bool.Type) -> Bool { fieldValue.boolValue != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Int.Type) -> Bool { fieldValue.intValue != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Float.Type) -> Bool { fieldValue.validFloatValue != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Double.Type) -> Bool { fieldValue.doubleValue?.isFinite == true } - public static func accepts(_ fieldValue: EditorFieldValue, for _: String.Type) -> Bool { - if case .string = fieldValue { - return true - } - return false - } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Vector2.Type) -> Bool { fieldValue.validFloatArray(count: 2) != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Vector3.Type) -> Bool { fieldValue.validFloatArray(count: 3) != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Vector4.Type) -> Bool { fieldValue.validFloatArray(count: 4) != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Quat.Type) -> Bool { fieldValue.validFloatArray(count: 4) != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: Color.Type) -> Bool { fieldValue.validColorComponents != nil } - public static func accepts(_ fieldValue: EditorFieldValue, for _: T.Type) -> Bool { - guard case let .string(string) = fieldValue else { - return false - } - return T.editorCase(named: string) != nil - } - - public static func read(_ value: T) -> EditorFieldValue { - .string(String(describing: value)) - } - - public static func read(_ value: Bool) -> EditorFieldValue { .bool(value) } - public static func read(_ value: Int) -> EditorFieldValue { .int(value) } - public static func read(_ value: Float) -> EditorFieldValue { .double(Double(value)) } - public static func read(_ value: Double) -> EditorFieldValue { .double(value) } - public static func read(_ value: String) -> EditorFieldValue { .string(value) } - public static func read(_ value: Vector2) -> EditorFieldValue { .array([.double(Double(value.x)), .double(Double(value.y))]) } - public static func read(_ value: Vector3) -> EditorFieldValue { .array([.double(Double(value.x)), .double(Double(value.y)), .double(Double(value.z))]) } - public static func read(_ value: Vector4) -> EditorFieldValue { .array([.double(Double(value.x)), .double(Double(value.y)), .double(Double(value.z)), .double(Double(value.w))]) } - public static func read(_ value: Quat) -> EditorFieldValue { .array([.double(Double(value.x)), .double(Double(value.y)), .double(Double(value.z)), .double(Double(value.w))]) } - public static func read(_ value: Color) -> EditorFieldValue { - .object([ - "red": .double(Double(value.red)), - "green": .double(Double(value.green)), - "blue": .double(Double(value.blue)), - "alpha": .double(Double(value.alpha)), - ]) - } - public static func read(_ value: T) -> EditorFieldValue { .string(value.editorCaseName) } - - public static func write(_: EditorFieldValue, to _: inout T) -> Bool { - false - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Bool) -> Bool { - guard let bool = fieldValue.boolValue else { - return false - } - value = bool - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Int) -> Bool { - guard let int = fieldValue.intValue else { - return false - } - value = int - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Float) -> Bool { - guard let float = fieldValue.validFloatValue else { - return false - } - value = float - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Double) -> Bool { - guard let double = fieldValue.doubleValue, double.isFinite else { - return false - } - value = double - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout String) -> Bool { - guard case let .string(string) = fieldValue else { - return false - } - value = string - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Vector2) -> Bool { - guard let components = fieldValue.validFloatArray(count: 2) else { - return false - } - value = Vector2(components[0], components[1]) - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Vector3) -> Bool { - guard let components = fieldValue.validFloatArray(count: 3) else { - return false - } - value = Vector3(components[0], components[1], components[2]) - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Vector4) -> Bool { - guard let components = fieldValue.validFloatArray(count: 4) else { - return false - } - value = Vector4(components[0], components[1], components[2], components[3]) - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Quat) -> Bool { - guard let components = fieldValue.validFloatArray(count: 4) else { - return false - } - value = Quat(x: components[0], y: components[1], z: components[2], w: components[3]) - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout Color) -> Bool { - guard let components = fieldValue.validColorComponents else { - return false - } - value = Color(red: components[0], green: components[1], blue: components[2], alpha: components[3]) - return true - } - - public static func write(_ fieldValue: EditorFieldValue, to value: inout T) -> Bool { - guard - case let .string(string) = fieldValue, - let enumValue = T.editorCase(named: string) - else { - return false - } - value = enumValue - return true - } -} - -extension EditorFieldValue { - public var validFloatValue: Float? { - guard - let value = doubleValue, - value.isFinite, - abs(value) <= Double(Float.greatestFiniteMagnitude) - else { - return nil - } - return Float(value) - } - - public func validFloatArray(count: Int) -> [Float]? { - guard - let components = numericArray(count: count), - components.allSatisfy({ $0.isFinite && abs($0) <= Double(Float.greatestFiniteMagnitude) }) - else { - return nil - } - return components.map(Float.init) - } - - public var validColorComponents: [Float]? { - guard - let components = colorComponents, - components.allSatisfy({ $0.isFinite && abs($0) <= Double(Float.greatestFiniteMagnitude) }) - else { - return nil - } - return components.map(Float.init) - } - - public var doubleValue: Double? { - switch self { - case let .int(value): - Double(value) - case let .double(value): - value - case let .string(value): - Double(value) - default: - nil - } - } - - public var intValue: Int? { - switch self { - case let .int(value): - return value - case let .double(value): - guard - value.isFinite, - value >= Double(Int.min), - value < Double(Int.max) + 1 - else { - return nil - } - return Int(value) - case let .string(value): - return Int(value) - default: - return nil - } - } - - public var boolValue: Bool? { - switch self { - case let .bool(value): - value - case .string("true"), - .string("1"): - true - case .string("false"), - .string("0"): - false - default: - nil - } - } - - public var colorComponents: [Double]? { - if let components = numericArray(count: 4) { - return components - } - guard case let .object(object) = self else { - return nil - } - return [ - object["red"]?.doubleValue ?? 0, - object["green"]?.doubleValue ?? 0, - object["blue"]?.doubleValue ?? 0, - object["alpha"]?.doubleValue ?? 1, - ] - } - - public func numericArray(count: Int) -> [Double]? { - guard case let .array(values) = self else { - return nil - } - var numbers: [Double] = [] - numbers.reserveCapacity(count) - for value in values.prefix(count) { - guard let number = value.doubleValue else { - return nil - } - numbers.append(number) - } - if numbers.count < count { - numbers.append(contentsOf: Array(repeating: 0, count: count - numbers.count)) - } - return numbers - } -} diff --git a/Sources/AdaECS/Component/RuntimeComponentConstructor.swift b/Sources/AdaECS/Component/RuntimeComponentConstructor.swift new file mode 100644 index 000000000..fd6957af0 --- /dev/null +++ b/Sources/AdaECS/Component/RuntimeComponentConstructor.swift @@ -0,0 +1,148 @@ +/// One named input accepted by a generated runtime component constructor. +public struct RuntimeComponentConstructorParameter: Equatable, Sendable { + public let kind: ReflectedFieldKind + public let name: String + + public init(name: String, kind: ReflectedFieldKind) { + self.kind = kind + self.name = name + } +} + +/// Errors produced while materializing a component from detached runtime values. +public enum RuntimeComponentConstructorError: Error, Equatable, Sendable { + case argumentCount(expected: Int, actual: Int) + case invalidArgument(component: String, parameter: String) + case invalidBase(component: String) + case missingArgument(component: String, parameter: String) + case missingDefaultFactory(component: String) +} + +/// Type-owned constructor metadata generated by ``Component()``. +/// +/// The generated apply closure writes supported stored properties directly on +/// the concrete component value. It performs no string lookup or reflection +/// traversal on the construction path. +public struct RuntimeComponentConstructorDescriptor: @unchecked Sendable { + public let parameters: [RuntimeComponentConstructorParameter] + public let typeName: String + + /// Whether construction needs an alias-specific default component. + public var requiresDefaultComponent: Bool { + constructArguments == nil + } + + private let applyArguments: (@Sendable ( + consuming any Component, + [ReflectedFieldValue?] + ) throws -> any Component)? + private let constructArguments: (@Sendable ( + [ReflectedFieldValue?] + ) throws -> any Component)? + + public init( + typeName: String, + parameters: [RuntimeComponentConstructorParameter], + applyArguments: @escaping @Sendable ( + consuming any Component, + [ReflectedFieldValue?] + ) throws -> any Component + ) { + self.applyArguments = applyArguments + self.constructArguments = nil + self.parameters = parameters + self.typeName = typeName + } + + /// Creates a descriptor backed by a real Swift initializer. + /// + /// Unlike the fieldwise bridge, this form does not materialize a default + /// component before construction and therefore preserves initializer + /// invariants without adding work to the spawn path. + public init( + typeName: String, + parameters: [RuntimeComponentConstructorParameter], + constructArguments: @escaping @Sendable ( + [ReflectedFieldValue?] + ) throws -> any Component + ) { + self.applyArguments = nil + self.constructArguments = constructArguments + self.parameters = parameters + self.typeName = typeName + } + + package func apply( + to component: consuming any Component, + arguments: [ReflectedFieldValue?] + ) throws -> any Component { + guard arguments.count == parameters.count else { + throw RuntimeComponentConstructorError.argumentCount( + expected: parameters.count, + actual: arguments.count + ) + } + if let constructArguments { + return try constructArguments(arguments) + } + guard let applyArguments else { + preconditionFailure("Runtime component constructor has no implementation") + } + return try applyArguments(component, arguments) + } + + package func construct( + arguments: [ReflectedFieldValue?], + makeDefault: (@Sendable () -> any Component)? + ) throws -> any Component { + guard arguments.count == parameters.count else { + throw RuntimeComponentConstructorError.argumentCount( + expected: parameters.count, + actual: arguments.count + ) + } + if let constructArguments { + return try constructArguments(arguments) + } + guard let makeDefault else { + throw RuntimeComponentConstructorError.missingDefaultFactory(component: typeName) + } + guard let applyArguments else { + preconditionFailure("Runtime component constructor has no implementation") + } + return try applyArguments(makeDefault(), arguments) + } +} + +/// A component whose runtime constructor metadata was generated by +/// ``Component()``. A fieldwise constructor needs an alias-specific default +/// factory; an ``AdaScriptInit()`` constructor can be registered directly. +public protocol RuntimeConstructibleComponent: Component { + static var runtimeComponentConstructor: RuntimeComponentConstructorDescriptor { get } +} + +/// A registered alias resolved once while an AdaScript module is linked. +public struct RegisteredRuntimeComponentConstructor: @unchecked Sendable { + public let name: String + public let parameters: [RuntimeComponentConstructorParameter] + public let typeName: String + + private let constructValue: @Sendable ([ReflectedFieldValue?]) throws -> any Component + + package init( + name: String, + descriptor: RuntimeComponentConstructorDescriptor, + makeDefault: (@Sendable () -> any Component)? + ) { + self.name = name + self.parameters = descriptor.parameters + self.typeName = descriptor.typeName + self.constructValue = { arguments in + try descriptor.construct(arguments: arguments, makeDefault: makeDefault) + } + } + + public func construct(arguments: [ReflectedFieldValue?]) throws -> any Component { + try constructValue(arguments) + } +} diff --git a/Sources/AdaECS/ECSMacros.swift b/Sources/AdaECS/ECSMacros.swift index 020f404af..f0d4ae478 100644 --- a/Sources/AdaECS/ECSMacros.swift +++ b/Sources/AdaECS/ECSMacros.swift @@ -7,10 +7,9 @@ import AdaUtils -// TODO: Add reflrection support - -public protocol EditorInspectableComponent: Component { - static var editorComponentDescriptor: EditorComponentDescriptor { get } +/// A component that exposes generated, runtime-agnostic field metadata. +public protocol ReflectableComponent: Component { + static var componentDescriptor: ReflectedComponentDescriptor { get } } /// A macro for creating a component. @@ -29,11 +28,23 @@ public protocol EditorInspectableComponent: Component { /// .setPosition(Vector3(0, 0, 0)) /// ``` @attached(member) -@attached(extension, conformances: Component, EditorInspectableComponent, names: arbitrary) +@attached( + extension, + conformances: Component, ReflectableComponent, RuntimeConstructibleComponent, + names: arbitrary +) public macro Component( required: [any (Component & DefaultValue).Type] = [] ) = #externalMacro(module: "AdaEngineMacros", type: "ComponentMacro") +/// Selects the Swift initializer used by AdaScript host construction. +/// ``Component()`` consumes this marker and generates the direct bridge. +@attached(peer) +public macro AdaScriptInit() = #externalMacro( + module: "AdaEngineMacros", + type: "AdaScriptInitMacro" +) + /// A macro for creating a bundle. /// A bundle macro is more preffered way to create a bundle. /// When you use a bundle macro, you will atomatically conforms ``Bundle`` protocol. diff --git a/Sources/AdaECS/Query/DynamicQuery.swift b/Sources/AdaECS/Query/DynamicQuery.swift index 4042d00c6..5fb0511ac 100644 --- a/Sources/AdaECS/Query/DynamicQuery.swift +++ b/Sources/AdaECS/Query/DynamicQuery.swift @@ -136,8 +136,8 @@ public final class DynamicQueryCursor: @unchecked Sendable { public func read( componentAt componentIndex: Int, - field: EditorComponentFieldDescriptor - ) -> EditorFieldValue? { + field: ReflectedComponentField + ) -> ReflectedFieldValue? { guard columns.indices.contains(componentIndex), rowPosition >= 0, let readPointer = unsafe field.readPointer @@ -152,8 +152,8 @@ public final class DynamicQueryCursor: @unchecked Sendable { @discardableResult public func write( componentAt componentIndex: Int, - field: EditorComponentFieldDescriptor, - value: EditorFieldValue + field: ReflectedComponentField, + value: ReflectedFieldValue ) -> Bool { guard columns.indices.contains(componentIndex), rowPosition >= 0, diff --git a/Sources/AdaECS/Query/DynamicResource.swift b/Sources/AdaECS/Query/DynamicResource.swift index 780993d10..c8d389676 100644 --- a/Sources/AdaECS/Query/DynamicResource.swift +++ b/Sources/AdaECS/Query/DynamicResource.swift @@ -2,10 +2,10 @@ import AdaUtils import Foundation public struct RuntimeResourceDescriptor: Sendable { - public let fields: [EditorComponentFieldDescriptor] + public let fields: [ReflectedComponentField] public let typeIdentifier: ObjectIdentifier - public init(type: T.Type, fields: [EditorComponentFieldDescriptor]) { + public init(type: T.Type, fields: [ReflectedComponentField]) { self.fields = fields self.typeIdentifier = ObjectIdentifier(type) } @@ -15,7 +15,7 @@ public enum RuntimeResourceReflectionRegistry { private static let lock = NSLock() nonisolated(unsafe) private static var descriptors: [ObjectIdentifier: RuntimeResourceDescriptor] = [:] - public static func register(_ type: T.Type, fields: [EditorComponentFieldDescriptor]) { + public static func register(_ type: T.Type, fields: [ReflectedComponentField]) { lock.withLock { unsafe descriptors[ObjectIdentifier(type)] = RuntimeResourceDescriptor(type: type, fields: fields) } @@ -61,7 +61,7 @@ public final class DynamicResource: @unchecked Sendable { public var isAvailable: Bool { unsafe pointer != nil } - public func read(field: EditorComponentFieldDescriptor) -> EditorFieldValue? { + public func read(field: ReflectedComponentField) -> ReflectedFieldValue? { guard let pointer = unsafe pointer, let readPointer = unsafe field.readPointer else { return nil } @@ -69,7 +69,7 @@ public final class DynamicResource: @unchecked Sendable { } @discardableResult - public func write(field: EditorComponentFieldDescriptor, value: EditorFieldValue) -> Bool { + public func write(field: ReflectedComponentField, value: ReflectedFieldValue) -> Bool { guard field.accepts(value), let pointer = unsafe pointer, let writePointer = unsafe field.writePointer, unsafe writePointer(pointer, value) diff --git a/Sources/AdaECS/RuntimeIntrospection.swift b/Sources/AdaECS/RuntimeIntrospection.swift index 1b9270db0..627504d8e 100644 --- a/Sources/AdaECS/RuntimeIntrospection.swift +++ b/Sources/AdaECS/RuntimeIntrospection.swift @@ -13,6 +13,16 @@ public enum RuntimeTypeRegistry { if let makeDefault { ComponentStorage.addDefaultFactory(makeDefault, named: name) } + if let constructibleType = type as? any RuntimeConstructibleComponent.Type { + let descriptor = constructibleType.runtimeComponentConstructor + if makeDefault != nil || !descriptor.requiresDefaultComponent { + ComponentStorage.addRuntimeConstructor( + descriptor, + named: name, + makeDefault: makeDefault + ) + } + } } } @@ -43,4 +53,8 @@ public enum RuntimeTypeRegistry { public static func makeDefaultComponent(named name: String) -> (any Component)? { ComponentStorage.makeDefaultComponent(named: name) } + + public static func registeredRuntimeComponentConstructors() -> [RegisteredRuntimeComponentConstructor] { + ComponentStorage.allRuntimeConstructors().values.sorted { $0.name < $1.name } + } } diff --git a/Sources/AdaECS/World/World+RuntimeIntrospection.swift b/Sources/AdaECS/World/World+RuntimeIntrospection.swift index 3d49a6979..6fed49569 100644 --- a/Sources/AdaECS/World/World+RuntimeIntrospection.swift +++ b/Sources/AdaECS/World/World+RuntimeIntrospection.swift @@ -64,8 +64,8 @@ extension World { @_spi(Scripting) public func readResourceField( type: any Resource.Type, - field: EditorComponentFieldDescriptor - ) -> EditorFieldValue? { + field: ReflectedComponentField + ) -> ReflectedFieldValue? { guard let data = resources.getResourceData(for: type), let pointer = unsafe data.pointer.buffer.pointer.baseAddress, @@ -80,8 +80,8 @@ extension World { @discardableResult public func writeResourceField( type: any Resource.Type, - field: EditorComponentFieldDescriptor, - value: EditorFieldValue + field: ReflectedComponentField, + value: ReflectedFieldValue ) -> Bool { guard field.accepts(value), diff --git a/Sources/AdaEngineMacros/AdaEngineMacrosPlugin.swift b/Sources/AdaEngineMacros/AdaEngineMacrosPlugin.swift index 975315829..3a6ce9fda 100644 --- a/Sources/AdaEngineMacros/AdaEngineMacrosPlugin.swift +++ b/Sources/AdaEngineMacros/AdaEngineMacrosPlugin.swift @@ -12,6 +12,7 @@ import SwiftSyntaxMacros struct AdaEngineMacrosPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ ComponentMacro.self, + AdaScriptInitMacro.self, EntryMacro.self, SystemMacro.self, BundleMacro.self, diff --git a/Sources/AdaEngineMacros/AdaScriptInitMacro.swift b/Sources/AdaEngineMacros/AdaScriptInitMacro.swift new file mode 100644 index 000000000..690e626e7 --- /dev/null +++ b/Sources/AdaEngineMacros/AdaScriptInitMacro.swift @@ -0,0 +1,14 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +/// Marker consumed by ``ComponentMacro`` when generating a direct runtime +/// constructor. The marker itself does not emit a peer declaration. +public struct AdaScriptInitMacro: PeerMacro { + public static func expansion( + of _: AttributeSyntax, + providingPeersOf _: some DeclSyntaxProtocol, + in _: some MacroExpansionContext + ) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/AdaEngineMacros/ComponentMacro.swift b/Sources/AdaEngineMacros/ComponentMacro.swift index fb891a8ff..08edd9b41 100644 --- a/Sources/AdaEngineMacros/ComponentMacro.swift +++ b/Sources/AdaEngineMacros/ComponentMacro.swift @@ -44,7 +44,7 @@ public struct ComponentMacro: ExtensionMacro { } return if let structDecl = declaration.as(StructDeclSyntax.self) { - componentMacroForStruct(structDecl, type: type, requiredComponents: dependencies) + try componentMacroForStruct(structDecl, type: type, requiredComponents: dependencies) } else if let enumDecl = declaration.as(EnumDeclSyntax.self) { generateDeclaration( type: type, @@ -59,6 +59,18 @@ public struct ComponentMacro: ExtensionMacro { } extension ComponentMacro { + private struct ExplicitRuntimeConstructor { + let body: String + let parameters: [String] + } + + private struct RuntimeConstructorParameter { + let defaultExpression: String? + let externalName: String + let localName: String + let typeName: String + } + /// Extracts type name from expression like Transform.self or AdaTransform.Transform.self private static func extractTypeName(from expression: ExprSyntax) -> String? { // Handle cases like Transform.self or AdaTransform.Transform.self @@ -98,7 +110,7 @@ extension ComponentMacro { _ structDecl: StructDeclSyntax, type: T, requiredComponents: [String] - ) -> [SwiftSyntax.ExtensionDeclSyntax] { + ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { let properties = structDecl.memberBlock.members.compactMap { member -> (String, TypeSyntax, String)? in guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { return nil @@ -140,38 +152,38 @@ extension ComponentMacro { """ } - let editorFields = properties.map { propertyName, propertyType, _ in + let reflectedFields = properties.map { propertyName, propertyType, _ in """ - unsafe AdaECS.EditorComponentFieldDescriptor( + unsafe AdaECS.ReflectedComponentField( key: "\(propertyName)", - label: "\(propertyName.editorFieldLabel)", - kind: AdaECS.EditorComponentReflection.kind(for: \(propertyType).self), - isEditable: AdaECS.EditorComponentReflection.isEditable(\(propertyType).self), + label: "\(propertyName.reflectedFieldLabel)", + kind: AdaECS.ComponentReflection.kind(for: \(propertyType).self), + isWritable: AdaECS.ComponentReflection.isWritable(\(propertyType).self), accepts: { fieldValue in - AdaECS.EditorComponentReflection.accepts(fieldValue, for: \(propertyType).self) + AdaECS.ComponentReflection.accepts(fieldValue, for: \(propertyType).self) }, read: { component in guard let typedComponent = component as? Self else { return nil } - return AdaECS.EditorComponentReflection.read(typedComponent.\(propertyName)) + return AdaECS.ComponentReflection.read(typedComponent.\(propertyName)) }, write: { component, fieldValue in guard var typedComponent = component as? Self else { return nil } - guard AdaECS.EditorComponentReflection.write(fieldValue, to: &typedComponent.\(propertyName)) else { + guard AdaECS.ComponentReflection.write(fieldValue, to: &typedComponent.\(propertyName)) else { return nil } return typedComponent }, readPointer: { pointer in let typedComponent = unsafe pointer.assumingMemoryBound(to: Self.self) - return AdaECS.EditorComponentReflection.read(unsafe typedComponent.pointee.\(propertyName)) + return AdaECS.ComponentReflection.read(unsafe typedComponent.pointee.\(propertyName)) }, writePointer: { pointer, fieldValue in let typedComponent = unsafe pointer.assumingMemoryBound(to: Self.self) - return unsafe AdaECS.EditorComponentReflection.write( + return unsafe AdaECS.ComponentReflection.write( fieldValue, to: &typedComponent.pointee.\(propertyName) ) @@ -180,27 +192,221 @@ extension ComponentMacro { """ } + let runtimeConstructorParameters = properties.map { propertyName, propertyType, _ in + """ + AdaECS.RuntimeComponentConstructorParameter( + name: "\(propertyName)", + kind: AdaECS.ComponentReflection.kind(for: \(propertyType).self) + ) + """ + } + let runtimeConstructorAssignments = properties.map { propertyName, propertyType, _ in + """ + if AdaECS.ComponentReflection.isWritable(\(propertyType).self) { + if let fieldValue = arguments[argumentIndex], + !AdaECS.ComponentReflection.write(fieldValue, to: &typedComponent.\(propertyName)) { + throw AdaECS.RuntimeComponentConstructorError.invalidArgument( + component: String(reflecting: Self.self), + parameter: "\(propertyName)" + ) + } + argumentIndex += 1 + } + """ + } + + let explicitConstructor = try adaScriptConstructor(in: structDecl) return generateDeclaration( type: type, availability: structDecl.modifiers, functions: functions, requiredComponents: requiredComponents, - editorFields: editorFields + reflectedFields: reflectedFields, + runtimeConstructorParameters: explicitConstructor?.parameters ?? runtimeConstructorParameters, + runtimeConstructorAssignments: explicitConstructor == nil ? runtimeConstructorAssignments : [], + runtimeConstructorBody: explicitConstructor?.body ) } + private static func adaScriptConstructor( + in structDecl: StructDeclSyntax + ) throws -> ExplicitRuntimeConstructor? { + let markedInitializers = structDecl.memberBlock.members.compactMap { member -> InitializerDeclSyntax? in + guard let initializer = member.decl.as(InitializerDeclSyntax.self) else { + return nil + } + let isMarked = initializer.attributes.contains { element in + guard let attribute = element.as(AttributeSyntax.self) else { + return false + } + let name = attribute.attributeName.trimmedDescription + return name == "AdaScriptInit" || name.hasSuffix(".AdaScriptInit") + } + return isMarked ? initializer : nil + } + + guard !markedInitializers.isEmpty else { + return nil + } + guard markedInitializers.count == 1, let initializer = markedInitializers.first else { + throw MacroError.macroUsage("A component can declare only one @AdaScriptInit initializer.") + } + + let parameters = try initializer.signature.parameterClause.parameters.map { parameter in + let externalName = parameter.firstName.text + let localName: String + if let secondName = parameter.secondName { + localName = secondName.text + } else if externalName != "_" { + localName = externalName + } else { + throw MacroError.macroUsage("An unnamed @AdaScriptInit parameter requires a local name.") + } + return RuntimeConstructorParameter( + defaultExpression: parameter.defaultValue?.value.trimmedDescription, + externalName: externalName, + localName: localName, + typeName: parameter.type.trimmedDescription + ) + } + + var exposedParameters: [String] = [] + var argumentDecoders: [String] = [] + var initializerArguments: [String] = [] + var argumentIndex = 0 + + for parameter in parameters { + let callArgument = parameter.externalName == "_" + ? parameter.localName + : "\(parameter.externalName): \(parameter.localName)" + initializerArguments.append(callArgument) + + guard isSupportedRuntimeConstructorType(parameter.typeName) else { + guard let defaultExpression = parameter.defaultExpression else { + throw MacroError.macroUsage( + "@AdaScriptInit parameter '\(parameter.externalName)' has unsupported type " + + "'\(parameter.typeName)' and must provide a default value." + ) + } + argumentDecoders.append( + "let \(parameter.localName): \(parameter.typeName) = \(defaultExpression)" + ) + continue + } + + let scriptName = parameter.externalName == "_" ? parameter.localName : parameter.externalName + exposedParameters.append( + """ + AdaECS.RuntimeComponentConstructorParameter( + name: "\(scriptName)", + kind: AdaECS.ComponentReflection.kind(for: \(parameter.typeName).self) + ) + """ + ) + + let missingValue: String + if let defaultExpression = parameter.defaultExpression { + missingValue = "\(parameter.localName) = \(defaultExpression)" + } else { + missingValue = + """ + throw AdaECS.RuntimeComponentConstructorError.missingArgument( + component: String(reflecting: Self.self), + parameter: "\(scriptName)" + ) + """ + } + argumentDecoders.append( + """ + let \(parameter.localName): \(parameter.typeName) + if let fieldValue = arguments[\(argumentIndex)] { + guard let decoded = AdaECS.ComponentReflection.value( + fieldValue, + as: \(parameter.typeName).self + ) else { + throw AdaECS.RuntimeComponentConstructorError.invalidArgument( + component: String(reflecting: Self.self), + parameter: "\(scriptName)" + ) + } + \(parameter.localName) = decoded + } else { + \(missingValue) + } + """ + ) + argumentIndex += 1 + } + + return ExplicitRuntimeConstructor( + body: + """ + \(argumentDecoders.joined(separator: "\n")) + return Self(\(initializerArguments.joined(separator: ", "))) + """, + parameters: exposedParameters + ) + } + + private static func isSupportedRuntimeConstructorType(_ typeName: String) -> Bool { + let supportedTypes = [ + "Bool", "Int", "Float", "Double", "String", + "Vector2", "Vector3", "Vector4", "Quat", "Color", + ] + return supportedTypes.contains { typeName == $0 || typeName.hasSuffix(".\($0)") } + } + private static func generateDeclaration( type: T, availability: DeclModifierListSyntax?, functions: [String], requiredComponents: [String] = [], - editorFields: [String] = [] + reflectedFields: [String] = [], + runtimeConstructorParameters: [String] = [], + runtimeConstructorAssignments: [String] = [], + runtimeConstructorBody: String? = nil ) -> [SwiftSyntax.ExtensionDeclSyntax] { // Process modifiers: if private or private, change to internal let processedAvailability = processModifiers(availability) let requiredComponentTypeNames = requiredComponents.map { "String(reflecting: \($0))" }.joined(separator: ", ") + let generatedRuntimeConstructorBody = if let runtimeConstructorBody { + runtimeConstructorBody + } else if runtimeConstructorAssignments.isEmpty { + """ + guard let typedComponent = component as? Self else { + throw AdaECS.RuntimeComponentConstructorError.invalidBase( + component: String(reflecting: Self.self) + ) + } + return typedComponent + """ + } else { + """ + guard var typedComponent = component as? Self else { + throw AdaECS.RuntimeComponentConstructorError.invalidBase( + component: String(reflecting: Self.self) + ) + } + var argumentIndex = 0 + \(runtimeConstructorAssignments.joined(separator: "\n")) + return typedComponent + """ + } + let runtimeConstructorImplementation = if runtimeConstructorBody == nil { + """ + applyArguments: { component, arguments in + \(generatedRuntimeConstructorBody) + } + """ + } else { + """ + constructArguments: { arguments in + \(generatedRuntimeConstructorBody) + } + """ + } - let proto = "AdaECS.Component, AdaECS.EditorInspectableComponent" + let proto = "AdaECS.Component, AdaECS.ReflectableComponent, AdaECS.RuntimeConstructibleComponent" let ext: DeclSyntax = """ extension \(type.trimmed): \(raw: proto) { @@ -208,16 +414,25 @@ extension ComponentMacro { \(processedAvailability) static var requiredComponents: RequiredComponents { RequiredComponents(components: [\(raw: requiredComponents.joined(separator: ", "))]) } - \(processedAvailability) static var editorComponentDescriptor: AdaECS.EditorComponentDescriptor { - AdaECS.EditorComponentDescriptor( + \(processedAvailability) static var componentDescriptor: AdaECS.ReflectedComponentDescriptor { + AdaECS.ReflectedComponentDescriptor( type: Self.self, displayName: String(describing: Self.self), requiredComponentTypeNames: [\(raw: requiredComponentTypeNames)], fields: [ - \(raw: editorFields.joined(separator: ",\n")) + \(raw: reflectedFields.joined(separator: ",\n")) ] ) } + \(processedAvailability) static var runtimeComponentConstructor: AdaECS.RuntimeComponentConstructorDescriptor { + AdaECS.RuntimeComponentConstructorDescriptor( + typeName: String(reflecting: Self.self), + parameters: [ + \(raw: runtimeConstructorParameters.joined(separator: ",\n")) + ].filter { $0.kind != .readOnly }, + \(raw: runtimeConstructorImplementation) + ) + } } """ return [ext.cast(ExtensionDeclSyntax.self)] @@ -275,7 +490,7 @@ extension String { return prefix(1).capitalized + dropFirst() } - var editorFieldLabel: String { + var reflectedFieldLabel: String { guard !isEmpty else { return self } diff --git a/Sources/AdaMultiplayer/AdaMultiplayer.docc/AdaMultiplayer.md b/Sources/AdaMultiplayer/AdaMultiplayer.docc/AdaMultiplayer.md new file mode 100644 index 000000000..4fb556ce9 --- /dev/null +++ b/Sources/AdaMultiplayer/AdaMultiplayer.docc/AdaMultiplayer.md @@ -0,0 +1,93 @@ +# ``AdaMultiplayer`` + +Build host-authoritative local and internet multiplayer with replaceable +transports, automatic marker-based ECS replication, interpolation, and typed +RPC. + +## Overview + +Multiplayer is opt-in and is not included in `DefaultPlugins`. Add the core +plugin before the plugin that declares your game's network schema: + +```swift +import AdaMultiplayer + +struct GameNetworkPlugin: Plugin { + func setup(in app: borrowing AppWorlds) { + app + .registerReplicatedComponent( + PlayerState.self, + id: "game.player-state", + version: 1 + ) + .registerNetworkCommand(MovePlayer.self) + } +} + +let transport = AppleLocalTransport( + mode: .host(serviceName: "My Game"), + configureQUIC: configurePinnedLocalIdentity +) +let configuration = MultiplayerConfiguration( + role: .host, + compatibility: NetworkCompatibility( + gameIdentifier: "com.example.game", + buildIdentifier: "1.0" + ) +) + +app + .addPlugin(MultiplayerPlugin(configuration: configuration, transport: transport)) + .addPlugin(GameNetworkPlugin()) +``` + +Add ``ReplicatedEntity`` to an authoritative entity. Every component on that +entity that was explicitly registered is included automatically; other ECS +state remains local: + +```swift +world.spawn("Player") { + ReplicatedEntity() + NetworkOwner(peer: controllingPeer) + Transform() + PlayerState(health: 100) +} +``` + +Peers send typed commands to Host through the ``MultiplayerSession`` resource. +Gameplay systems consume the values through ``RemoteCommands``: + +```swift +struct MovePlayer: NetworkCommand { + static let networkIdentifier = "game.move-player" + var direction: Vector2 +} + +@PlainSystem +struct ApplyRemoteMovement { + @RemoteCommands private var commands + + init(world: World) {} + + func update(context: UpdateContext) async { + for command in commands { + // Validate command.source and apply authoritative gameplay rules. + } + } +} +``` + +## Transport choices + +- ``InMemoryTransport`` is deterministic infrastructure for tests and embedded + sessions. +- `AppleLocalTransport` advertises or joins a Bonjour QUIC service on Apple + platforms. +- ``CloudWebSocketTransport`` connects Apple and browser clients to the + AdaEngine Cloud relay with a single-use connection ticket. +- A custom transport implements ``MultiplayerTransport`` and never accesses an + AdaECS `World` directly. + +The first protocol version uses reliable ordered state snapshots and visual +interpolation. Prediction, rollback, host migration, and peer-to-peer authority +are intentionally not implied by the API. diff --git a/Sources/AdaMultiplayer/AdaScriptMultiplayerBridge.swift b/Sources/AdaMultiplayer/AdaScriptMultiplayerBridge.swift new file mode 100644 index 000000000..07de3e453 --- /dev/null +++ b/Sources/AdaMultiplayer/AdaScriptMultiplayerBridge.swift @@ -0,0 +1,297 @@ +import AdaApp +import AdaECS +import AdaUtils +import Foundation + +/// A transport-neutral mailbox exposed to AdaScript as a reflected resource. +/// +/// The bridge deliberately knows nothing about a game's entities or rules. Peers +/// publish one command payload to the host, while the host publishes one +/// authoritative snapshot payload to all peers. Payload schemas belong to the +/// AdaScript project and use detached scalar/list values. +public struct AdaScriptMultiplayerState: Resource, Sendable { + public var role: String + public var status: String + public var localPeerID: String + public var peerIDs: [String] + public var receivedCommands: [ReflectedFieldValue] + public var receivedSnapshot: [ReflectedFieldValue] + public var outgoingCommand: [ReflectedFieldValue] + public var outgoingCommandSequence: Int + public var publishedSnapshot: [ReflectedFieldValue] + public var publishedSnapshotSequence: Int + + public init( + role: NetworkRole, + localPeerID: PeerID + ) { + self.role = role.rawValue + self.status = MultiplayerSessionState.idle.scriptValue + self.localPeerID = localPeerID.rawValue.uuidString + self.peerIDs = [] + self.receivedCommands = [] + self.receivedSnapshot = [] + self.outgoingCommand = [] + self.outgoingCommandSequence = 0 + self.publishedSnapshot = [] + self.publishedSnapshotSequence = 0 + } + + /// Registers the bridge resource and its detached AdaScript fields. + @MainActor + public static func registerRuntimeType() { + RuntimeTypeRegistry.registerResource( + Self.self, + names: ["AdaScriptMultiplayerState", "ada.multiplayer.script-state"] + ) + RuntimeResourceReflectionRegistry.register( + Self.self, + fields: [ + field(.role), + field(.status), + field(.localPeerID), + field(.peerIDs), + field(.receivedCommands), + field(.receivedSnapshot), + field(.outgoingCommand), + field(.outgoingCommandSequence), + field(.publishedSnapshot), + field(.publishedSnapshotSequence), + ] + ) + } +} + +/// Installs the generic AdaScript command/snapshot mailbox on top of +/// ``MultiplayerPlugin``. +public struct AdaScriptMultiplayerBridgePlugin: Plugin { + private let configuration: MultiplayerConfiguration + + public init(configuration: MultiplayerConfiguration) { + self.configuration = configuration + } + + @MainActor + public func setup(in app: borrowing AppWorlds) { + AdaScriptMultiplayerState.registerRuntimeType() + app + .registerNetworkCommand(AdaScriptNetworkCommand.self) + .registerNetworkEvent(AdaScriptNetworkSnapshot.self) + .insertResource( + AdaScriptMultiplayerState( + role: configuration.role, + localPeerID: configuration.localPeerID + ) + ) + .addSystem(AdaScriptMultiplayerReceiveBridgeSystem.self, on: .networkReceive) + .addSystem(AdaScriptMultiplayerSendBridgeSystem.self, on: .networkSend) + } +} + +struct AdaScriptNetworkCommand: NetworkCommand { + static let networkIdentifier = "ada.script.command" + + var sequence: Int + var payload: [ReflectedFieldValue] +} + +struct AdaScriptNetworkSnapshot: NetworkEvent { + static let networkIdentifier = "ada.script.snapshot" + + var sequence: Int + var payload: [ReflectedFieldValue] +} + +@PlainSystem(dependencies: [.after(NetworkReceiveSystem.self)]) +struct AdaScriptMultiplayerReceiveBridgeSystem { + @RemoteCommands + private var commands + + @RemoteEvents + private var snapshots + + @Res + private var session + + @ResMut + private var state + + init(world _: World) {} + + func update(context _: UpdateContext) async { + let sessionState = await session.currentState() + let peers = await session.peers() + state.status = sessionState.scriptValue + state.peerIDs = peers.map { $0.rawValue.uuidString }.sorted() + state.receivedCommands = commands.map { command in + .array([ + .string(command.source.rawValue.uuidString), + .int(command.value.sequence), + .array(command.value.payload), + ]) + } + if let latest = snapshots.max(by: { $0.value.sequence < $1.value.sequence }) { + state.receivedSnapshot = latest.value.payload + } + } +} + +@PlainSystem +struct AdaScriptMultiplayerSendBridgeSystem { + @Res + private var session + + @Res + private var state + + @Local + private var lastCommandSequence = 0 + + @Local + private var lastSnapshotSequence = 0 + + init(world _: World) {} + + func update(context _: UpdateContext) async { + guard await session.currentState() == .connected else { + return + } + if state.role == NetworkRole.peer.rawValue, + state.outgoingCommandSequence > lastCommandSequence { + do { + try await session.sendCommand( + AdaScriptNetworkCommand( + sequence: state.outgoingCommandSequence, + payload: state.outgoingCommand + ) + ) + lastCommandSequence = state.outgoingCommandSequence + } catch MultiplayerError.notConnected { + return + } catch { + RuntimeLogStore.shared.append( + level: "error", + label: "AdaScript.Multiplayer", + message: "Command send failed: \(error)" + ) + } + } + if state.role == NetworkRole.host.rawValue, + state.publishedSnapshotSequence > lastSnapshotSequence { + do { + try await session.sendEvent( + AdaScriptNetworkSnapshot( + sequence: state.publishedSnapshotSequence, + payload: state.publishedSnapshot + ) + ) + lastSnapshotSequence = state.publishedSnapshotSequence + } catch MultiplayerError.notConnected { + return + } catch { + RuntimeLogStore.shared.append( + level: "error", + label: "AdaScript.Multiplayer", + message: "Snapshot send failed: \(error)" + ) + } + } + } +} + +private extension MultiplayerSessionState { + var scriptValue: String { + switch self { + case .idle: "idle" + case .connecting: "connecting" + case .connected: "connected" + case .ended: "ended" + } + } +} + +private extension AdaScriptMultiplayerState { + enum Field: String, Sendable { + case role, status, localPeerID, peerIDs + case receivedCommands, receivedSnapshot + case outgoingCommand, outgoingCommandSequence + case publishedSnapshot, publishedSnapshotSequence + + var isWritable: Bool { + switch self { + case .outgoingCommand, .outgoingCommandSequence, .publishedSnapshot, .publishedSnapshotSequence: true + default: false + } + } + } + + static func field(_ field: Field) -> ReflectedComponentField { + let writePointer: (@Sendable (UnsafeMutableRawPointer, ReflectedFieldValue) -> Bool)? + if field.isWritable { + unsafe writePointer = { pointer, value in + unsafe write(value, to: field, state: pointer.assumingMemoryBound(to: Self.self)) + } + } else { + unsafe writePointer = nil + } + return unsafe ReflectedComponentField( + key: field.rawValue, + label: field.rawValue, + kind: .readOnly, + isWritable: field.isWritable, + accepts: { value in accepts(value, for: field) }, + read: { _ in nil }, + write: { _, _ in nil }, + readPointer: { pointer in + read(field, from: unsafe pointer.assumingMemoryBound(to: Self.self).pointee) + }, + writePointer: writePointer + ) + } + + static func accepts(_ value: ReflectedFieldValue, for field: Field) -> Bool { + switch field { + case .outgoingCommand, .publishedSnapshot: + if case .array = value { true } else { false } + case .outgoingCommandSequence, .publishedSnapshotSequence: + if case .int = value { true } else { false } + default: + false + } + } + + static func read(_ field: Field, from state: Self) -> ReflectedFieldValue { + switch field { + case .role: .string(state.role) + case .status: .string(state.status) + case .localPeerID: .string(state.localPeerID) + case .peerIDs: .array(state.peerIDs.map(ReflectedFieldValue.string)) + case .receivedCommands: .array(state.receivedCommands) + case .receivedSnapshot: .array(state.receivedSnapshot) + case .outgoingCommand: .array(state.outgoingCommand) + case .outgoingCommandSequence: .int(state.outgoingCommandSequence) + case .publishedSnapshot: .array(state.publishedSnapshot) + case .publishedSnapshotSequence: .int(state.publishedSnapshotSequence) + } + } + + static func write( + _ value: ReflectedFieldValue, + to field: Field, + state: UnsafeMutablePointer + ) -> Bool { + switch (field, value) { + case let (.outgoingCommand, .array(values)): + unsafe state.pointee.outgoingCommand = values + case let (.outgoingCommandSequence, .int(value)): + unsafe state.pointee.outgoingCommandSequence = value + case let (.publishedSnapshot, .array(values)): + unsafe state.pointee.publishedSnapshot = values + case let (.publishedSnapshotSequence, .int(value)): + unsafe state.pointee.publishedSnapshotSequence = value + default: + return false + } + return true + } +} diff --git a/Sources/AdaMultiplayer/AppleLocalTransport.swift b/Sources/AdaMultiplayer/AppleLocalTransport.swift new file mode 100644 index 000000000..1319d206a --- /dev/null +++ b/Sources/AdaMultiplayer/AppleLocalTransport.swift @@ -0,0 +1,310 @@ +#if canImport(Network) + import Foundation + import Network + + /// Bonjour service discovered by ``AppleLocalDiscovery``. + public struct AppleLocalService: Identifiable, Sendable { + public let id: String + public let name: String + public let endpoint: NWEndpoint + + init(name: String, endpoint: NWEndpoint) { + self.id = String(describing: endpoint) + self.name = name + self.endpoint = endpoint + } + } + + /// Discovers Apple LAN hosts advertising the AdaMultiplayer QUIC service. + public actor AppleLocalDiscovery { + public static let serviceType = "_ada-mp._udp" + + private let stream: AsyncStream<[AppleLocalService]> + private let continuation: AsyncStream<[AppleLocalService]>.Continuation + private var browser: NWBrowser? + + public init() { + let pair = AsyncStream<[AppleLocalService]>.makeStream() + self.stream = pair.stream + self.continuation = pair.continuation + } + + public func resultStream() -> AsyncStream<[AppleLocalService]> { + stream + } + + public func start() { + guard browser == nil else { return } + let browser = NWBrowser( + for: .bonjour(type: Self.serviceType, domain: nil), + using: AppleLocalTransport.discoveryParameters() + ) + self.browser = browser + browser.browseResultsChangedHandler = { [weak self] results, _ in + let services = results.compactMap { result -> AppleLocalService? in + guard case let .service(name, _, _, _) = result.endpoint else { return nil } + return AppleLocalService(name: name, endpoint: result.endpoint) + }.sorted { $0.id < $1.id } + Task { await self?.publish(services) } + } + browser.start(queue: .global(qos: .userInitiated)) + } + + public func stop() { + browser?.cancel() + browser = nil + continuation.yield([]) + } + + private func publish(_ services: [AppleLocalService]) { + continuation.yield(services) + } + } + + /// Host or join mode for ``AppleLocalTransport``. + public enum AppleLocalTransportMode: Sendable { + case host(serviceName: String) + case peer(endpoint: NWEndpoint) + } + + private actor AppleLocalChannel { + private static let maximumFrameBytes = 8 * 1_024 * 1_024 + private let connection: NWConnection + + init(_ connection: NWConnection) { + self.connection = connection + } + + func start() { + connection.start(queue: .global(qos: .userInitiated)) + } + + func stop() { + connection.cancel() + } + + func send(_ payload: Data) async throws { + guard payload.count <= Self.maximumFrameBytes else { + throw MultiplayerError.invalidPayload + } + let count = UInt32(payload.count) + var frame = Data([ + UInt8(count >> 24), + UInt8(truncatingIfNeeded: count >> 16), + UInt8(truncatingIfNeeded: count >> 8), + UInt8(truncatingIfNeeded: count), + ]) + frame.append(payload) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: frame, completion: .contentProcessed { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + }) + } + } + + func receive() async throws -> Data { + let header = try await read(count: 4) + let count = header.reduce(0) { ($0 << 8) | Int($1) } + guard count > 0, count <= Self.maximumFrameBytes else { + throw MultiplayerError.invalidPayload + } + return try await read(count: count) + } + + private func read(count: Int) async throws -> Data { + var result = Data() + while result.count < count { + let remaining = count - result.count + let chunk: Data = try await withCheckedThrowingContinuation { continuation in + connection.receive( + minimumIncompleteLength: 1, + maximumLength: min(remaining, 64 * 1_024) + ) { data, _, complete, error in + if let error { continuation.resume(throwing: error) } + else if let data, !data.isEmpty { continuation.resume(returning: data) } + else { + continuation.resume( + throwing: complete + ? MultiplayerError.sessionEnded + : MultiplayerError.invalidPayload + ) + } + } + } + result.append(chunk) + } + return result + } + } + + /// Direct Apple LAN transport using a reliable QUIC stream and Bonjour. + public actor AppleLocalTransport: MultiplayerTransport { + public nonisolated let capabilities: MultiplayerTransportCapabilities = [ + .reliableOrdered, + .localDiscovery, + ] + + private let mode: AppleLocalTransportMode + private let configureQUIC: @Sendable (NWProtocolQUIC.Options) -> Void + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + private var configuration: MultiplayerTransportConfiguration? + private var listener: NWListener? + private var channels: [PeerID: AppleLocalChannel] = [:] + private var receiveTasks: [PeerID: Task] = [:] + + /// Creates a local transport with application-owned TLS identity and + /// trust configuration. + /// + /// QUIC always uses TLS. `configureQUIC` must install the local + /// identity for a host and pin or otherwise validate the remote + /// identity for a peer. + public init( + mode: AppleLocalTransportMode, + configureQUIC: @escaping @Sendable (NWProtocolQUIC.Options) -> Void + ) { + self.mode = mode + self.configureQUIC = configureQUIC + let pair = AsyncStream.makeStream() + self.stream = pair.stream + self.continuation = pair.continuation + } + + public func eventStream() -> AsyncStream { + stream + } + + public func start(configuration: MultiplayerTransportConfiguration) async throws { + guard self.configuration == nil else { return } + self.configuration = configuration + switch mode { + case let .host(serviceName): + guard configuration.role == .host else { throw MultiplayerError.hostOnly } + let listener = try NWListener(using: parameters()) + listener.service = NWListener.Service( + name: serviceName, + type: AppleLocalDiscovery.serviceType + ) + listener.newConnectionHandler = { [weak self] connection in + Task { await self?.accept(connection) } + } + listener.stateUpdateHandler = { [weak self] state in + if case let .failed(error) = state { + Task { await self?.fail(error.localizedDescription) } + } + } + self.listener = listener + listener.start(queue: .global(qos: .userInitiated)) + case let .peer(endpoint): + guard configuration.role == .peer else { throw MultiplayerError.peerOnly } + let channel = AppleLocalChannel( + NWConnection(to: endpoint, using: parameters()) + ) + await channel.start() + try await channel.send(try JSONEncoder().encode(configuration.localPeerID)) + let hostID = try JSONDecoder().decode(PeerID.self, from: await channel.receive()) + attach(channel, peer: hostID) + } + } + + public func send(_ payload: Data, to target: NetworkTarget) async throws { + guard let configuration else { throw MultiplayerError.notConnected } + let recipients: [AppleLocalChannel] + switch target { + case .host: + guard configuration.role == .peer, let channel = channels.values.first else { + throw MultiplayerError.invalidDirection + } + recipients = [channel] + case let .peer(peer): + guard configuration.role == .host, let channel = channels[peer] else { + throw MultiplayerError.invalidDirection + } + recipients = [channel] + case .allPeers: + guard configuration.role == .host else { throw MultiplayerError.invalidDirection } + recipients = Array(channels.values) + case let .allPeersExcept(excluded): + guard configuration.role == .host else { throw MultiplayerError.invalidDirection } + recipients = channels.compactMap { peer, channel in peer == excluded ? nil : channel } + } + for channel in recipients { + try await channel.send(payload) + } + } + + public func stop() async { + listener?.cancel() + listener = nil + for task in receiveTasks.values { task.cancel() } + receiveTasks.removeAll() + for channel in channels.values { await channel.stop() } + channels.removeAll() + configuration = nil + } + + static func discoveryParameters() -> NWParameters { + let options = NWProtocolQUIC.Options() + options.direction = .bidirectional + options.alpn = ["ada-multiplayer-v1"] + return NWParameters(quic: options) + } + + private func parameters() -> NWParameters { + let options = NWProtocolQUIC.Options() + options.direction = .bidirectional + options.alpn = ["ada-multiplayer-v1"] + configureQUIC(options) + return NWParameters(quic: options) + } + + private func accept(_ connection: NWConnection) async { + guard let configuration else { + connection.cancel() + return + } + let channel = AppleLocalChannel(connection) + await channel.start() + do { + let peerID = try JSONDecoder().decode(PeerID.self, from: await channel.receive()) + try await channel.send(try JSONEncoder().encode(configuration.localPeerID)) + attach(channel, peer: peerID) + } catch { + await channel.stop() + continuation.yield(.failed(String(describing: error))) + } + } + + private func attach(_ channel: AppleLocalChannel, peer: PeerID) { + channels[peer] = channel + continuation.yield(.connected(peer)) + receiveTasks[peer] = Task { [weak self] in + do { + while !Task.isCancelled { + let payload = try await channel.receive() + await self?.receive(payload, source: peer) + } + } catch { + await self?.disconnect(peer, message: String(describing: error)) + } + } + } + + private func receive(_ payload: Data, source: PeerID) { + continuation.yield(.received(source: source, payload: payload)) + } + + private func disconnect(_ peer: PeerID, message _: String) async { + receiveTasks.removeValue(forKey: peer)?.cancel() + if let channel = channels.removeValue(forKey: peer) { + await channel.stop() + } + continuation.yield(.disconnected(peer)) + } + + private func fail(_ message: String) { + continuation.yield(.failed(message)) + } + } +#endif diff --git a/Sources/AdaMultiplayer/CloudWebSocketTransport+Web.swift b/Sources/AdaMultiplayer/CloudWebSocketTransport+Web.swift new file mode 100644 index 000000000..92b3b204d --- /dev/null +++ b/Sources/AdaMultiplayer/CloudWebSocketTransport+Web.swift @@ -0,0 +1,166 @@ +#if WASI + import Foundation + import JavaScriptFoundationCompat + import JavaScriptKit + + /// Browser implementation of the AdaEngine Cloud WebSocket transport. + public actor CloudWebSocketTransport: MultiplayerTransport { + public nonisolated let capabilities: MultiplayerTransportCapabilities = [.reliableOrdered] + + private let credentials: CloudRelayCredentials + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + private var configuration: MultiplayerTransportConfiguration? + private var socket: JSObject? + private var closures: [JSClosure] = [] + + public init(credentials: CloudRelayCredentials) { + self.credentials = credentials + let pair = AsyncStream.makeStream() + self.stream = pair.stream + self.continuation = pair.continuation + } + + public func eventStream() -> AsyncStream { + stream + } + + public func start(configuration: MultiplayerTransportConfiguration) async throws { + guard self.configuration == nil else { return } + guard credentials.url.scheme == "wss" || credentials.url.scheme == "ws", + let constructor = JSObject.global.WebSocket.function + else { + throw MultiplayerError.unsupportedPlatform + } + self.configuration = configuration + let socket = constructor.new(credentials.url.absoluteString) + socket["binaryType"] = .string("arraybuffer") + self.socket = socket + + let open = JSClosure { [weak self] _ in + Task { await self?.opened() } + return .undefined + } + let message = JSClosure { [weak self] arguments in + guard let event = arguments.first?.object else { return .undefined } + let value = event["data"] + if let text = value.string { + Task { await self?.receiveText(text) } + } else if let object = value.object, + let constructor = JSObject.global.Uint8Array.function { + let array = JSUint8Array(unsafelyWrapping: constructor.new(object)) + if let data = Data.construct(from: array) { + Task { await self?.receiveData(data) } + } + } + return .undefined + } + let close = JSClosure { [weak self] _ in + Task { await self?.failed("Cloud relay closed the WebSocket") } + return .undefined + } + let error = JSClosure { [weak self] _ in + Task { await self?.failed("Cloud relay WebSocket failed") } + return .undefined + } + closures = [open, message, close, error] + socket["onopen"] = .object(open) + socket["onmessage"] = .object(message) + socket["onclose"] = .object(close) + socket["onerror"] = .object(error) + } + + public func send(_ payload: Data, to target: NetworkTarget) async throws { + let route: (String, UUID?) = switch target { + case .host: ("host", nil) + case let .peer(peer): ("peer", peer.rawValue) + case .allPeers: ("allPeers", nil) + case let .allPeersExcept(peer): ("allPeersExcept", peer.rawValue) + } + try send( + JSONEncoder().encode( + CloudRelayPacket( + source: nil, + target: route.0, + peerID: route.1, + payload: payload + ) + ) + ) + } + + public func stop() async { + _ = socket?.close?() + socket = nil + closures.removeAll() + configuration = nil + } + + private func opened() { + guard let configuration else { return } + do { + let hello = CloudRelayHello( + ticket: credentials.connectionTicket, + sessionID: configuration.sessionID.rawValue, + peerID: configuration.localPeerID.rawValue, + role: configuration.role + ) + try send(String(decoding: JSONEncoder().encode(hello), as: UTF8.self)) + } catch { + failed(String(describing: error)) + } + } + + private func receiveText(_ text: String) { + do { + let control = try JSONDecoder().decode(CloudRelayControl.self, from: Data(text.utf8)) + switch control.kind { + case "ready", "connected": + if let peer = control.peerID { + continuation.yield(.connected(PeerID(rawValue: peer))) + } + case "disconnected": + if let peer = control.peerID { + continuation.yield(.disconnected(PeerID(rawValue: peer))) + } + case "ended", "error": + continuation.yield(.failed(control.message ?? "Cloud relay ended the session")) + default: + throw MultiplayerError.invalidPayload + } + } catch { + failed(String(describing: error)) + } + } + + private func receiveData(_ data: Data) { + do { + let packet = try JSONDecoder().decode(CloudRelayPacket.self, from: data) + guard let source = packet.source else { throw MultiplayerError.invalidPayload } + continuation.yield( + .received(source: PeerID(rawValue: source), payload: packet.payload) + ) + } catch { + failed(String(describing: error)) + } + } + + private func send(_ text: String) throws { + guard let socket, let send: (String) -> JSValue = socket.send else { + throw MultiplayerError.notConnected + } + _ = send(text) + } + + private func send(_ data: Data) throws { + guard let socket, let send: (JSValue) -> JSValue = socket.send else { + throw MultiplayerError.notConnected + } + _ = send(data.jsTypedArray.jsValue) + } + + private func failed(_ message: String) { + continuation.yield(.failed(message)) + } + } +#endif diff --git a/Sources/AdaMultiplayer/CloudWebSocketTransport.swift b/Sources/AdaMultiplayer/CloudWebSocketTransport.swift new file mode 100644 index 000000000..265c9edf1 --- /dev/null +++ b/Sources/AdaMultiplayer/CloudWebSocketTransport.swift @@ -0,0 +1,165 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Ticket returned by AdaEngine Cloud's create/join endpoints. +public struct CloudRelayCredentials: Sendable { + public var url: URL + public var connectionTicket: String + + public init(url: URL, connectionTicket: String) { + self.url = url + self.connectionTicket = connectionTicket + } +} + +struct CloudRelayHello: Codable, Sendable { + var kind = "hello" + var ticket: String + var sessionID: UUID + var peerID: UUID + var role: NetworkRole +} + +struct CloudRelayControl: Codable, Sendable { + var kind: String + var peerID: UUID? + var message: String? +} + +struct CloudRelayPacket: Codable, Sendable { + var source: UUID? + var target: String + var peerID: UUID? + var payload: Data +} + +/// Reliable ordered transport backed by the AdaEngine Cloud WebSocket relay. +/// +/// The connection ticket is sent as the first WebSocket message, never as part +/// of the URL. The relay reads only the outer routing envelope; `payload` is an +/// opaque AdaMultiplayer frame. +#if !WASI +public actor CloudWebSocketTransport: MultiplayerTransport { + public nonisolated let capabilities: MultiplayerTransportCapabilities = [.reliableOrdered] + + private let credentials: CloudRelayCredentials + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + private var configuration: MultiplayerTransportConfiguration? + private var socket: URLSessionWebSocketTask? + private var receiveTask: Task? + + public init(credentials: CloudRelayCredentials) { + self.credentials = credentials + let pair = AsyncStream.makeStream() + self.stream = pair.stream + self.continuation = pair.continuation + } + + public func eventStream() -> AsyncStream { + stream + } + + public func start(configuration: MultiplayerTransportConfiguration) async throws { + guard self.configuration == nil else { + return + } + guard credentials.url.scheme == "wss" || credentials.url.scheme == "ws" else { + throw MultiplayerError.invalidPayload + } + + let socket = URLSession.shared.webSocketTask(with: credentials.url) + self.socket = socket + self.configuration = configuration + socket.resume() + + let hello = CloudRelayHello( + ticket: credentials.connectionTicket, + sessionID: configuration.sessionID.rawValue, + peerID: configuration.localPeerID.rawValue, + role: configuration.role + ) + try await socket.send(.string(String(decoding: JSONEncoder().encode(hello), as: UTF8.self))) + receiveTask = Task { [weak self] in + await self?.receiveLoop(socket: socket) + } + } + + public func send(_ payload: Data, to target: NetworkTarget) async throws { + guard let socket else { + throw MultiplayerError.notConnected + } + let route: (String, UUID?) = switch target { + case .host: ("host", nil) + case let .peer(peer): ("peer", peer.rawValue) + case .allPeers: ("allPeers", nil) + case let .allPeersExcept(peer): ("allPeersExcept", peer.rawValue) + } + let packet = CloudRelayPacket( + source: nil, + target: route.0, + peerID: route.1, + payload: payload + ) + try await socket.send(.data(JSONEncoder().encode(packet))) + } + + public func stop() async { + receiveTask?.cancel() + receiveTask = nil + socket?.cancel(with: .normalClosure, reason: nil) + socket = nil + configuration = nil + } + + private func receiveLoop(socket: URLSessionWebSocketTask) async { + do { + while !Task.isCancelled { + let message = try await socket.receive() + switch message { + case let .string(text): + try receiveControl(Data(text.utf8)) + case let .data(data): + let packet = try JSONDecoder().decode(CloudRelayPacket.self, from: data) + guard let source = packet.source else { + throw MultiplayerError.invalidPayload + } + continuation.yield( + .received(source: PeerID(rawValue: source), payload: packet.payload) + ) + @unknown default: + throw MultiplayerError.invalidPayload + } + } + } catch { + guard !Task.isCancelled else { + return + } + continuation.yield(.failed(String(describing: error))) + } + } + + private func receiveControl(_ data: Data) throws { + let control = try JSONDecoder().decode(CloudRelayControl.self, from: data) + switch control.kind { + case "ready", "connected": + guard let peer = control.peerID else { + return + } + continuation.yield(.connected(PeerID(rawValue: peer))) + case "disconnected": + guard let peer = control.peerID else { + return + } + continuation.yield(.disconnected(PeerID(rawValue: peer))) + case "ended", "error": + continuation.yield(.failed(control.message ?? "Cloud relay ended the session")) + default: + throw MultiplayerError.invalidPayload + } + } +} +#endif diff --git a/Sources/AdaMultiplayer/LocalTCPTransport.swift b/Sources/AdaMultiplayer/LocalTCPTransport.swift new file mode 100644 index 000000000..40abcf991 --- /dev/null +++ b/Sources/AdaMultiplayer/LocalTCPTransport.swift @@ -0,0 +1,390 @@ +#if canImport(Network) +import AdaUtils +import Foundation +@unsafe import Network + +public actor LocalTCPTransport: MultiplayerTransport { + nonisolated public let capabilities: MultiplayerTransportCapabilities = [.reliableOrdered] + + private struct Hello: Codable, Sendable { + var sessionID: SessionID + var peerID: PeerID + var role: NetworkRole + } + + private enum EnvelopeKind: UInt8 { + case hello = 1 + case payload = 2 + } + + private enum TransportError: Error, LocalizedError { + case invalidPort + case invalidEnvelope + case incompatibleSession + case unavailableTarget + + var errorDescription: String? { + switch self { + case .invalidPort: "The local multiplayer port is invalid." + case .invalidEnvelope: "The local multiplayer frame is malformed." + case .incompatibleSession: "The local peer belongs to another session." + case .unavailableTarget: "The requested local multiplayer target is unavailable." + } + } + } + + private let host: String + private let portNumber: UInt16 + private let log: @Sendable (String) -> Void + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var configuration: MultiplayerTransportConfiguration? + private var listener: NWListener? + private var hostConnection: NWConnection? + private var hostPeerID: PeerID? + private var connections: [PeerID: NWConnection] = [:] + private var peersByConnection: [ObjectIdentifier: PeerID] = [:] + private var pendingConnections: [ObjectIdentifier: NWConnection] = [:] + + public init( + host: String, + port: UInt16, + log: @escaping @Sendable (String) -> Void = { message in + RuntimeLogStore.shared.append(level: "info", label: "AdaMultiplayer.LocalTCP", message: message) + } + ) { + self.host = host + self.portNumber = port + self.log = log + let pair = AsyncStream.makeStream() + self.stream = pair.stream + self.continuation = pair.continuation + } + + public func eventStream() -> AsyncStream { + stream + } + + public func start(configuration: MultiplayerTransportConfiguration) async throws { + guard self.configuration == nil else { + return + } + guard let port = NWEndpoint.Port(rawValue: portNumber) else { + throw TransportError.invalidPort + } + self.configuration = configuration + + switch configuration.role { + case .host: + let listener = try NWListener(using: .tcp, on: port) + listener.stateUpdateHandler = { [weak self] state in + Task { await self?.listenerChanged(state) } + } + listener.newConnectionHandler = { [weak self] connection in + Task { await self?.accept(connection) } + } + self.listener = listener + listener.start(queue: .global(qos: .userInitiated)) + log("listening on 0.0.0.0:\(portNumber)") + case .peer: + let connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: .tcp) + hostConnection = connection + configure(connection) + connection.start(queue: .global(qos: .userInitiated)) + log("connecting to \(host):\(portNumber)") + } + } + + public func send(_ payload: Data, to target: NetworkTarget) async throws { + guard let configuration else { + throw MultiplayerError.notConnected + } + let envelope = Data([EnvelopeKind.payload.rawValue]) + payload + switch (configuration.role, target) { + case (.peer, .host): + guard let hostConnection else { + throw TransportError.unavailableTarget + } + try await sendEnvelope(envelope, through: hostConnection) + case let (.host, .peer(peer)): + guard let connection = connections[peer] else { + throw TransportError.unavailableTarget + } + try await sendEnvelope(envelope, through: connection) + case (.host, .allPeers): + for connection in connections.values { + try await sendEnvelope(envelope, through: connection) + } + case let (.host, .allPeersExcept(excluded)): + for (peer, connection) in connections where peer != excluded { + try await sendEnvelope(envelope, through: connection) + } + default: + throw MultiplayerError.invalidDirection + } + } + + public func stop() async { + listener?.cancel() + listener = nil + hostConnection?.cancel() + hostConnection = nil + for connection in connections.values { + connection.cancel() + } + connections.removeAll() + peersByConnection.removeAll() + pendingConnections.removeAll() + configuration = nil + log("transport stopped") + } + + private func listenerChanged(_ state: NWListener.State) { + switch state { + case .failed(let error): + continuation.yield(.failed(error.localizedDescription)) + log("listener failed: \(error)") + case .ready: + log("listener ready") + default: + break + } + } + + private func accept(_ connection: NWConnection) { + pendingConnections[ObjectIdentifier(connection)] = connection + configure(connection) + connection.start(queue: .global(qos: .userInitiated)) + } + + private func configure(_ connection: NWConnection) { + connection.stateUpdateHandler = { [weak self, weak connection] state in + guard let connection else { + return + } + Task { await self?.connectionChanged(connection, state: state) } + } + receiveHeader(from: connection) + } + + private func connectionChanged(_ connection: NWConnection, state: NWConnection.State) async { + switch state { + case .ready: + guard let configuration, configuration.role == .peer else { + return + } + do { + try await sendHello(configuration, through: connection) + } catch { + fail(connection, error: error) + } + case .failed(let error): + fail(connection, error: error) + case .waiting(let error): + log("connection waiting: \(error)") + case .cancelled: + disconnect(connection) + default: + break + } + } + + private func receiveHeader(from connection: NWConnection) { + connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { [weak self, weak connection] data, _, isComplete, error in + guard let self, let connection else { + return + } + Task { + if let error { + await self.fail(connection, error: error) + return + } + guard let data, data.count == 4 else { + if isComplete { + await self.disconnect(connection) + } else { + await self.fail(connection, error: TransportError.invalidEnvelope) + } + return + } + let size = data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + guard size > 0, size <= 1_048_576 else { + await self.fail(connection, error: TransportError.invalidEnvelope) + return + } + await self.receiveBody(Int(size), from: connection) + } + } + } + + private func receiveBody(_ size: Int, from connection: NWConnection) { + connection.receive(minimumIncompleteLength: size, maximumLength: size) { [weak self, weak connection] data, _, isComplete, error in + guard let self, let connection else { + return + } + Task { + if let error { + await self.fail(connection, error: error) + return + } + guard let data, data.count == size else { + if isComplete { + await self.disconnect(connection) + } else { + await self.fail(connection, error: TransportError.invalidEnvelope) + } + return + } + do { + try await self.receiveEnvelope(data, from: connection) + await self.receiveHeader(from: connection) + } catch { + await self.fail(connection, error: error) + } + } + } + } + + private func receiveEnvelope(_ envelope: Data, from connection: NWConnection) async throws { + guard let kindByte = envelope.first, let kind = EnvelopeKind(rawValue: kindByte) else { + throw TransportError.invalidEnvelope + } + let payload = envelope.dropFirst() + switch kind { + case .hello: + let hello = try JSONDecoder().decode(Hello.self, from: payload) + try await receiveHello(hello, from: connection) + case .payload: + let source: PeerID? + if configuration?.role == .host { + source = peersByConnection[ObjectIdentifier(connection)] + } else { + source = hostPeerID + } + guard let source else { + throw TransportError.invalidEnvelope + } + continuation.yield(.received(source: source, payload: Data(payload))) + } + } + + private func receiveHello(_ hello: Hello, from connection: NWConnection) async throws { + guard let configuration, + hello.sessionID == configuration.sessionID, + hello.role != configuration.role else { + throw TransportError.incompatibleSession + } + + switch configuration.role { + case .host: + guard hello.role == .peer else { + throw TransportError.incompatibleSession + } + let identifier = ObjectIdentifier(connection) + pendingConnections[identifier] = nil + connections[hello.peerID]?.cancel() + connections[hello.peerID] = connection + peersByConnection[identifier] = hello.peerID + try await sendHello(configuration, through: connection) + continuation.yield(.connected(hello.peerID)) + log("connected peer \(hello.peerID.rawValue.uuidString)") + case .peer: + guard hello.role == .host else { + throw TransportError.incompatibleSession + } + hostPeerID = hello.peerID + continuation.yield(.connected(hello.peerID)) + log("connected host \(hello.peerID.rawValue.uuidString)") + } + } + + private func sendHello( + _ configuration: MultiplayerTransportConfiguration, + through connection: NWConnection + ) async throws { + let hello = Hello( + sessionID: configuration.sessionID, + peerID: configuration.localPeerID, + role: configuration.role + ) + let envelope = Data([EnvelopeKind.hello.rawValue]) + (try JSONEncoder().encode(hello)) + try await sendEnvelope(envelope, through: connection) + } + + private func sendEnvelope(_ envelope: Data, through connection: NWConnection) async throws { + let size = UInt32(envelope.count) + var frame = Data([ + UInt8(size >> 24), + UInt8(truncatingIfNeeded: size >> 16), + UInt8(truncatingIfNeeded: size >> 8), + UInt8(truncatingIfNeeded: size), + ]) + frame.append(envelope) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: frame, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + private func fail(_ connection: NWConnection, error: any Error) { + log("connection failed: \(error)") + continuation.yield(.failed(error.localizedDescription)) + connection.cancel() + disconnect(connection) + } + + private func disconnect(_ connection: NWConnection) { + let identifier = ObjectIdentifier(connection) + pendingConnections[identifier] = nil + if let peer = peersByConnection.removeValue(forKey: identifier) { + connections[peer] = nil + continuation.yield(.disconnected(peer)) + log("disconnected peer \(peer.rawValue.uuidString)") + } else if connection === hostConnection, let hostPeerID { + self.hostPeerID = nil + hostConnection = nil + continuation.yield(.disconnected(hostPeerID)) + log("disconnected host \(hostPeerID.rawValue.uuidString)") + } + } +} +#else +import Foundation + +/// Reliable development transport for direct local-network sessions. +/// +/// The reference implementation uses Network.framework and is therefore only +/// available on Apple platforms. Other platforms can provide their own +/// ``MultiplayerTransport`` implementation without changing AdaScript code. +public actor LocalTCPTransport: MultiplayerTransport { + nonisolated public let capabilities: MultiplayerTransportCapabilities = [.reliableOrdered] + + public init( + host _: String, + port _: UInt16, + log _: @escaping @Sendable (String) -> Void = { _ in } + ) {} + + public func eventStream() -> AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + + public func start(configuration _: MultiplayerTransportConfiguration) async throws { + throw MultiplayerError.unsupportedPlatform + } + + public func send(_: Data, to _: NetworkTarget) async throws { + throw MultiplayerError.unsupportedPlatform + } + + public func stop() async {} +} +#endif diff --git a/Sources/AdaMultiplayer/MultiplayerPlugin.swift b/Sources/AdaMultiplayer/MultiplayerPlugin.swift new file mode 100644 index 000000000..bfd81e795 --- /dev/null +++ b/Sources/AdaMultiplayer/MultiplayerPlugin.swift @@ -0,0 +1,589 @@ +import AdaApp +import AdaECS +import Foundation + +/// Installs transport-independent multiplayer replication and RPC systems. +public struct MultiplayerPlugin: Plugin { + private let configuration: MultiplayerConfiguration + private let transport: any MultiplayerTransport + private let codec: any NetworkCodec + private let replicationPolicy: any ReplicationPolicy + + public init( + configuration: MultiplayerConfiguration, + transport: any MultiplayerTransport, + codec: any NetworkCodec = JSONNetworkCodec(), + replicationPolicy: any ReplicationPolicy = AllPeersReplicationPolicy() + ) { + self.configuration = configuration + self.transport = transport + self.codec = codec + self.replicationPolicy = replicationPolicy + } + + public func setup(in app: borrowing AppWorlds) { + ReplicatedEntity.registerComponent() + NetworkOwner.registerComponent() + + var registry = MultiplayerRegistry() + registry.registerBuiltInComponents() + + app + .insertResource(registry) + .insertResource( + MultiplayerRuntime( + configuration: configuration, + codec: codec, + replicationPolicy: replicationPolicy + ) + ) + .insertResource( + MultiplayerSession(configuration: configuration, transport: transport) + ) + .addSystem(NetworkReceiveSystem.self, on: .networkReceive) + .addSystem(NetworkSendSystem.self, on: .networkSend) + .addSystem(NetworkInterpolationSystem.self, on: .networkInterpolate) + } +} + +@propertyWrapper +final class MultiplayerWorldAccess: @unchecked Sendable { + var wrappedValue: MultiplayerWorldAccess { self } + + static var access: SystemAccessSet { + var access = SystemAccessSet() + // Network systems perform type-erased structural mutation. Making this + // access exclusive prevents concurrent world queries while those + // descriptors are applied. + access.addDeferredWorldAccess() + return access + } + + init() {} +} + +extension MultiplayerWorldAccess: SystemParameter { + convenience init(from _: World) { + self.init() + } + + func update(from _: World) {} +} + +struct HostEntityState: Sendable { + var localID: Entity.ID + var name: String + var components: [String: Data] +} + +struct InterpolationKey: Hashable, Sendable { + var entity: NetworkEntityID + var component: String +} + +struct InterpolationSample: Sendable { + var previous: Data + var current: Data + var receivedAt: TimeInterval +} + +struct MultiplayerRuntime: Resource { + var configuration: MultiplayerConfiguration + var codec: any NetworkCodec + var replicationPolicy: any ReplicationPolicy + var sequence: UInt64 = 0 + var lastReceivedSequence: UInt64 = 0 + var simulationTick: UInt64 = 0 + var lastSnapshotAt: TimeInterval = 0 + var compatiblePeers: Set = [] + var peersNeedingBaseline: Set = [] + var hostEntities: [NetworkEntityID: HostEntityState] = [:] + var clientEntities: [NetworkEntityID: Entity.ID] = [:] + var clientPayloads: [InterpolationKey: Data] = [:] + var interpolationSamples: [InterpolationKey: InterpolationSample] = [:] + + init( + configuration: MultiplayerConfiguration, + codec: any NetworkCodec, + replicationPolicy: any ReplicationPolicy + ) { + self.configuration = configuration + self.codec = codec + self.replicationPolicy = replicationPolicy + } + + func effectiveCompatibility(registry: MultiplayerRegistry) -> NetworkCompatibility { + var compatibility = configuration.compatibility + if compatibility.schemaDigest.isEmpty { + compatibility.schemaDigest = registry.schemaDigest + } + return compatibility + } +} + +@PlainSystem +struct NetworkReceiveSystem { + @Res + private var session + + @Res + private var registry + + @ResMut + private var runtime + + @MultiplayerWorldAccess + private var worldAccess + + init(world _: World) {} + + func update(context: UpdateContext) async { + _ = worldAccess + await session.ensureStarted() + + for descriptor in registry.rpcByTypeID.values { + descriptor.clear(context.world) + } + + let events = await session.drainTransportEvents() + for event in events { + do { + switch event { + case let .connected(peer): + await session.markConnected(peer) + try await sendHandshake(to: peer) + case let .disconnected(peer): + runtime.compatiblePeers.remove(peer) + runtime.peersNeedingBaseline.remove(peer) + await session.markDisconnected(peer) + case let .received(source, payload): + try await receive(payload, source: source, world: context.world) + case let .failed(message): + await session.end(.transportFailure(message)) + } + } catch { + await session.end(.transportFailure(String(describing: error))) + } + } + } + + private func sendHandshake(to peer: PeerID) async throws { + let handshake = NetworkHandshake( + compatibility: runtime.effectiveCompatibility(registry: registry), + role: runtime.configuration.role, + sessionID: runtime.configuration.sessionID, + peerID: runtime.configuration.localPeerID + ) + try await session.send( + frame: NetworkFrame( + kind: .handshake, + payload: try runtime.codec.encode(handshake) + ), + to: runtime.configuration.role == .host ? .peer(peer) : .host + ) + } + + private func receive(_ bytes: Data, source: PeerID, world: World) async throws { + let frame = try NetworkWireCodec.decode(bytes) + switch frame.kind { + case .handshake: + try await receiveHandshake(frame, source: source) + case .handshakeAccepted: + runtime.compatiblePeers.insert(source) + if runtime.configuration.role == .host { + runtime.peersNeedingBaseline.insert(source) + } + case .snapshot: + guard runtime.configuration.role == .peer, runtime.compatiblePeers.contains(source) else { + throw MultiplayerError.invalidDirection + } + let snapshot = try runtime.codec.decode(NetworkSnapshot.self, from: frame.payload) + try apply(snapshot, sequence: frame.sequence, world: world) + case .command, .event, .request: + try receiveRPC(frame, source: source, world: world) + case .response: + guard let correlationID = frame.correlationID else { + throw MultiplayerError.invalidPayload + } + await session.resolveResponse(correlationID, payload: frame.payload) + case .protocolError: + let failure = try runtime.codec.decode(NetworkProtocolFailure.self, from: frame.payload) + await session.end(.transportFailure("\(failure.code): \(failure.message)")) + } + } + + private func receiveHandshake(_ frame: NetworkFrame, source: PeerID) async throws { + let handshake = try runtime.codec.decode(NetworkHandshake.self, from: frame.payload) + let expected = runtime.effectiveCompatibility(registry: registry) + guard handshake.sessionID == runtime.configuration.sessionID, + handshake.compatibility.protocolMajor == expected.protocolMajor, + handshake.compatibility.gameIdentifier == expected.gameIdentifier, + handshake.compatibility.buildIdentifier == expected.buildIdentifier + else { + try await reject(source, error: .incompatibleProtocol) + return + } + guard handshake.compatibility.schemaDigest == expected.schemaDigest else { + try await reject(source, error: .incompatibleSchema) + return + } + guard handshake.role != runtime.configuration.role else { + try await reject(source, error: .invalidDirection) + return + } + + runtime.compatiblePeers.insert(source) + if runtime.configuration.role == .host { + runtime.peersNeedingBaseline.insert(source) + } + try await session.send( + frame: NetworkFrame(kind: .handshakeAccepted, payload: Data()), + to: runtime.configuration.role == .host ? .peer(source) : .host + ) + } + + private func reject(_ peer: PeerID, error: MultiplayerError) async throws { + let failure = NetworkProtocolFailure( + code: String(describing: error), + message: "Multiplayer compatibility check failed" + ) + try await session.send( + frame: NetworkFrame(kind: .protocolError, payload: try runtime.codec.encode(failure)), + to: runtime.configuration.role == .host ? .peer(peer) : .host + ) + await session.end(.incompatiblePeer(peer)) + } + + private func receiveRPC(_ frame: NetworkFrame, source: PeerID, world: World) throws { + guard runtime.compatiblePeers.contains(source), + let typeID = frame.typeID, + let version = frame.typeVersion, + let descriptor = registry.rpcByTypeID[typeID], + descriptor.version == version, + frame.payload.count <= descriptor.maximumPayloadSize + else { + throw MultiplayerError.unknownMessage(frame.typeID ?? "") + } + + let expectedKind: RPCMessageKind = switch frame.kind { + case .command: .command + case .event: .event + case .request: .request + default: throw MultiplayerError.invalidPayload + } + guard descriptor.kind == expectedKind else { + throw MultiplayerError.invalidPayload + } + + let allowed = switch (runtime.configuration.role, descriptor.direction) { + case (.host, .peerToHost), (.peer, .hostToPeer), (_, .bidirectional): true + default: false + } + guard allowed else { + throw MultiplayerError.invalidDirection + } + try descriptor.deliver( + source, + frame.correlationID, + frame.payload, + world, + session, + runtime.codec + ) + } + + private func apply(_ snapshot: NetworkSnapshot, sequence: UInt64, world: World) throws { + guard sequence > runtime.lastReceivedSequence else { + return + } + runtime.lastReceivedSequence = sequence + + let receivedIDs = Set(snapshot.entities.lazy.filter { !$0.despawned }.map(\.id)) + if snapshot.baseline { + for (networkID, localID) in runtime.clientEntities where !receivedIDs.contains(networkID) { + world.removeEntity(localID) + runtime.clientEntities[networkID] = nil + } + } + + for entityDelta in snapshot.entities { + if entityDelta.despawned { + if let localID = runtime.clientEntities.removeValue(forKey: entityDelta.id) { + world.removeEntity(localID) + } + continue + } + + let localID: Entity.ID + if let existing = runtime.clientEntities[entityDelta.id] { + localID = existing + } else { + let entity = world.spawn(entityDelta.name) + world.insert(ReplicatedEntity(id: entityDelta.id), for: entity.id) + runtime.clientEntities[entityDelta.id] = entity.id + localID = entity.id + } + + for componentDelta in entityDelta.components { + guard let descriptor = registry.replicatedComponentsByTypeID[componentDelta.typeID], + descriptor.version == componentDelta.version + else { + throw MultiplayerError.unknownMessage(componentDelta.typeID) + } + let key = InterpolationKey(entity: entityDelta.id, component: componentDelta.typeID) + switch componentDelta.operation { + case .remove: + descriptor.remove(world, localID) + runtime.clientPayloads[key] = nil + runtime.interpolationSamples[key] = nil + case .set: + guard let payload = componentDelta.payload else { + throw MultiplayerError.invalidPayload + } + if descriptor.interpolate != nil, let previous = runtime.clientPayloads[key] { + runtime.interpolationSamples[key] = InterpolationSample( + previous: previous, + current: payload, + receivedAt: Date.timeIntervalSinceReferenceDate + ) + } else { + try descriptor.apply(payload, world, localID, runtime.codec) + } + runtime.clientPayloads[key] = payload + } + } + } + } +} + +@PlainSystem +struct NetworkSendSystem { + @Res + private var session + + @Res + private var registry + + @ResMut + private var runtime + + @MultiplayerWorldAccess + private var worldAccess + + init(world _: World) {} + + func update(context: UpdateContext) async { + _ = worldAccess + guard runtime.configuration.role == .host else { + return + } + let now = Date.timeIntervalSinceReferenceDate + guard now - runtime.lastSnapshotAt >= 1 / runtime.configuration.snapshotsPerSecond else { + return + } + runtime.lastSnapshotAt = now + runtime.simulationTick &+= 1 + + do { + let current = try capture(world: context.world) + for peer in runtime.peersNeedingBaseline where runtime.compatiblePeers.contains(peer) { + let snapshot = makeBaseline(from: current, for: peer) + try await send(snapshot, to: .peer(peer)) + runtime.peersNeedingBaseline.remove(peer) + } + let delta = makeDelta(previous: runtime.hostEntities, current: current) + if !delta.entities.isEmpty { + for peer in runtime.compatiblePeers { + let visible = NetworkSnapshot( + baseline: false, + entities: delta.entities.filter { + runtime.replicationPolicy.shouldReplicate(entity: $0.id, to: peer) + } + ) + if !visible.entities.isEmpty { + try await send(visible, to: .peer(peer)) + } + } + } + runtime.hostEntities = current + } catch { + await session.end(.transportFailure(String(describing: error))) + } + } + + private func capture(world: World) throws -> [NetworkEntityID: HostEntityState] { + var result: [NetworkEntityID: HostEntityState] = [:] + let query = EntityQuery(where: .has(ReplicatedEntity.self)) + for entity in world.performQuery(query) { + guard var marker = world.get(ReplicatedEntity.self, from: entity.id) else { + continue + } + if marker.id == nil { + marker.id = NetworkEntityID() + world.insert(marker, for: entity.id) + } + guard let networkID = marker.id else { + continue + } + var components: [String: Data] = [:] + for descriptor in registry.replicatedComponentsByTypeID.values { + if let payload = try descriptor.encode(world, entity.id, runtime.codec) { + components[descriptor.typeID] = payload + } + } + result[networkID] = HostEntityState( + localID: entity.id, + name: entity.name, + components: components + ) + } + return result + } + + private func makeBaseline( + from current: [NetworkEntityID: HostEntityState], + for peer: PeerID + ) -> NetworkSnapshot { + NetworkSnapshot( + baseline: true, + entities: current.compactMap { id, state in + guard runtime.replicationPolicy.shouldReplicate(entity: id, to: peer) else { + return nil + } + return NetworkEntityDelta( + id: id, + name: state.name, + despawned: false, + components: componentSets(state.components) + ) + } + ) + } + + private func makeDelta( + previous: [NetworkEntityID: HostEntityState], + current: [NetworkEntityID: HostEntityState] + ) -> NetworkSnapshot { + var entities: [NetworkEntityDelta] = [] + for (id, state) in current { + guard let old = previous[id] else { + entities.append( + NetworkEntityDelta( + id: id, + name: state.name, + despawned: false, + components: componentSets(state.components) + ) + ) + continue + } + var changes: [NetworkComponentDelta] = [] + for (typeID, payload) in state.components where old.components[typeID] != payload { + guard let descriptor = registry.replicatedComponentsByTypeID[typeID] else { + continue + } + changes.append( + NetworkComponentDelta( + typeID: typeID, + version: descriptor.version, + operation: .set, + payload: payload + ) + ) + } + for typeID in old.components.keys where state.components[typeID] == nil { + guard let descriptor = registry.replicatedComponentsByTypeID[typeID] else { + continue + } + changes.append( + NetworkComponentDelta( + typeID: typeID, + version: descriptor.version, + operation: .remove, + payload: nil + ) + ) + } + if !changes.isEmpty { + entities.append( + NetworkEntityDelta(id: id, name: state.name, despawned: false, components: changes) + ) + } + } + for (id, state) in previous where current[id] == nil { + entities.append( + NetworkEntityDelta(id: id, name: state.name, despawned: true, components: []) + ) + } + return NetworkSnapshot(baseline: false, entities: entities) + } + + private func componentSets(_ components: [String: Data]) -> [NetworkComponentDelta] { + components.compactMap { typeID, payload in + guard let descriptor = registry.replicatedComponentsByTypeID[typeID] else { + return nil + } + return NetworkComponentDelta( + typeID: typeID, + version: descriptor.version, + operation: .set, + payload: payload + ) + } + } + + private func send(_ snapshot: NetworkSnapshot, to target: NetworkTarget) async throws { + runtime.sequence &+= 1 + try await session.send( + frame: NetworkFrame( + kind: .snapshot, + sequence: runtime.sequence, + simulationTick: runtime.simulationTick, + payload: try runtime.codec.encode(snapshot) + ), + to: target + ) + } +} + +@PlainSystem +struct NetworkInterpolationSystem { + @Res + private var registry + + @ResMut + private var runtime + + @MultiplayerWorldAccess + private var worldAccess + + init(world _: World) {} + + func update(context: UpdateContext) async { + _ = worldAccess + guard runtime.configuration.role == .peer else { + return + } + let now = Date.timeIntervalSinceReferenceDate + let duration = 1 / runtime.configuration.snapshotsPerSecond + for (key, sample) in runtime.interpolationSamples { + guard let localID = runtime.clientEntities[key.entity], + let descriptor = registry.replicatedComponentsByTypeID[key.component], + let interpolate = descriptor.interpolate + else { + runtime.interpolationSamples[key] = nil + continue + } + let alpha = Float(min(1, max(0, (now - sample.receivedAt) / duration))) + do { + let payload = try interpolate(sample.previous, sample.current, alpha, runtime.codec) + try descriptor.apply(payload, context.world, localID, runtime.codec) + if alpha >= 1 { + runtime.interpolationSamples[key] = nil + } + } catch { + runtime.interpolationSamples[key] = nil + } + } + } +} diff --git a/Sources/AdaMultiplayer/MultiplayerRPC.swift b/Sources/AdaMultiplayer/MultiplayerRPC.swift new file mode 100644 index 000000000..da4a5e9a7 --- /dev/null +++ b/Sources/AdaMultiplayer/MultiplayerRPC.swift @@ -0,0 +1,321 @@ +import AdaApp +import AdaECS +import Foundation + +/// Common wire identity for typed RPC messages. +public protocol NetworkMessage: Codable, Sendable { + static var networkIdentifier: String { get } + static var networkVersion: UInt16 { get } +} + +extension NetworkMessage { + public static var networkVersion: UInt16 { 1 } +} + +/// One-way message sent by a peer to the authoritative host. +public protocol NetworkCommand: NetworkMessage {} + +/// One-way message emitted by the host for one or more peers. +public protocol NetworkEvent: NetworkMessage {} + +/// Correlated request whose response is encoded by the same RPC registry. +public protocol NetworkRequest: NetworkMessage { + associatedtype Response: Codable & Sendable +} + +/// Allowed direction for a registered RPC message. +public enum RPCDirection: String, Codable, Sendable { + case peerToHost + case hostToPeer + case bidirectional +} + +enum RPCMessageKind: String, Codable, Sendable { + case command + case event + case request +} + +struct RPCDescriptor: Sendable { + var typeID: String + var version: UInt16 + var kind: RPCMessageKind + var direction: RPCDirection + var maximumPayloadSize: Int + var clear: @Sendable (World) -> Void + var deliver: @Sendable ( + _ source: PeerID, + _ correlationID: UUID?, + _ payload: Data, + _ world: World, + _ session: MultiplayerSession, + _ codec: any NetworkCodec + ) throws -> Void +} + +/// A typed command together with its authenticated transport source. +public struct RemoteCommand: Sendable { + public let source: PeerID + public let value: T +} + +private struct RemoteCommandStorage: Resource { + var values: [RemoteCommand] = [] +} + +/// System parameter containing commands received during `networkReceive`. +@propertyWrapper +public final class RemoteCommands: @unchecked Sendable { + private var storage: Ref>? + + public var wrappedValue: [RemoteCommand] { + storage?.wrappedValue.values ?? [] + } + + public init() {} +} + +extension RemoteCommands: SystemParameter { + public static var access: SystemAccessSet { + var access = SystemAccessSet() + access.addResourceRead(RemoteCommandStorage.self) + return access + } + + public convenience init(from _: World) { + self.init() + } + + public func update(from world: World) { + storage = world.getOrInitRefResource(RemoteCommandStorage.self) { + RemoteCommandStorage() + } + } +} + +/// A typed event together with the host that emitted it. +public struct RemoteEvent: Sendable { + public let source: PeerID + public let value: T +} + +private struct RemoteEventStorage: Resource { + var values: [RemoteEvent] = [] +} + +/// System parameter containing host events received during `networkReceive`. +@propertyWrapper +public final class RemoteEvents: @unchecked Sendable { + private var storage: Ref>? + + public var wrappedValue: [RemoteEvent] { + storage?.wrappedValue.values ?? [] + } + + public init() {} +} + +extension RemoteEvents: SystemParameter { + public static var access: SystemAccessSet { + var access = SystemAccessSet() + access.addResourceRead(RemoteEventStorage.self) + return access + } + + public convenience init(from _: World) { + self.init() + } + + public func update(from world: World) { + storage = world.getOrInitRefResource(RemoteEventStorage.self) { + RemoteEventStorage() + } + } +} + +/// Sends the response for one received request exactly through its source path. +public struct RPCResponder: Sendable { + let session: MultiplayerSession + let correlationID: UUID + let typeID: String + let version: UInt16 + let peer: PeerID + + public func respond(_ response: Response) async throws { + try await session.sendResponse( + response, + correlationID: correlationID, + typeID: typeID, + version: version, + to: peer + ) + } +} + +/// A decoded request and its correlated responder. +public struct RemoteRequest: Sendable { + public let source: PeerID + public let value: T + public let responder: RPCResponder +} + +private struct RemoteRequestStorage: Resource { + var values: [RemoteRequest] = [] +} + +/// System parameter containing requests received during `networkReceive`. +@propertyWrapper +public final class RemoteRequests: @unchecked Sendable { + private var storage: Ref>? + + public var wrappedValue: [RemoteRequest] { + storage?.wrappedValue.values ?? [] + } + + public init() {} +} + +extension RemoteRequests: SystemParameter { + public static var access: SystemAccessSet { + var access = SystemAccessSet() + access.addResourceRead(RemoteRequestStorage.self) + return access + } + + public convenience init(from _: World) { + self.init() + } + + public func update(from world: World) { + storage = world.getOrInitRefResource(RemoteRequestStorage.self) { + RemoteRequestStorage() + } + } +} + +extension AppWorlds { + /// Registers a peer-to-host command type. + @discardableResult + public func registerNetworkCommand( + _ type: T.Type, + direction: RPCDirection = .peerToHost, + maximumPayloadSize: Int = 64 * 1_024 + ) -> Self { + registerRPC( + type, + kind: .command, + direction: direction, + maximumPayloadSize: maximumPayloadSize, + clear: { world in + world.getOrInitRefResource(RemoteCommandStorage.self) { + RemoteCommandStorage() + }.wrappedValue.values.removeAll(keepingCapacity: true) + }, + deliver: { source, _, payload, world, _, codec in + let value = try codec.decode(T.self, from: payload) + world.getOrInitRefResource(RemoteCommandStorage.self) { + RemoteCommandStorage() + }.wrappedValue.values.append(RemoteCommand(source: source, value: value)) + } + ) + } + + /// Registers a host-to-peer event type. + @discardableResult + public func registerNetworkEvent( + _ type: T.Type, + direction: RPCDirection = .hostToPeer, + maximumPayloadSize: Int = 64 * 1_024 + ) -> Self { + registerRPC( + type, + kind: .event, + direction: direction, + maximumPayloadSize: maximumPayloadSize, + clear: { world in + world.getOrInitRefResource(RemoteEventStorage.self) { + RemoteEventStorage() + }.wrappedValue.values.removeAll(keepingCapacity: true) + }, + deliver: { source, _, payload, world, _, codec in + let value = try codec.decode(T.self, from: payload) + world.getOrInitRefResource(RemoteEventStorage.self) { + RemoteEventStorage() + }.wrappedValue.values.append(RemoteEvent(source: source, value: value)) + } + ) + } + + /// Registers a correlated request/response type. + @discardableResult + public func registerNetworkRequest( + _ type: T.Type, + direction: RPCDirection = .bidirectional, + maximumPayloadSize: Int = 64 * 1_024 + ) -> Self { + registerRPC( + type, + kind: .request, + direction: direction, + maximumPayloadSize: maximumPayloadSize, + clear: { world in + world.getOrInitRefResource(RemoteRequestStorage.self) { + RemoteRequestStorage() + }.wrappedValue.values.removeAll(keepingCapacity: true) + }, + deliver: { source, correlationID, payload, world, session, codec in + guard let correlationID else { + throw MultiplayerError.invalidPayload + } + let value = try codec.decode(T.self, from: payload) + let responder = RPCResponder( + session: session, + correlationID: correlationID, + typeID: T.networkIdentifier, + version: T.networkVersion, + peer: source + ) + world.getOrInitRefResource(RemoteRequestStorage.self) { + RemoteRequestStorage() + }.wrappedValue.values.append( + RemoteRequest(source: source, value: value, responder: responder) + ) + } + ) + } + + @discardableResult + private func registerRPC( + _ type: T.Type, + kind: RPCMessageKind, + direction: RPCDirection, + maximumPayloadSize: Int, + clear: @escaping @Sendable (World) -> Void, + deliver: @escaping @Sendable ( + PeerID, + UUID?, + Data, + World, + MultiplayerSession, + any NetworkCodec + ) throws -> Void + ) -> Self { + guard getResource(MultiplayerRegistry.self) != nil else { + preconditionFailure("Add MultiplayerPlugin before registering network messages") + } + var registry = getRefResource(MultiplayerRegistry.self).wrappedValue + precondition(!T.networkIdentifier.isEmpty, "Network message identifier must not be empty") + precondition(registry.rpcByTypeID[T.networkIdentifier] == nil, "Network message identifier \(T.networkIdentifier) is already registered") + registry.rpcByTypeID[T.networkIdentifier] = RPCDescriptor( + typeID: T.networkIdentifier, + version: T.networkVersion, + kind: kind, + direction: direction, + maximumPayloadSize: max(1, maximumPayloadSize), + clear: clear, + deliver: deliver + ) + getRefResource(MultiplayerRegistry.self).wrappedValue = registry + return self + } +} diff --git a/Sources/AdaMultiplayer/MultiplayerSession.swift b/Sources/AdaMultiplayer/MultiplayerSession.swift new file mode 100644 index 000000000..24deb9e34 --- /dev/null +++ b/Sources/AdaMultiplayer/MultiplayerSession.swift @@ -0,0 +1,250 @@ +import AdaECS +import Foundation + +/// Actor that owns transport lifetime and asynchronous request continuations. +public actor MultiplayerSession: Resource { + private struct PendingResponse: Sendable { + var continuation: AsyncThrowingStream.Continuation + var timeout: Task + } + + public let configuration: MultiplayerConfiguration + + private let transport: any MultiplayerTransport + private var state: MultiplayerSessionState = .idle + private var connectedPeers: Set = [] + private var incomingEvents: [MultiplayerTransportEvent] = [] + private var eventTask: Task? + private var disconnectTasks: [PeerID: Task] = [:] + private var pendingResponses: [UUID: PendingResponse] = [:] + + public init(configuration: MultiplayerConfiguration, transport: any MultiplayerTransport) { + self.configuration = configuration + self.transport = transport + } + + public func currentState() -> MultiplayerSessionState { + state + } + + public func peers() -> Set { + connectedPeers + } + + func ensureStarted() async { + guard state == .idle else { + return + } + state = .connecting + let stream = await transport.eventStream() + eventTask = Task { [weak self] in + for await event in stream { + await self?.enqueue(event) + } + } + do { + try await transport.start( + configuration: MultiplayerTransportConfiguration( + role: configuration.role, + sessionID: configuration.sessionID, + localPeerID: configuration.localPeerID + ) + ) + if configuration.role == .host { + state = .connected + } + } catch { + state = .ended(.transportFailure(String(describing: error))) + incomingEvents.append(.failed(String(describing: error))) + } + } + + public func stop() async { + eventTask?.cancel() + eventTask = nil + for task in disconnectTasks.values { + task.cancel() + } + disconnectTasks.removeAll() + for response in pendingResponses.values { + response.timeout.cancel() + response.continuation.finish(throwing: MultiplayerError.sessionEnded) + } + pendingResponses.removeAll() + await transport.stop() + connectedPeers.removeAll() + state = .ended(.stopped) + } + + func drainTransportEvents() -> [MultiplayerTransportEvent] { + let events = incomingEvents + incomingEvents.removeAll(keepingCapacity: true) + return events + } + + func markConnected(_ peer: PeerID) { + disconnectTasks.removeValue(forKey: peer)?.cancel() + connectedPeers.insert(peer) + state = .connected + } + + func markDisconnected(_ peer: PeerID) { + connectedPeers.remove(peer) + if configuration.role == .peer { + let delay = configuration.disconnectGracePeriod + disconnectTasks[peer]?.cancel() + disconnectTasks[peer] = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { + return + } + await self?.endAfterDisconnect(peer) + } + } + } + + func end(_ reason: MultiplayerSessionEndReason) { + state = .ended(reason) + } + + func send(frame: NetworkFrame, to target: NetworkTarget) async throws { + guard state == .connected || state == .connecting else { + throw MultiplayerError.notConnected + } + try await transport.send(NetworkWireCodec.encode(frame), to: target) + } + + /// Sends a typed one-way command to the authoritative host. + public func sendCommand(_ command: T) async throws { + guard configuration.role == .peer else { + throw MultiplayerError.peerOnly + } + let payload = try JSONNetworkCodec().encode(command) + try await send( + frame: NetworkFrame( + kind: .command, + typeID: T.networkIdentifier, + typeVersion: T.networkVersion, + payload: payload + ), + to: .host + ) + } + + /// Sends a typed event from the host to one or more peers. + public func sendEvent(_ event: T, to target: NetworkTarget = .allPeers) async throws { + guard configuration.role == .host else { + throw MultiplayerError.hostOnly + } + let payload = try JSONNetworkCodec().encode(event) + try await send( + frame: NetworkFrame( + kind: .event, + typeID: T.networkIdentifier, + typeVersion: T.networkVersion, + payload: payload + ), + to: target + ) + } + + /// Sends a typed request and waits for its correlated response. + public func request( + _ request: T, + to requestedTarget: NetworkTarget? = nil, + timeout: TimeInterval = 5 + ) async throws -> T.Response { + let target: NetworkTarget + switch (configuration.role, requestedTarget) { + case (.peer, nil), (.peer, .host?): + target = .host + case let (.host, .peer(peer)?): + target = .peer(peer) + default: + throw MultiplayerError.invalidDirection + } + let correlationID = UUID() + let pair = AsyncThrowingStream.makeStream() + let timeoutNanoseconds = UInt64(max(0, timeout) * 1_000_000_000) + let timeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + guard !Task.isCancelled else { + return + } + await self?.failResponse(correlationID, error: MultiplayerError.requestTimedOut) + } + pendingResponses[correlationID] = PendingResponse( + continuation: pair.continuation, + timeout: timeoutTask + ) + + do { + let payload = try JSONNetworkCodec().encode(request) + try await send( + frame: NetworkFrame( + kind: .request, + typeID: T.networkIdentifier, + typeVersion: T.networkVersion, + correlationID: correlationID, + payload: payload + ), + to: target + ) + for try await data in pair.stream { + return try JSONNetworkCodec().decode(T.Response.self, from: data) + } + throw MultiplayerError.sessionEnded + } catch { + failResponse(correlationID, error: error) + throw error + } + } + + func sendResponse( + _ response: T, + correlationID: UUID, + typeID: String, + version: UInt16, + to peer: PeerID + ) async throws { + try await send( + frame: NetworkFrame( + kind: .response, + typeID: typeID, + typeVersion: version, + correlationID: correlationID, + payload: try JSONNetworkCodec().encode(response) + ), + to: configuration.role == .host ? .peer(peer) : .host + ) + } + + func resolveResponse(_ correlationID: UUID, payload: Data) { + guard let pending = pendingResponses.removeValue(forKey: correlationID) else { + return + } + pending.timeout.cancel() + pending.continuation.yield(payload) + pending.continuation.finish() + } + + private func enqueue(_ event: MultiplayerTransportEvent) { + incomingEvents.append(event) + } + + private func endAfterDisconnect(_ peer: PeerID) { + disconnectTasks[peer] = nil + guard !connectedPeers.contains(peer) else { + return + } + state = .ended(.hostDisconnected) + } + + private func failResponse(_ correlationID: UUID, error: any Error) { + guard let pending = pendingResponses.removeValue(forKey: correlationID) else { + return + } + pending.timeout.cancel() + pending.continuation.finish(throwing: error) + } +} diff --git a/Sources/AdaMultiplayer/MultiplayerTransport.swift b/Sources/AdaMultiplayer/MultiplayerTransport.swift new file mode 100644 index 000000000..bd5b962a0 --- /dev/null +++ b/Sources/AdaMultiplayer/MultiplayerTransport.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Delivery features exposed by a multiplayer transport. +public struct MultiplayerTransportCapabilities: OptionSet, Sendable { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + public static let reliableOrdered = Self(rawValue: 1 << 0) + public static let unreliable = Self(rawValue: 1 << 1) + public static let localDiscovery = Self(rawValue: 1 << 2) +} + +/// Information needed to attach a transport endpoint to a logical session. +public struct MultiplayerTransportConfiguration: Sendable { + public var role: NetworkRole + public var sessionID: SessionID + public var localPeerID: PeerID + + public init(role: NetworkRole, sessionID: SessionID, localPeerID: PeerID) { + self.role = role + self.sessionID = sessionID + self.localPeerID = localPeerID + } +} + +/// Events delivered by an active multiplayer transport. +public enum MultiplayerTransportEvent: Sendable { + case connected(PeerID) + case disconnected(PeerID) + case received(source: PeerID, payload: Data) + case failed(String) +} + +/// Replaceable byte transport used by ``MultiplayerPlugin``. +/// +/// Implementations must not retain or mutate an AdaECS `World`. They deliver +/// `Sendable` events which are consumed by the network receive scheduler. +public protocol MultiplayerTransport: Sendable { + var capabilities: MultiplayerTransportCapabilities { get } + + func eventStream() async -> AsyncStream + func start(configuration: MultiplayerTransportConfiguration) async throws + func send(_ payload: Data, to target: NetworkTarget) async throws + func stop() async +} + +/// In-process star router used by tests, previews, and custom server embedding. +public actor InMemoryTransportHub { + private struct Endpoint: Sendable { + var role: NetworkRole + var continuation: AsyncStream.Continuation + } + + private var endpoints: [PeerID: Endpoint] = [:] + private var host: PeerID? + + public init() {} + + func connect( + peer: PeerID, + role: NetworkRole, + continuation: AsyncStream.Continuation + ) throws { + if role == .host { + guard host == nil || host == peer else { + throw MultiplayerError.invalidDirection + } + host = peer + } else if host == nil { + throw MultiplayerError.notConnected + } + + let connectedPeers = Array(endpoints.keys) + endpoints[peer] = Endpoint(role: role, continuation: continuation) + for connectedPeer in connectedPeers { + endpoints[connectedPeer]?.continuation.yield(.connected(peer)) + continuation.yield(.connected(connectedPeer)) + } + } + + func disconnect(peer: PeerID) { + guard endpoints.removeValue(forKey: peer) != nil else { + return + } + if host == peer { + host = nil + } + for endpoint in endpoints.values { + endpoint.continuation.yield(.disconnected(peer)) + } + } + + func send(_ payload: Data, source: PeerID, target: NetworkTarget) throws { + guard let sourceEndpoint = endpoints[source] else { + throw MultiplayerError.notConnected + } + + let recipients: [PeerID] + switch target { + case .host: + guard sourceEndpoint.role == .peer, let host else { + throw MultiplayerError.invalidDirection + } + recipients = [host] + case let .peer(peer): + guard sourceEndpoint.role == .host else { + throw MultiplayerError.invalidDirection + } + recipients = [peer] + case .allPeers: + guard sourceEndpoint.role == .host else { + throw MultiplayerError.invalidDirection + } + recipients = endpoints.compactMap { peer, endpoint in + endpoint.role == .peer ? peer : nil + } + case let .allPeersExcept(excluded): + guard sourceEndpoint.role == .host else { + throw MultiplayerError.invalidDirection + } + recipients = endpoints.compactMap { peer, endpoint in + endpoint.role == .peer && peer != excluded ? peer : nil + } + } + + for recipient in recipients { + endpoints[recipient]?.continuation.yield(.received(source: source, payload: payload)) + } + } +} + +/// A concrete transport endpoint backed by ``InMemoryTransportHub``. +public actor InMemoryTransport: MultiplayerTransport { + public nonisolated let capabilities: MultiplayerTransportCapabilities = [.reliableOrdered] + + private let hub: InMemoryTransportHub + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + private var configuration: MultiplayerTransportConfiguration? + + public init(hub: InMemoryTransportHub) { + self.hub = hub + let pair = AsyncStream.makeStream() + self.stream = pair.stream + self.continuation = pair.continuation + } + + public func eventStream() -> AsyncStream { + stream + } + + public func start(configuration: MultiplayerTransportConfiguration) async throws { + guard self.configuration == nil else { + return + } + try await hub.connect( + peer: configuration.localPeerID, + role: configuration.role, + continuation: continuation + ) + self.configuration = configuration + } + + public func send(_ payload: Data, to target: NetworkTarget) async throws { + guard let configuration else { + throw MultiplayerError.notConnected + } + try await hub.send(payload, source: configuration.localPeerID, target: target) + } + + public func stop() async { + guard let configuration else { + return + } + await hub.disconnect(peer: configuration.localPeerID) + self.configuration = nil + } +} diff --git a/Sources/AdaMultiplayer/MultiplayerTypes.swift b/Sources/AdaMultiplayer/MultiplayerTypes.swift new file mode 100644 index 000000000..68a33ad23 --- /dev/null +++ b/Sources/AdaMultiplayer/MultiplayerTypes.swift @@ -0,0 +1,166 @@ +import AdaECS +import Foundation + +/// Stable identifier for a multiplayer session. +public struct SessionID: Codable, Hashable, RawRepresentable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + public init() { + self.init(rawValue: UUID()) + } +} + +/// Stable identifier for one participant in a multiplayer session. +public struct PeerID: Codable, Hashable, RawRepresentable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + public init() { + self.init(rawValue: UUID()) + } +} + +/// Stable network identity assigned by the authoritative host. +public struct NetworkEntityID: Codable, Hashable, RawRepresentable, Sendable { + public let rawValue: UUID + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + public init() { + self.init(rawValue: UUID()) + } +} + +/// The local runtime role in a star-shaped multiplayer session. +public enum NetworkRole: String, Codable, Sendable { + case host + case peer +} + +/// Destination for a packet sent through a multiplayer transport. +public enum NetworkTarget: Codable, Equatable, Sendable { + case host + case peer(PeerID) + case allPeers + case allPeersExcept(PeerID) +} + +/// Compatibility data exchanged before any gameplay state is accepted. +public struct NetworkCompatibility: Codable, Equatable, Sendable { + public var protocolMajor: UInt16 + public var protocolMinor: UInt16 + public var gameIdentifier: String + public var buildIdentifier: String + public var schemaDigest: String + + public init( + protocolMajor: UInt16 = 1, + protocolMinor: UInt16 = 0, + gameIdentifier: String, + buildIdentifier: String, + schemaDigest: String = "" + ) { + self.protocolMajor = protocolMajor + self.protocolMinor = protocolMinor + self.gameIdentifier = gameIdentifier + self.buildIdentifier = buildIdentifier + self.schemaDigest = schemaDigest + } +} + +/// Runtime configuration for ``MultiplayerPlugin``. +public struct MultiplayerConfiguration: Sendable { + public var role: NetworkRole + public var sessionID: SessionID + public var localPeerID: PeerID + public var compatibility: NetworkCompatibility + public var snapshotsPerSecond: Double + public var disconnectGracePeriod: TimeInterval + + public init( + role: NetworkRole, + sessionID: SessionID = SessionID(), + localPeerID: PeerID = PeerID(), + compatibility: NetworkCompatibility, + snapshotsPerSecond: Double = 20, + disconnectGracePeriod: TimeInterval = 10 + ) { + self.role = role + self.sessionID = sessionID + self.localPeerID = localPeerID + self.compatibility = compatibility + self.snapshotsPerSecond = max(1, snapshotsPerSecond) + self.disconnectGracePeriod = max(0, disconnectGracePeriod) + } +} + +/// Marker placed on an entity that participates in network replication. +/// +/// The host fills ``id`` on the first network snapshot. Only component types +/// registered with `registerReplicatedComponent` are serialized. +public struct ReplicatedEntity: Component, Codable, Sendable { + public static var requiredComponents: RequiredComponents { + RequiredComponents(components: []) + } + + public var id: NetworkEntityID? + + public init(id: NetworkEntityID? = nil) { + self.id = id + } +} + +/// Identifies the peer whose input controls an entity. +/// +/// This does not transfer authoritative component mutation away from the host. +public struct NetworkOwner: Component, Codable, Sendable { + public static var requiredComponents: RequiredComponents { + RequiredComponents(components: []) + } + + public var peer: PeerID + + public init(peer: PeerID) { + self.peer = peer + } +} + +/// High-level state exposed by ``MultiplayerSession``. +public enum MultiplayerSessionState: Equatable, Sendable { + case idle + case connecting + case connected + case ended(MultiplayerSessionEndReason) +} + +/// Reason an active multiplayer session ended. +public enum MultiplayerSessionEndReason: Equatable, Sendable { + case stopped + case hostDisconnected + case transportFailure(String) + case incompatiblePeer(PeerID) +} + +/// Errors produced by the public multiplayer API. +public enum MultiplayerError: Error, Equatable, Sendable { + case notConnected + case hostOnly + case peerOnly + case incompatibleProtocol + case incompatibleSchema + case unknownMessage(String) + case invalidDirection + case invalidPayload + case requestTimedOut + case sessionEnded + case unsupportedPlatform +} diff --git a/Sources/AdaMultiplayer/NetworkWire.swift b/Sources/AdaMultiplayer/NetworkWire.swift new file mode 100644 index 000000000..2fa413ba0 --- /dev/null +++ b/Sources/AdaMultiplayer/NetworkWire.swift @@ -0,0 +1,142 @@ +import Foundation + +/// Codec used for typed component and RPC payloads. +public protocol NetworkCodec: Sendable { + func encode(_ value: T) throws -> Data + func decode(_ type: T.Type, from data: Data) throws -> T +} + +/// Debuggable Codable codec shipped by the first protocol version. +public struct JSONNetworkCodec: NetworkCodec { + public init() {} + + public func encode(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(value) + } + + public func decode(_ type: T.Type, from data: Data) throws -> T { + try JSONDecoder().decode(type, from: data) + } +} + +enum NetworkFrameKind: String, Codable, Sendable { + case handshake + case handshakeAccepted + case protocolError + case snapshot + case command + case event + case request + case response +} + +struct NetworkFrame: Codable, Sendable { + var kind: NetworkFrameKind + var epoch: UInt32 = 0 + var sequence: UInt64 = 0 + var simulationTick: UInt64 = 0 + var typeID: String? + var typeVersion: UInt16? + var correlationID: UUID? + var payload: Data +} + +struct NetworkHandshake: Codable, Sendable { + var compatibility: NetworkCompatibility + var role: NetworkRole + var sessionID: SessionID + var peerID: PeerID +} + +struct NetworkProtocolFailure: Codable, Sendable { + var code: String + var message: String +} + +struct NetworkSnapshot: Codable, Sendable { + var baseline: Bool + var entities: [NetworkEntityDelta] +} + +struct NetworkEntityDelta: Codable, Sendable { + var id: NetworkEntityID + var name: String + var despawned: Bool + var components: [NetworkComponentDelta] +} + +struct NetworkComponentDelta: Codable, Sendable { + enum Operation: String, Codable, Sendable { + case set + case remove + } + + var typeID: String + var version: UInt16 + var operation: Operation + var payload: Data? +} + +/// Binary framing used on every transport. The body stays Codable JSON in v1, +/// while the fixed header permits codec negotiation in a future major version. +enum NetworkWireCodec { + private static let magic: [UInt8] = [0x41, 0x44, 0x4D, 0x50] // ADMP + private static let maximumBodySize = 8 * 1_024 * 1_024 + + static func encode(_ frame: NetworkFrame) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let body = try encoder.encode(frame) + guard body.count <= maximumBodySize else { + throw MultiplayerError.invalidPayload + } + + var data = Data(magic) + append(UInt16(1), to: &data) + append(UInt16(0), to: &data) + append(UInt32(body.count), to: &data) + data.append(body) + return data + } + + static func decode(_ data: Data) throws -> NetworkFrame { + guard data.count >= 12, Array(data.prefix(4)) == magic else { + throw MultiplayerError.invalidPayload + } + let major = readUInt16(data, at: 4) + guard major == 1 else { + throw MultiplayerError.incompatibleProtocol + } + let length = Int(readUInt32(data, at: 8)) + guard length <= maximumBodySize, data.count == 12 + length else { + throw MultiplayerError.invalidPayload + } + return try JSONDecoder().decode(NetworkFrame.self, from: data.dropFirst(12)) + } + + private static func append(_ value: UInt16, to data: inout Data) { + data.append(UInt8((value >> 8) & 0xFF)) + data.append(UInt8(value & 0xFF)) + } + + private static func append(_ value: UInt32, to data: inout Data) { + data.append(UInt8((value >> 24) & 0xFF)) + data.append(UInt8((value >> 16) & 0xFF)) + data.append(UInt8((value >> 8) & 0xFF)) + data.append(UInt8(value & 0xFF)) + } + + private static func readUInt16(_ data: Data, at offset: Int) -> UInt16 { + (UInt16(data[data.index(data.startIndex, offsetBy: offset)]) << 8) + | UInt16(data[data.index(data.startIndex, offsetBy: offset + 1)]) + } + + private static func readUInt32(_ data: Data, at offset: Int) -> UInt32 { + (UInt32(data[data.index(data.startIndex, offsetBy: offset)]) << 24) + | (UInt32(data[data.index(data.startIndex, offsetBy: offset + 1)]) << 16) + | (UInt32(data[data.index(data.startIndex, offsetBy: offset + 2)]) << 8) + | UInt32(data[data.index(data.startIndex, offsetBy: offset + 3)]) + } +} diff --git a/Sources/AdaMultiplayer/ReplicationRegistry.swift b/Sources/AdaMultiplayer/ReplicationRegistry.swift new file mode 100644 index 000000000..3607beb6c --- /dev/null +++ b/Sources/AdaMultiplayer/ReplicationRegistry.swift @@ -0,0 +1,162 @@ +import AdaApp +import AdaECS +import AdaTransform +import Foundation +import Math + +/// Controls whether an authoritative entity is visible to a peer. +public protocol ReplicationPolicy: Sendable { + func shouldReplicate(entity: NetworkEntityID, to peer: PeerID) -> Bool +} + +/// The v1 policy that exposes every replicated entity to every connected peer. +public struct AllPeersReplicationPolicy: ReplicationPolicy { + public init() {} + + public func shouldReplicate(entity _: NetworkEntityID, to _: PeerID) -> Bool { + true + } +} + +/// Per-component replication behavior. +public struct ReplicatedComponentOptions: Sendable { + public var interpolate: (@Sendable (_ previous: T, _ current: T, _ alpha: Float) -> T)? + + public init( + interpolate: (@Sendable (_ previous: T, _ current: T, _ alpha: Float) -> T)? = nil + ) { + self.interpolate = interpolate + } +} + +struct ReplicatedComponentDescriptor: Sendable { + var typeID: String + var version: UInt16 + var componentID: ComponentId + var encode: @Sendable (World, Entity.ID, any NetworkCodec) throws -> Data? + var apply: @Sendable (Data, World, Entity.ID, any NetworkCodec) throws -> Void + var remove: @Sendable (World, Entity.ID) -> Void + var interpolate: (@Sendable (Data, Data, Float, any NetworkCodec) throws -> Data)? +} + +/// Registry shared by the multiplayer systems in one world. +public struct MultiplayerRegistry: Resource { + var replicatedComponentsByTypeID: [String: ReplicatedComponentDescriptor] = [:] + var replicatedTypeIDByComponentID: [ComponentId: String] = [:] + var rpcByTypeID: [String: RPCDescriptor] = [:] + + public init() {} + + public var schemaDigest: String { + let componentEntries = replicatedComponentsByTypeID.values.map { + "component:\($0.typeID):\($0.version)" + } + let rpcEntries = rpcByTypeID.values.map { + "rpc:\($0.typeID):\($0.version):\($0.kind.rawValue):\($0.direction.rawValue)" + } + return Self.fnv1a64((componentEntries + rpcEntries).sorted().joined(separator: "|")) + } + + mutating func register( + _ type: T.Type, + id: String, + version: UInt16, + options: ReplicatedComponentOptions + ) { + precondition(!id.isEmpty, "Network component identifier must not be empty") + precondition(replicatedComponentsByTypeID[id] == nil, "Network component identifier \(id) is already registered") + + let interpolation: (@Sendable (Data, Data, Float, any NetworkCodec) throws -> Data)? + if let interpolate = options.interpolate { + interpolation = { previous, current, alpha, codec in + let lhs: T = try codec.decode(T.self, from: previous) + let rhs: T = try codec.decode(T.self, from: current) + return try codec.encode(interpolate(lhs, rhs, alpha)) + } + } else { + interpolation = nil + } + + let descriptor: ReplicatedComponentDescriptor = ReplicatedComponentDescriptor( + typeID: id, + version: version, + componentID: T.identifier, + encode: { world, entity, codec in + guard let component = world.get(T.self, from: entity) else { + return nil + } + return try codec.encode(component) + }, + apply: { data, world, entity, codec in + world.insert(try codec.decode(T.self, from: data), for: entity) + }, + remove: { world, entity in + world.remove(T.self, from: entity) + }, + interpolate: interpolation + ) + replicatedComponentsByTypeID[id] = descriptor + replicatedTypeIDByComponentID[T.identifier] = id + } + + private static func fnv1a64(_ text: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in text.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} + +extension AppWorlds { + /// Registers a component that is copied for entities carrying + /// ``ReplicatedEntity``. Registration must happen during plugin setup. + @discardableResult + public func registerReplicatedComponent( + _ type: T.Type, + id: String, + version: UInt16 = 1, + options: ReplicatedComponentOptions = ReplicatedComponentOptions() + ) -> Self { + T.registerComponent() + guard getResource(MultiplayerRegistry.self) != nil else { + preconditionFailure("Add MultiplayerPlugin before registering network components") + } + getRefResource(MultiplayerRegistry.self).wrappedValue.register( + type, + id: id, + version: version, + options: options + ) + return self + } +} + +extension MultiplayerRegistry { + mutating func registerBuiltInComponents() { + register( + NetworkOwner.self, + id: "ada.network-owner", + version: 1, + options: ReplicatedComponentOptions() + ) + register( + Transform.self, + id: "ada.transform", + version: 1, + options: ReplicatedComponentOptions { previous, current, alpha in + Transform( + rotation: Quat( + x: lerp(previous.rotation.x, current.rotation.x, alpha), + y: lerp(previous.rotation.y, current.rotation.y, alpha), + z: lerp(previous.rotation.z, current.rotation.z, alpha), + w: lerp(previous.rotation.w, current.rotation.w, alpha) + ).normalized, + scale: lerp(previous.scale, current.scale, alpha), + position: lerp(previous.position, current.position, alpha) + ) + } + ) + } +} diff --git a/Sources/AdaRender/DisplayLayout.swift b/Sources/AdaRender/DisplayLayout.swift index 5852a50ad..59a728e0e 100644 --- a/Sources/AdaRender/DisplayLayout.swift +++ b/Sources/AdaRender/DisplayLayout.swift @@ -51,11 +51,11 @@ public struct DisplayLayout: Resource, Codable, Equatable, Sendable { Self.self, fields: keys.map { key in // ECS supplies this pointer only for the duration of a declared resource access. - unsafe EditorComponentFieldDescriptor( + unsafe ReflectedComponentField( key: key, label: key, kind: .readOnly, - isEditable: false, + isWritable: false, accepts: { _ in false }, read: { _ in nil }, write: { _, _ in nil }, @@ -75,7 +75,7 @@ public struct DisplayLayout: Resource, Codable, Equatable, Sendable { ) } - private static func rectangleValue(_ rect: Rect) -> EditorFieldValue { + private static func rectangleValue(_ rect: Rect) -> ReflectedFieldValue { .object([ "x": .double(Double(rect.minX)), "y": .double(Double(rect.minY)), diff --git a/Sources/AdaScene/ScriptableComponents/ScriptUIBindingSystem.swift b/Sources/AdaScene/ScriptableComponents/ScriptUIBindingSystem.swift index 8273a38a2..ef1f0e0de 100644 --- a/Sources/AdaScene/ScriptableComponents/ScriptUIBindingSystem.swift +++ b/Sources/AdaScene/ScriptableComponents/ScriptUIBindingSystem.swift @@ -64,7 +64,7 @@ public struct ScriptUIBindingSystem { } extension UIValue { - init(exportedField value: EditorFieldValue) { + init(exportedField value: ReflectedFieldValue) { switch value { case .null: self = .null case let .bool(value): self = .bool(value) @@ -76,7 +76,7 @@ extension UIValue { } } - var exportedField: EditorFieldValue { + var exportedField: ReflectedFieldValue { switch self { case .null: .null case let .bool(value): .bool(value) diff --git a/Sources/AdaScene/ScriptableComponents/ScriptableComponent.swift b/Sources/AdaScene/ScriptableComponents/ScriptableComponent.swift index f55fc2e43..5cc9b85a3 100644 --- a/Sources/AdaScene/ScriptableComponents/ScriptableComponent.swift +++ b/Sources/AdaScene/ScriptableComponents/ScriptableComponent.swift @@ -58,11 +58,11 @@ open class ScriptableObject: Codable, @unchecked Sendable { /// Returns detached exported state for UI binding. Override alongside `writeExportedField` in native scripts. /// AdaScript provides these accessors automatically for `@export` properties. @MainActor - open func readExportedField(_: String) -> EditorFieldValue? { nil } + open func readExportedField(_: String) -> ReflectedFieldValue? { nil } /// Applies a queued UI edit outside view construction. Return false for unknown or incompatible values. @MainActor - open func writeExportedField(_: String, value _: EditorFieldValue) -> Bool { false } + open func writeExportedField(_: String, value _: ReflectedFieldValue) -> Bool { false } /// Called exactly once after successful attachment. @MainActor diff --git a/Sources/AdaScene/ScriptableComponents/ScriptableObjectRegistry.swift b/Sources/AdaScene/ScriptableComponents/ScriptableObjectRegistry.swift index c9b233291..b1892c0f9 100644 --- a/Sources/AdaScene/ScriptableComponents/ScriptableObjectRegistry.swift +++ b/Sources/AdaScene/ScriptableComponents/ScriptableObjectRegistry.swift @@ -30,7 +30,7 @@ public enum ScriptableObjectCodingError: Error, Equatable, Sendable, CustomStrin public struct ScriptableObjectDescriptor: Sendable { public let aliases: [String] public let declaredAccess: SystemAccessSet - public let exportedFields: [String: EditorFieldValue] + public let exportedFields: [String: ReflectedFieldValue] public let identifier: String public let requiredComponents: [ComponentId] public let version: Int @@ -44,7 +44,7 @@ public struct ScriptableObjectDescriptor: Sendable { version: Int, aliases: [String] = [], declaredAccess: SystemAccessSet = SystemAccessSet(), - exportedFields: [String: EditorFieldValue] = [:], + exportedFields: [String: ReflectedFieldValue] = [:], requiredComponents: [ComponentId] = [], runtimeType: ObjectIdentifier? = nil, make: @escaping @Sendable () -> ScriptableObject, @@ -74,7 +74,7 @@ public enum ScriptableObjectRegistry { version: Int = 1, aliases: [String] = [], declaredAccess: SystemAccessSet = SystemAccessSet(), - exportedFields: [String: EditorFieldValue] = [:], + exportedFields: [String: ReflectedFieldValue] = [:], requiredComponents: [any Component.Type] = [] ) throws { try register( diff --git a/Sources/AdaScriptCompilerCore/AdaScriptSchema.swift b/Sources/AdaScriptCompilerCore/AdaScriptSchema.swift index eb2fd752f..15941f756 100644 --- a/Sources/AdaScriptCompilerCore/AdaScriptSchema.swift +++ b/Sources/AdaScriptCompilerCore/AdaScriptSchema.swift @@ -307,6 +307,7 @@ extension Parser { usesDeferredCommands = usesDeferredCommands || checkSequence(["context", ".", "world", ".", "commands"]) + || checkSequence(["context", ".", "world", ".", "spawn"]) if depth == 1, let binding = try parseResourceBinding(systemName: systemName) { bindings.append(binding) continue diff --git a/Sources/AdaScripting/AdaScriptComponentRuntime.swift b/Sources/AdaScripting/AdaScriptComponentRuntime.swift new file mode 100644 index 000000000..f8aba28dd --- /dev/null +++ b/Sources/AdaScripting/AdaScriptComponentRuntime.swift @@ -0,0 +1,164 @@ +@_spi(Scripting) import AdaECS +import Gravity + +struct AdaScriptLinkedComponentConstructor: Sendable { + let constructor: RegisteredRuntimeComponentConstructor + let index: Int + +} + +enum AdaScriptComponentRuntime { + static func linkedConstructors() -> [AdaScriptLinkedComponentConstructor] { + RuntimeTypeRegistry.registeredRuntimeComponentConstructors() + .enumerated() + .map { AdaScriptLinkedComponentConstructor(constructor: $0.element, index: $0.offset) } + } + + static func prelude( + constructors: [AdaScriptLinkedComponentConstructor] + ) -> String { + let constructorDeclarations = constructors.compactMap { linkedConstructor -> String? in + let constructor = linkedConstructor.constructor + guard + isIdentifier(constructor.name), + constructor.parameters.allSatisfy({ isIdentifier($0.name) }) + else { + return nil + } + let parameters = constructor.parameters + .map(\.name) + .joined(separator: ", ") + let arguments = constructor.parameters + .map(\.name) + .joined(separator: ", ") + return """ + func \(constructor.name)(\(parameters)) { + return __adaComponentFactory.make(\(linkedConstructor.index), [\(arguments)]); + } + """ + } + return ([ + "extern var __adaComponentFactory;", + """ + class __AdaVector3Factory { + var ZERO { + get { return [0.0, 0.0, 0.0]; } + }; + + func exec(x, y, z) { + return [x, y, z]; + } + } + var Vector3 = __AdaVector3Factory(); + """, + ] + constructorDeclarations).joined(separator: "\n") + "\n" + } + + static func bind( + to virtualMachine: GravityVirtualMachine, + constructors: [AdaScriptLinkedComponentConstructor], + reportDiagnostic: @escaping @Sendable (String) -> Void + ) throws { + try virtualMachine.bindClass(with: AnnotatedGravityComponentValue.self) + try virtualMachine.bindClass(with: AnnotatedGravityComponentFactory.self) + virtualMachine.setValue( + AnnotatedGravityComponentFactory.make( + constructors: constructors.map(\.constructor), + reportDiagnostic: reportDiagnostic + ), + forKey: "__adaComponentFactory" + ) + } + + private static func isIdentifier(_ value: String) -> Bool { + guard let first = value.first, first == "_" || first.isLetter else { + return false + } + return value.dropFirst().allSatisfy { $0 == "_" || $0.isLetter || $0.isNumber } + } +} + +@GSExportable("AdaComponentValue") +final class AnnotatedGravityComponentValue: @unchecked Sendable { + @GSExportableIgnore + private var component: (any Component)? + + @GSExportableIgnore + init(component: consuming any Component) { + self.component = component + } + + @GSExportableIgnore + init() { + self.component = nil + } + + @GSExportableIgnore + func takeComponent() -> (any Component)? { + let component = component + self.component = nil + return component + } +} + +@GSExportable("AdaComponentFactory") +final class AnnotatedGravityComponentFactory: @unchecked Sendable { + @GSExportableIgnore + private let constructors: [RegisteredRuntimeComponentConstructor] + + @GSExportableIgnore + private let reportDiagnostic: @Sendable (String) -> Void + + @GSExportableIgnore + static func make( + constructors: [RegisteredRuntimeComponentConstructor], + reportDiagnostic: @escaping @Sendable (String) -> Void + ) -> AnnotatedGravityComponentFactory { + AnnotatedGravityComponentFactory( + constructors: constructors, + reportDiagnostic: reportDiagnostic + ) + } + + private init( + constructors: [RegisteredRuntimeComponentConstructor], + reportDiagnostic: @escaping @Sendable (String) -> Void + ) { + self.constructors = constructors + self.reportDiagnostic = reportDiagnostic + } + + func make(_ constructorIndex: Int, _ argumentValues: GSValue) -> AnnotatedGravityComponentValue { + guard constructors.indices.contains(constructorIndex) else { + reportDiagnostic("AdaScript component constructor index \(constructorIndex) is not linked") + return AnnotatedGravityComponentValue() + } + guard argumentValues.isList else { + reportDiagnostic("AdaScript component constructor arguments must be a list") + return AnnotatedGravityComponentValue() + } + + var arguments: [ReflectedFieldValue?] = [] + arguments.reserveCapacity(argumentValues.toList.count) + for value in argumentValues.toList { + if value.isNull || value.isUndefined { + arguments.append(nil) + continue + } + guard let fieldValue = AnnotatedGravityValueBridge.makeReflectedFieldValue(value) else { + reportDiagnostic("AdaScript component constructor received an unsupported value") + return AnnotatedGravityComponentValue() + } + arguments.append(fieldValue) + } + + do { + return AnnotatedGravityComponentValue( + component: try constructors[constructorIndex].construct(arguments: arguments) + ) + } catch { + reportDiagnostic(String(describing: error)) + return AnnotatedGravityComponentValue() + } + } +} diff --git a/Sources/AdaScripting/AdaScriptUIExport.swift b/Sources/AdaScripting/AdaScriptUIExport.swift index a57847b89..70feecb83 100644 --- a/Sources/AdaScripting/AdaScriptUIExport.swift +++ b/Sources/AdaScripting/AdaScriptUIExport.swift @@ -81,7 +81,7 @@ private struct AdaScriptExportedView: View { } extension UIValue { - var scriptFieldValue: EditorFieldValue { + var scriptFieldValue: ReflectedFieldValue { switch self { case .null: .null case let .bool(value): .bool(value) @@ -92,7 +92,7 @@ extension UIValue { } } - init(field: EditorFieldValue) { + init(field: ReflectedFieldValue) { switch field { case .null: self = .null case let .bool(value): self = .bool(value) diff --git a/Sources/AdaScripting/AdaScriptView.swift b/Sources/AdaScripting/AdaScriptView.swift index 38d82a89a..53d97888f 100644 --- a/Sources/AdaScripting/AdaScriptView.swift +++ b/Sources/AdaScripting/AdaScriptView.swift @@ -336,7 +336,7 @@ final class AdaScriptViewModuleRuntime: @unchecked Sendable { func evaluate( instance: GSValue, identifier: String, - environment: [String: EditorFieldValue] + environment: [String: ReflectedFieldValue] ) throws -> AdaScriptViewModel { try AdaScriptRuntimeCoordinator.lock.withLock { guard let metadata = viewsByIdentifier[identifier] else { @@ -390,7 +390,7 @@ final class AdaScriptViewStorage { private let identifier: String private let instance: GSValue private let runtime: AdaScriptViewModuleRuntime - private var environment: [String: EditorFieldValue] = [:] + private var environment: [String: ReflectedFieldValue] = [:] init(runtime: AdaScriptViewModuleRuntime, identifier: String) throws { self.identifier = identifier @@ -413,7 +413,7 @@ final class AdaScriptViewStorage { func readInput(_ name: String) throws -> UIValue { try runtime.readInput(name, instance: instance) } - func updateEnvironment(_ environment: [String: EditorFieldValue]) throws { + func updateEnvironment(_ environment: [String: ReflectedFieldValue]) throws { guard model == nil || self.environment != environment else { return } @@ -440,7 +440,7 @@ extension UserInterfaceIdiom { } } -private func defaultAdaScriptViewEnvironment() -> [String: EditorFieldValue] { +private func defaultAdaScriptViewEnvironment() -> [String: ReflectedFieldValue] { [ "colorScheme": .string("light"), "isEnabled": .bool(true), diff --git a/Sources/AdaScripting/AnnotatedGravityQueryView.swift b/Sources/AdaScripting/AnnotatedGravityQueryView.swift index 0f1f5059a..4e7ba56c4 100644 --- a/Sources/AdaScripting/AnnotatedGravityQueryView.swift +++ b/Sources/AdaScripting/AnnotatedGravityQueryView.swift @@ -181,7 +181,7 @@ final class AnnotatedGravityComponentView: @unchecked Sendable { return false } guard - let fieldValue = AnnotatedGravityValueBridge.makeEditorFieldValue(value), + let fieldValue = AnnotatedGravityValueBridge.makeReflectedFieldValue(value), cursor.write(componentAt: access.componentIndex, field: field, value: fieldValue) else { reportDiagnostic("Invalid value for '\(access.alias).\(fieldName)'") diff --git a/Sources/AdaScripting/AnnotatedGravityResourceView.swift b/Sources/AdaScripting/AnnotatedGravityResourceView.swift index 8b2b967f5..1540b9fb0 100644 --- a/Sources/AdaScripting/AnnotatedGravityResourceView.swift +++ b/Sources/AdaScripting/AnnotatedGravityResourceView.swift @@ -3,7 +3,7 @@ import Gravity @GSExportable("AdaResource") final class AnnotatedGravityResourceView: @unchecked Sendable { - private let fields: [String: EditorComponentFieldDescriptor] + private let fields: [String: ReflectedComponentField] private let parameter: DynamicResource private let reportDiagnostic: @Sendable (String) -> Void private let virtualMachine: GravityVirtualMachine @@ -11,7 +11,7 @@ final class AnnotatedGravityResourceView: @unchecked Sendable { @GSExportableIgnore static func make( parameter: DynamicResource, - fields: [String: EditorComponentFieldDescriptor], + fields: [String: ReflectedComponentField], reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) -> AnnotatedGravityResourceView { @@ -25,7 +25,7 @@ final class AnnotatedGravityResourceView: @unchecked Sendable { private init( parameter: DynamicResource, - fields: [String: EditorComponentFieldDescriptor], + fields: [String: ReflectedComponentField], reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) { @@ -54,7 +54,7 @@ final class AnnotatedGravityResourceView: @unchecked Sendable { return false } guard - let fieldValue = AnnotatedGravityValueBridge.makeEditorFieldValue(value), + let fieldValue = AnnotatedGravityValueBridge.makeReflectedFieldValue(value), parameter.write(field: field, value: fieldValue) else { reportDiagnostic("Invalid value for resource field '\(fieldName)'") diff --git a/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift b/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift index f8376ed6f..0bd1d0bd0 100644 --- a/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift +++ b/Sources/AdaScripting/AnnotatedGravityScriptPlugin.swift @@ -48,8 +48,12 @@ public final class AdaScriptPlugin: Plugin, @unchecked Sendable { name: String, startupSystemIdentifier: String? = nil ) throws { + let componentConstructors = AdaScriptComponentRuntime.linkedConstructors() let module = try GravityScriptModuleResolver.resolve(sources) - let runtime = try AnnotatedGravityRuntime(module: module) + let runtime = try AnnotatedGravityRuntime( + module: module, + componentConstructors: componentConstructors + ) let resourceBindings = try AdaScriptSchemaParser.parseResourceBindings(sources: sources) let capabilities = try AdaScriptSchemaParser.parseSystemCapabilities(sources: sources) var plans = try AdaScriptSystemPlanBuilder.makePlans( @@ -200,7 +204,7 @@ public final class AdaScriptPlugin: Plugin, @unchecked Sendable { // fetched components. Static access inference will narrow this set. access.addComponentWrite(component.identifier) let typeName = String(reflecting: component) - let descriptor = EditorComponentReflectionRegistry.descriptor(named: typeName) + let descriptor = ComponentReflectionRegistry.descriptor(named: typeName) return AnnotatedComponentAccess( alias: defaultAlias(for: name), componentIndex: index, @@ -274,7 +278,7 @@ private struct PreparedAnnotatedSystem: Sendable { private enum PreparedAnnotatedResource: Sendable { case reflected( - fields: [String: EditorComponentFieldDescriptor], + fields: [String: ReflectedComponentField], parameter: DynamicResource, propertyName: String, resourceName: String @@ -324,7 +328,7 @@ private struct PreparedAnnotatedQuery: Sendable { struct AnnotatedComponentAccess: Sendable { let alias: String let componentIndex: Int - let fields: [String: EditorComponentFieldDescriptor] + let fields: [String: ReflectedComponentField] } private struct AnnotatedGravityScriptSystem: System { @@ -411,7 +415,10 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { private let virtualMachine: GravityVirtualMachine private var instances: [String: GSValue] = [:] - init(module: ResolvedGravityScriptModule) throws { + init( + module: ResolvedGravityScriptModule, + componentConstructors: [AdaScriptLinkedComponentConstructor] + ) throws { let delegate = AnnotatedGravityRuntimeDelegate(module: module) self.delegate = delegate @@ -429,9 +436,16 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { try virtualMachine.bindClass(with: AnnotatedGravityComponentView.self) try virtualMachine.bindClass(with: AnnotatedGravityResourceView.self) try virtualMachine.bindClass(with: AdaScriptViewBridge.self) + try AdaScriptComponentRuntime.bind( + to: virtualMachine, + constructors: componentConstructors, + reportDiagnostic: delegate.append + ) virtualMachine.setValue(AdaScriptViewBridge(), forKey: "adaUIBuilder") - let binary = virtualMachine.loadGravityFile(from: module.entrySource) + let binary = virtualMachine.loadGravityFile( + from: AdaScriptComponentRuntime.prelude(constructors: componentConstructors) + module.entrySource + ) guard delegate.errors.isEmpty else { throw AdaScriptError.compilation(delegate.errors) } @@ -456,6 +470,13 @@ private final class AnnotatedGravityRuntime: @unchecked Sendable { guard instance.hasMethod(named: "update") else { throw AdaScriptError.invalidManifest("@system class '\(plan.className)' must define update(context)") } + // The VM's collector cannot see Swift's GSValue dictionary. Publish a + // private VM global so the system instance remains a live GC root for + // the complete plugin lifetime, matching scriptable-object instances. + virtualMachine.setValue( + instance, + forKey: "__ada_live_system_" + plan.identifier + ) instances[plan.className] = instance } } diff --git a/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift b/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift index 5829d57d5..f0c97b29d 100644 --- a/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift +++ b/Sources/AdaScripting/AnnotatedGravityScriptSupport.swift @@ -118,7 +118,7 @@ final class AnnotatedGravityRuntimeDelegate: GravityVirtualMachineDelegate, @unc } enum AnnotatedGravityValueBridge { - static func makeGravityValue(_ value: EditorFieldValue, virtualMachine: GravityVirtualMachine) -> GSValue { + static func makeGravityValue(_ value: ReflectedFieldValue, virtualMachine: GravityVirtualMachine) -> GSValue { switch value { case .null: GSValue(nullIn: virtualMachine) case let .bool(value): GSValue(boolean: value, in: virtualMachine) @@ -138,7 +138,7 @@ enum AnnotatedGravityValueBridge { } } - static func makeEditorFieldValue(_ value: GSValue) -> EditorFieldValue? { + static func makeReflectedFieldValue(_ value: GSValue) -> ReflectedFieldValue? { if value.isNull || value.isUndefined { return .null } @@ -158,9 +158,9 @@ enum AnnotatedGravityValueBridge { return .string(value.toString) } if value.isList { - var result: [EditorFieldValue] = [] + var result: [ReflectedFieldValue] = [] for item in value.toList { - guard let converted = makeEditorFieldValue(item) else { + guard let converted = makeReflectedFieldValue(item) else { return nil } result.append(converted) diff --git a/Sources/AdaScripting/AnnotatedGravityWorldContext.swift b/Sources/AdaScripting/AnnotatedGravityWorldContext.swift index 942354c69..bff9e2644 100644 --- a/Sources/AdaScripting/AnnotatedGravityWorldContext.swift +++ b/Sources/AdaScripting/AnnotatedGravityWorldContext.swift @@ -14,6 +14,10 @@ final class AnnotatedGravityWorldContext: @unchecked Sendable { self.commands = commands } + func spawn(_ components: GSValue) -> Int { + commands.spawn(components) + } + func invalidate() { commands.invalidate() } @@ -44,29 +48,41 @@ final class AnnotatedGravityCommandsBridge: @unchecked Sendable { self.reportDiagnostic = reportDiagnostic } - func spawn(_ componentNamesValue: GSValue) -> Int { + func spawn(_ componentValues: GSValue) -> Int { guard validateAccess() else { return -1 } - guard componentNamesValue.isList else { - reportDiagnostic("commands.spawn expects a list of component names") + guard componentValues.isList else { + reportDiagnostic("world.spawn expects a list of components") return -1 } var componentIDs = Set() var components: [any Component] = [] - for nameValue in componentNamesValue.toList { - guard nameValue.isString else { - reportDiagnostic("commands.spawn component names must be strings") - return -1 - } - let name = nameValue.toString - guard let component = RuntimeTypeRegistry.makeDefaultComponent(named: name) else { - reportDiagnostic("Component '\(name)' does not have a registered default") + for componentValue in componentValues.toList { + let component: any Component + let diagnosticName: String + if componentValue.isString { + let name = componentValue.toString + guard let defaultComponent = RuntimeTypeRegistry.makeDefaultComponent(named: name) else { + reportDiagnostic("Component '\(name)' does not have a registered default") + return -1 + } + component = defaultComponent + diagnosticName = name + } else if let componentValue = componentValue.toObjectOf(AnnotatedGravityComponentValue.self) { + guard let draftedComponent = componentValue.takeComponent() else { + reportDiagnostic("world.spawn received an invalid or already consumed component") + return -1 + } + component = draftedComponent + diagnosticName = String(describing: type(of: draftedComponent)) + } else { + reportDiagnostic("world.spawn values must be component constructors") return -1 } guard componentIDs.insert(type(of: component).identifier).inserted else { - reportDiagnostic("commands.spawn contains duplicate component '\(name)'") + reportDiagnostic("world.spawn contains duplicate component '\(diagnosticName)'") return -1 } components.append(component) diff --git a/Sources/AdaScripting/GravityAttachedDataView.swift b/Sources/AdaScripting/GravityAttachedDataView.swift index 491136cdb..0eda5e840 100644 --- a/Sources/AdaScripting/GravityAttachedDataView.swift +++ b/Sources/AdaScripting/GravityAttachedDataView.swift @@ -4,7 +4,7 @@ import Gravity @GSExportable("AdaAttachedComponent") final class GravityAttachedComponentView: @unchecked Sendable { private let componentType: any Component.Type - private let descriptor: EditorComponentDescriptor? + private let descriptor: ReflectedComponentDescriptor? private let entityID: Entity.ID private let reportDiagnostic: @Sendable (String) -> Void private let virtualMachine: GravityVirtualMachine @@ -17,7 +17,7 @@ final class GravityAttachedComponentView: @unchecked Sendable { world: World, entityID: Entity.ID, componentType: any Component.Type, - descriptor: EditorComponentDescriptor?, + descriptor: ReflectedComponentDescriptor?, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) -> GravityAttachedComponentView { @@ -35,7 +35,7 @@ final class GravityAttachedComponentView: @unchecked Sendable { world: World, entityID: Entity.ID, componentType: any Component.Type, - descriptor: EditorComponentDescriptor?, + descriptor: ReflectedComponentDescriptor?, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine ) { @@ -68,7 +68,7 @@ final class GravityAttachedComponentView: @unchecked Sendable { func set(_ fieldName: String, _ value: GSValue) -> Bool { guard let world, let descriptor, - let fieldValue = AnnotatedGravityValueBridge.makeEditorFieldValue(value), + let fieldValue = AnnotatedGravityValueBridge.makeReflectedFieldValue(value), descriptor.write(fieldValue, toField: fieldName, in: world, entity: entityID) else { reportDiagnostic("Invalid attached component field '\(fieldName)'") @@ -80,7 +80,7 @@ final class GravityAttachedComponentView: @unchecked Sendable { @GSExportable("AdaAttachedResource") final class GravityAttachedResourceView: @unchecked Sendable { - private let fields: [String: EditorComponentFieldDescriptor] + private let fields: [String: ReflectedComponentField] private let optional: Bool private let reportDiagnostic: @Sendable (String) -> Void private let resourceType: any Resource.Type @@ -93,7 +93,7 @@ final class GravityAttachedResourceView: @unchecked Sendable { static func make( world: World, resourceType: any Resource.Type, - fields: [String: EditorComponentFieldDescriptor], + fields: [String: ReflectedComponentField], optional: Bool, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine @@ -111,7 +111,7 @@ final class GravityAttachedResourceView: @unchecked Sendable { private init( world: World, resourceType: any Resource.Type, - fields: [String: EditorComponentFieldDescriptor], + fields: [String: ReflectedComponentField], optional: Bool, reportDiagnostic: @escaping @Sendable (String) -> Void, virtualMachine: GravityVirtualMachine @@ -145,7 +145,7 @@ final class GravityAttachedResourceView: @unchecked Sendable { func set(_ fieldName: String, _ value: GSValue) -> Bool { guard let world, let field = fields[fieldName], - let fieldValue = AnnotatedGravityValueBridge.makeEditorFieldValue(value), + let fieldValue = AnnotatedGravityValueBridge.makeReflectedFieldValue(value), world.writeResourceField(type: resourceType, field: field, value: fieldValue) else { reportDiagnostic("Invalid attached resource field '\(fieldName)'") diff --git a/Sources/AdaScripting/GravityScriptableObject.swift b/Sources/AdaScripting/GravityScriptableObject.swift index 613aa982e..b7f06dbc6 100644 --- a/Sources/AdaScripting/GravityScriptableObject.swift +++ b/Sources/AdaScripting/GravityScriptableObject.swift @@ -9,7 +9,7 @@ public struct AdaScriptObjectSchema: Sendable { public let aliases: [String] public let bindings: [AdaScriptObjectBinding] public let className: String - public let fields: [String: EditorFieldValue] + public let fields: [String: ReflectedFieldValue] public let identifier: String public let version: Int @@ -19,7 +19,7 @@ public struct AdaScriptObjectSchema: Sendable { version: Int, aliases: [String], bindings: [AdaScriptObjectBinding] = [], - fields: [String: EditorFieldValue] + fields: [String: ReflectedFieldValue] ) { self.aliases = aliases self.bindings = bindings @@ -137,7 +137,7 @@ private final class GravityScriptableDefinition: @unchecked Sendable { return .component( propertyName: binding.propertyName, type: type, - descriptor: EditorComponentReflectionRegistry.descriptor(named: String(reflecting: type)), + descriptor: ComponentReflectionRegistry.descriptor(named: String(reflecting: type)), required: required ) case let .resource(optional): @@ -181,13 +181,13 @@ private enum ResolvedGravityScriptableBinding: @unchecked Sendable { case component( propertyName: String, type: any Component.Type, - descriptor: EditorComponentDescriptor?, + descriptor: ReflectedComponentDescriptor?, required: Bool ) case resource( propertyName: String, type: any Resource.Type, - fields: [String: EditorComponentFieldDescriptor], + fields: [String: ReflectedComponentField], optional: Bool ) } @@ -197,10 +197,10 @@ private final class GravityScriptableObject: ScriptableObject, @unchecked Sendab private let definition: GravityScriptableDefinition private var instanceID: Foundation.UUID? - private var payload: [String: EditorFieldValue] + private var payload: [String: ReflectedFieldValue] @MainActor - override func readExportedField(_ name: String) -> EditorFieldValue? { + override func readExportedField(_ name: String) -> ReflectedFieldValue? { guard definition.schema.fields[name] != nil else { return nil } @@ -208,7 +208,7 @@ private final class GravityScriptableObject: ScriptableObject, @unchecked Sendab } @MainActor - override func writeExportedField(_ name: String, value: EditorFieldValue) -> Bool { + override func writeExportedField(_ name: String, value: ReflectedFieldValue) -> Bool { guard let current = payload[name], definition.schema.fields[name] != nil, let converted = Self.compatible(value, with: current) @@ -222,10 +222,10 @@ private final class GravityScriptableObject: ScriptableObject, @unchecked Sendab return true } - private static func compatible(_ value: EditorFieldValue, with current: EditorFieldValue) -> EditorFieldValue? { + private static func compatible(_ value: ReflectedFieldValue, with current: ReflectedFieldValue) -> ReflectedFieldValue? { switch (current, value) { case let (.int, .double(number)): - return Int(exactly: number).map(EditorFieldValue.int) + return Int(exactly: number).map(ReflectedFieldValue.int) case let (.double, .int(number)): return .double(Double(number)) case (.string, .string), (.bool, .bool), @@ -247,7 +247,7 @@ private final class GravityScriptableObject: ScriptableObject, @unchecked Sendab init( definition: GravityScriptableDefinition, - payload: [String: EditorFieldValue]? = nil + payload: [String: ReflectedFieldValue]? = nil ) { self.definition = definition self.payload = definition.schema.fields.merging(payload ?? [:]) { _, decoded in decoded } @@ -393,6 +393,7 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { private var instances: [Foundation.UUID: GSValue] = [:] init(sources: [AdaScriptSource], schemas: [AdaScriptObjectSchema]) throws { + let componentConstructors = AdaScriptComponentRuntime.linkedConstructors() let module = try GravityScriptModuleResolver.resolve(sources) let factoryNamesByClass = Dictionary( uniqueKeysWithValues: schemas.enumerated() @@ -431,6 +432,11 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { try virtualMachine.bindClass(with: GravityAttachedComponentView.self) try virtualMachine.bindClass(with: GravityAttachedResourceView.self) try virtualMachine.bindClass(with: AdaScriptViewBridge.self) + try AdaScriptComponentRuntime.bind( + to: virtualMachine, + constructors: componentConstructors, + reportDiagnostic: delegate.append + ) virtualMachine.setValue(AdaScriptViewBridge(), forKey: "adaUIBuilder") let factories = factoryNamesByClass @@ -448,7 +454,12 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { } .sorted() let generatedSource = (factories + getters).joined(separator: "\n") - let binary = virtualMachine.loadGravityFile(from: module.entrySource + "\n" + generatedSource) + let binary = virtualMachine.loadGravityFile( + from: AdaScriptComponentRuntime.prelude(constructors: componentConstructors) + + module.entrySource + + "\n" + + generatedSource + ) guard delegate.errors.isEmpty else { throw AdaScriptError.compilation(delegate.errors) } @@ -463,7 +474,7 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { Logger(label: "org.adaengine.AdaScript").error("\(message)") } - func instantiate(className: String, payload: [String: EditorFieldValue]) throws -> Foundation.UUID { + func instantiate(className: String, payload: [String: ReflectedFieldValue]) throws -> Foundation.UUID { try AdaScriptRuntimeCoordinator.lock.withLock { guard let factoryName = factoryNamesByClass[className] else { throw AdaScriptError.invalidManifest("Missing @scriptable factory for '\(className)'") @@ -545,8 +556,8 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { func snapshot( instanceID: Foundation.UUID, - fields: [String: EditorFieldValue].Keys - ) -> [String: EditorFieldValue] { + fields: [String: ReflectedFieldValue].Keys + ) -> [String: ReflectedFieldValue] { AdaScriptRuntimeCoordinator.lock.withLock { guard let instance = instances[instanceID] else { return [:] @@ -565,7 +576,7 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { guard getter.isClosure, let value = getter.callConstructor(with: [instance]), - let converted = AnnotatedGravityValueBridge.makeEditorFieldValue(value) + let converted = AnnotatedGravityValueBridge.makeReflectedFieldValue(value) else { return } @@ -582,7 +593,7 @@ private final class GravityScriptableModuleRuntime: @unchecked Sendable { } } - func write(instanceID: Foundation.UUID, field: String, value: EditorFieldValue) -> Bool { + func write(instanceID: Foundation.UUID, field: String, value: ReflectedFieldValue) -> Bool { AdaScriptRuntimeCoordinator.lock.withLock { guard let instance = instances[instanceID] else { return false diff --git a/Sources/AdaScripting/GravityScriptablePayload.swift b/Sources/AdaScripting/GravityScriptablePayload.swift index 72f5c0872..825fedb09 100644 --- a/Sources/AdaScripting/GravityScriptablePayload.swift +++ b/Sources/AdaScripting/GravityScriptablePayload.swift @@ -2,22 +2,22 @@ import AdaECS import Foundation enum GravityScriptablePayload { - static func decode(from decoder: Decoder) throws -> [String: EditorFieldValue] { + static func decode(from decoder: Decoder) throws -> [String: ReflectedFieldValue] { try decoder.singleValueContainer() .decode([String: CodableFieldValue].self) .mapValues(\.value) } - static func encode(_ payload: [String: EditorFieldValue], to encoder: Encoder) throws { + static func encode(_ payload: [String: ReflectedFieldValue], to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(payload.mapValues(CodableFieldValue.init)) } } private struct CodableFieldValue: Codable { - let value: EditorFieldValue + let value: ReflectedFieldValue - init(_ value: EditorFieldValue) { + init(_ value: ReflectedFieldValue) { self.value = value } diff --git a/Sources/AdaSprite/SpriteComponent.swift b/Sources/AdaSprite/SpriteComponent.swift index 8f16d362e..062b0aa22 100644 --- a/Sources/AdaSprite/SpriteComponent.swift +++ b/Sources/AdaSprite/SpriteComponent.swift @@ -33,6 +33,7 @@ public struct Sprite: Codable { /// - Parameter flipX: Flip texture horizontally /// - Parameter flipY: Flip texture vertically. /// - Parameter size: The custom size of the sprite. + @AdaScriptInit public init( texture: AssetHandle? = nil, tintColor: Color = .white, diff --git a/Sources/AdaSprite/SpritePlugin.swift b/Sources/AdaSprite/SpritePlugin.swift index c858cefb1..0c7c895a0 100644 --- a/Sources/AdaSprite/SpritePlugin.swift +++ b/Sources/AdaSprite/SpritePlugin.swift @@ -28,6 +28,7 @@ public struct SpritePlugin: Plugin { renderWorld // Sprite resources .insertResource(ExtractedSprites()) + .insertResource(AdditionalExtractedSprites()) .insertResource(SpriteDrawPass()) .insertResource(SpriteBatches()) .initResource(SpriteDrawData.self) diff --git a/Sources/AdaSprite/SpriteRenderSystem.swift b/Sources/AdaSprite/SpriteRenderSystem.swift index 51044a297..7bac412b4 100644 --- a/Sources/AdaSprite/SpriteRenderSystem.swift +++ b/Sources/AdaSprite/SpriteRenderSystem.swift @@ -49,6 +49,17 @@ public struct ExtractedSprites: Resource { } } +/// Sprite data contributed by render features that do not materialize one ECS entity per sprite. +public struct AdditionalExtractedSprites: Resource { + /// Extracted sprites keyed by their frame-local render identifier. + public var sprites: [Entity.ID: ExtractedSprite] + + /// Initialize additional extracted sprites. + public init(sprites: [Entity.ID: ExtractedSprite] = [:]) { + self.sprites = sprites + } +} + /// A sprite that contains the extracted sprite. public struct ExtractedSprite: Sendable { /// The entity id of the extracted sprite. @@ -67,6 +78,31 @@ public struct ExtractedSprite: Sendable { public var transform: Transform /// The world transform of the extracted sprite. public var worldTransform: Transform3D + /// The source entity used for per-camera visibility, or `nil` to render for every camera. + public var visibilityEntityId: Entity.ID? + + /// Initialize an extracted sprite. + public init( + entityId: Entity.ID, + texture: Texture2D?, + size: Size?, + flipX: Bool, + flipY: Bool, + tintColor: Color, + transform: Transform, + worldTransform: Transform3D, + visibilityEntityId: Entity.ID? = nil + ) { + self.entityId = entityId + self.texture = texture + self.size = size + self.flipX = flipX + self.flipY = flipY + self.tintColor = tintColor + self.transform = transform + self.worldTransform = worldTransform + self.visibilityEntityId = visibilityEntityId + } } /// A data for drawing sprites. @@ -105,7 +141,8 @@ public func ExtractSprite( flipY: sprite.flipY, tintColor: sprite.tintColor, transform: transform, - worldTransform: globalTransform.matrix + worldTransform: globalTransform.matrix, + visibilityEntityId: entity.id ) } } @@ -156,6 +193,7 @@ func PrepareSprites( _ spriteRenderPipeline: ResMut>, _ renderDevice: Res, _ extractedSprites: Res, + _ additionalSprites: Res, _ spriteDrawPass: Res ) { camera.forEach { _, entities in @@ -175,6 +213,23 @@ func PrepareSprites( ) ) } + + for sprite in additionalSprites.sprites.values { + if let visibilityEntityId = sprite.visibilityEntityId, + !entities.entityIds.contains(visibilityEntityId) { + continue + } + let pipeline = spriteRenderPipeline.wrappedValue.pipeline(device: renderDevice.renderDevice) + renderItems.items.append( + Transparent2DRenderItem( + entity: sprite.entityId, + drawPass: spriteDrawPass.wrappedValue, + renderPipeline: pipeline, + sortKey: sprite.worldTransform.w.z, + batchRange: 0..<0 + ) + ) + } } } @@ -189,6 +244,9 @@ public struct SpriteRenderSystem { @Res private var extractedSprites + @Res + private var additionalSprites + @ResMut private var spriteRenderPipeline: RenderPipelines @@ -235,7 +293,8 @@ public struct SpriteRenderSystem { } for index in renderItems.items.items.indices { - guard let sprite = extractedSprites.sprites[renderItems.items.items[index].entity] else { + let renderEntityID = renderItems.items.items[index].entity + guard let sprite = extractedSprites.sprites[renderEntityID] ?? additionalSprites.sprites[renderEntityID] else { finishCurrentBatch() currentTexture = nil batchEntityId = nil diff --git a/Sources/AdaTilemap/TileMapComponent.swift b/Sources/AdaTilemap/TileMapComponent.swift index 8b699ba08..664eaad32 100644 --- a/Sources/AdaTilemap/TileMapComponent.swift +++ b/Sources/AdaTilemap/TileMapComponent.swift @@ -6,10 +6,13 @@ // import AdaECS +import AdaRender +import AdaTransform +import AdaUtils import Math /// Component that responsible to display ``TileMap`` instance on screen. -@Component +@Component(required: [Visibility.self, BoundingComponent.self]) public struct TileMapComponent { /// Contains ``TileMap`` instance that will display on screen. public var tileMap: TileMap @@ -34,8 +37,17 @@ public struct TileMapComponent { /// The tile display size used for this component's last render. internal var lastRenderedTileDisplaySize: Size? + /// Static atlas tiles extracted directly into the render world without child ECS entities. + internal var renderedAtlasTiles: [TileMapLayer.ID: [TileMapRenderedAtlasTile]] = [:] + public init(tileMap: TileMap, tileDisplaySize: Size) { self.tileMap = tileMap self.tileDisplaySize = tileDisplaySize } } + +struct TileMapRenderedAtlasTile: Sendable { + var texture: Texture2D + var tintColor: Color + var transform: Transform +} diff --git a/Sources/AdaTilemap/TileMapPlugin.swift b/Sources/AdaTilemap/TileMapPlugin.swift index 623eafaf1..da793bc09 100644 --- a/Sources/AdaTilemap/TileMapPlugin.swift +++ b/Sources/AdaTilemap/TileMapPlugin.swift @@ -9,6 +9,7 @@ import AdaApp import AdaAssets import AdaECS import AdaPhysics +import AdaRender import AdaSprite import AdaTransform import Logging @@ -25,6 +26,53 @@ public struct TileMapPlugin: Plugin { TileEntityAtlasSource.registerTileSource() app.addSystem(TileMapSystem.self) + app.getSubworldBuilder(by: .renderWorld)? + .insertResource(AdditionalExtractedSprites()) + .addSystem(ExtractTileMapSpritesSystem.self, on: .extract) + } +} + +@PlainSystem +struct ExtractTileMapSpritesSystem { + @Extract> + private var tileMaps + + @ResMut + private var extractedSprites + + init(world _: World) {} + + func update(context _: UpdateContext) { + extractedSprites.sprites.removeAll(keepingCapacity: true) + var renderID = Int.min + + tileMaps.wrappedValue.forEach { entity, component, globalTransform in + if case .some(.hidden) = entity.components[Visibility.self] { + return + } + + for layer in component.tileMap.layers { + guard layer.isEnabled, let tiles = component.renderedAtlasTiles[layer.id] else { + continue + } + + for tile in tiles { + let entityID = renderID + renderID &+= 1 + extractedSprites.sprites[entityID] = ExtractedSprite( + entityId: entityID, + texture: tile.texture, + size: component.tileDisplaySize, + flipX: false, + flipY: false, + tintColor: tile.tintColor, + transform: tile.transform, + worldTransform: globalTransform.matrix * tile.transform.matrix, + visibilityEntityId: entity.id + ) + } + } + } } } @@ -32,7 +80,7 @@ public struct TileMapPlugin: Plugin { public struct TileMapSystem: Sendable { private let logger = Logger(label: "org.adaengine.tilemap") - @Query, Transform> + @Query, Transform, Ref> private var tileMap @Res @@ -44,7 +92,7 @@ public struct TileMapSystem: Sendable { public init(world _: World) {} public func update(context _: UpdateContext) { - tileMap.forEach { entity, tileMapComponent, transform in + tileMap.forEach { entity, tileMapComponent, transform, bounds in let tileMap = tileMapComponent.tileMap let displaySizeChanged = tileMapComponent.lastRenderedTileDisplaySize != tileMapComponent.tileDisplaySize @@ -62,6 +110,7 @@ public struct TileMapSystem: Sendable { self.removeTileRoot(rootID) } tileMapComponent.tileLayers.removeAll() + tileMapComponent.renderedAtlasTiles.removeAll() tileMapComponent.lastRenderedLayerRevisions.removeAll() } @@ -70,8 +119,12 @@ public struct TileMapSystem: Sendable { for (layerID, rootID) in removedLayers { self.removeTileRoot(rootID) tileMapComponent.tileLayers[layerID] = nil + tileMapComponent.renderedAtlasTiles[layerID] = nil tileMapComponent.lastRenderedLayerRevisions[layerID] = nil } + for layerID in tileMapComponent.renderedAtlasTiles.keys where !layerIDs.contains(layerID) { + tileMapComponent.renderedAtlasTiles[layerID] = nil + } for layer in tileMap.layers { self.addTiles( @@ -90,7 +143,39 @@ public struct TileMapSystem: Sendable { tileMapComponent.lastRenderedTileMapID = ObjectIdentifier(tileMap) tileMapComponent.lastRenderedTileMapRevision = tileMap.updateRevision tileMapComponent.lastRenderedTileDisplaySize = tileMapComponent.tileDisplaySize + bounds.bounds = .aabb(Self.bounds(for: tileMap, tileSize: tileMapComponent.tileDisplaySize)) + } + } + + private static func bounds(for tileMap: TileMap, tileSize: Size) -> AABB { + guard let firstCell = tileMap.layers.lazy.compactMap({ layer in + layer.tileCells.keys.first.map { (layer, $0) } + }).first else { + return .empty + } + + let halfWidth = tileSize.width * 0.5 + let halfHeight = tileSize.height * 0.5 + let firstCenter = Vector3( + Float(firstCell.1.x) * tileSize.width, + Float(firstCell.1.y) * tileSize.height, + Float(firstCell.0.zIndex) + ) + var minimum = firstCenter - Vector3(halfWidth, halfHeight, 0) + var maximum = firstCenter + Vector3(halfWidth, halfHeight, 0) + + for layer in tileMap.layers { + for position in layer.tileCells.keys { + let center = Vector3( + Float(position.x) * tileSize.width, + Float(position.y) * tileSize.height, + Float(layer.zIndex) + ) + minimum = min(minimum, center - Vector3(halfWidth, halfHeight, 0)) + maximum = max(maximum, center + Vector3(halfWidth, halfHeight, 0)) + } } + return AABB(min: minimum, max: maximum) } private func removeTileRoot(_ entityID: Entity.ID) { @@ -119,27 +204,13 @@ public struct TileMapSystem: Sendable { } if layer.needUpdates || forceUpdate { - if let entity = tileMapComponent.tileLayers[layer.id] { - self.removeTileRoot(entity) + if let rootID = tileMapComponent.tileLayers[layer.id] { + self.removeTileRoot(rootID) + tileMapComponent.tileLayers[layer.id] = nil } - let tileParent = Entity(name: "TileRoot<\((layer.id, layer.name))>") { - RelationshipComponent() - Transform() - } - tileParent.isActive = layer.isEnabled - _ = commands.insertEntity(tileParent) - let tileParentID = tileParent.id - let ownerID = entity.id - commands.queue.push { world in - guard - let owner = world.getEntityByID(ownerID), - let parent = world.getEntityByID(tileParentID) - else { - return - } - owner.addChild(parent) - } + var atlasTiles: [TileMapRenderedAtlasTile] = [] + var entityTiles: [Entity] = [] for (position, tile) in layer.tileCells { guard let source = tileSet.sources[tile.sourceId] else { @@ -164,17 +235,25 @@ public struct TileMapSystem: Sendable { switch source { case let atlasSource as TextureAtlasTileSource: let texture = atlasSource.getTexture(at: tile.atlasCoordinates) - - tileEntity = Entity { - Sprite( - texture: AssetHandle(texture), - tintColor: tileData.modulateColor, - size: tileSize - ) - Transform(position: position) - } if let ring = tileData.occluderPolygon, ring.count >= 3 { - tileEntity.components += LightOccluder2D(points: ring) + tileEntity = Entity { + Sprite( + texture: AssetHandle(texture), + tintColor: tileData.modulateColor, + size: tileSize + ) + Transform(position: position) + LightOccluder2D(points: ring) + } + } else { + atlasTiles.append( + TileMapRenderedAtlasTile( + texture: texture, + tintColor: tileData.modulateColor, + transform: Transform(position: position) + ) + ) + continue } case let entitySource as TileEntityAtlasSource: tileEntity = entitySource.getEntity(at: tile.atlasCoordinates) @@ -189,30 +268,45 @@ public struct TileMapSystem: Sendable { } tileEntity.isActive = layer.isEnabled + entityTiles.append(tileEntity) + } - // if tileData.useCollisition { - // tileEntity.components += Collision2DComponent( - // shapes: [.generateBox()], - // filter: CollisionFilter( - // categoryBitMask: tileData.physicLayer.collisionLayer, - // collisionBitMask: tileData.physicLayer.collisionMask - // ) - // ) - // } - - _ = commands.insertEntity(tileEntity) - let tileEntityID = tileEntity.id + tileMapComponent.renderedAtlasTiles[layer.id] = atlasTiles + + if !entityTiles.isEmpty { + let tileParent = Entity(name: "TileRoot<\((layer.id, layer.name))>") { + RelationshipComponent() + Transform() + } + tileParent.isActive = layer.isEnabled + _ = commands.insertEntity(tileParent) + let tileParentID = tileParent.id + let ownerID = entity.id commands.queue.push { world in guard - let parent = world.getEntityByID(tileParentID), - let child = world.getEntityByID(tileEntityID) + let owner = world.getEntityByID(ownerID), + let parent = world.getEntityByID(tileParentID) else { return } - parent.addChild(child) + owner.addChild(parent) + } + + for tileEntity in entityTiles { + _ = commands.insertEntity(tileEntity) + let tileEntityID = tileEntity.id + commands.queue.push { world in + guard + let parent = world.getEntityByID(tileParentID), + let child = world.getEntityByID(tileEntityID) + else { + return + } + parent.addChild(child) + } } + tileMapComponent.tileLayers[layer.id] = tileParentID } - tileMapComponent.tileLayers[layer.id] = tileParentID layer.updateDidFinish() } } diff --git a/Sources/AdaTransform/Transform.swift b/Sources/AdaTransform/Transform.swift index 2d113b1f9..c5d6c1851 100644 --- a/Sources/AdaTransform/Transform.swift +++ b/Sources/AdaTransform/Transform.swift @@ -22,6 +22,7 @@ public struct Transform: Codable, Hashable, Sendable { public var position: Vector3 /// Create a new transform component from rotation, scale and position. + @AdaScriptInit public init( rotation: Quat = .identity, scale: Vector3 = [1, 1, 1], diff --git a/Sources/AdaUI/DSL/ImageView.swift b/Sources/AdaUI/DSL/ImageView.swift index 7ae4d114c..ed83f9eca 100644 --- a/Sources/AdaUI/DSL/ImageView.swift +++ b/Sources/AdaUI/DSL/ImageView.swift @@ -72,15 +72,15 @@ extension Image { final class ImageViewNode: ViewNode { /// The texture. - let texture: Texture2D - private let slices: [(column: Int, row: Int, texture: Texture2D)] - private let sliceGrid: ImageSliceGrid? + private(set) var texture: Texture2D + private var slices: [(column: Int, row: Int, texture: Texture2D)] + private var sliceGrid: ImageSliceGrid? /// A Boolean value indicating whether the image view is resizable. - let isResizable: Bool + private(set) var isResizable: Bool /// The render mode. - let renderMode: ImageRenderMode + private(set) var renderMode: ImageRenderMode /// The tint color. - let tintColor: Color? + private(set) var tintColor: Color? init( image: Image, @@ -113,6 +113,24 @@ final class ImageViewNode: ViewNode { super.init(content: content) } + override func update(from newNode: ViewNode) { + guard let otherNode = newNode as? ImageViewNode else { + super.update(from: newNode) + return + } + + super.update(from: otherNode) + texture = otherNode.texture + slices = otherNode.slices + sliceGrid = otherNode.sliceGrid + isResizable = otherNode.isResizable + renderMode = otherNode.renderMode + tintColor = otherNode.tintColor + markNeedsLayout() + invalidateNearestLayer() + owner?.containerView?.setNeedsDisplay(in: visualAbsoluteFrame()) + } + override func sizeThatFits(_ proposal: ProposedViewSize) -> Size { if isResizable { return proposal.replacingUnspecifiedDimensions(by: Size(width: Float(texture.width), height: Float(texture.height))) diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift index 65fea35bf..b2107bc02 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Navigation.swift @@ -153,28 +153,42 @@ extension TextEditorViewNode { } func ensureCaretVisibleIfNeeded() { + let caretRect = self.caretRect() + let padding = EdgeInsets( + top: Constants.caretScrollPadding, + leading: Constants.caretScrollPadding, + bottom: Constants.caretScrollPadding, + trailing: Constants.caretScrollPadding + ) + + _ = self.nearestScrollView()?.scrollToVisibleRect(caretRect, in: self, padding: padding) + } + + func caretRect() -> Rect { let lines = self.lines() let position = self.position(forOffset: self.caretOffset, lines: lines) let pointSize = self.resolvedFontPointSize() let lineHeight = self.lineHeight(for: pointSize) - let characterAdvance = self.characterAdvance(for: pointSize) let font = self.resolvedFontForRendering() let textRect = self.textRect() let lineText = lines.indices.contains(position.line) ? lines[position.line].text : "" - let caretRect = Rect( + return Rect( x: textRect.minX + self.caretXOffset(forColumn: position.column, in: lineText, font: font, pointSize: pointSize), y: textRect.minY + Float(position.line) * lineHeight, - width: characterAdvance, + width: Constants.caretLineWidth, height: lineHeight ) - let padding = EdgeInsets( - top: Constants.caretScrollPadding, - leading: Constants.caretScrollPadding, - bottom: Constants.caretScrollPadding, - trailing: Constants.caretScrollPadding - ) + } - _ = self.nearestScrollView()?.scrollToVisibleRect(caretRect, in: self, padding: padding) + func caretViewportRect() -> Rect { + let caretRect = self.caretRect() + let contentOffset = self.nearestScrollView()?.contentOffset ?? .zero + return Rect( + x: caretRect.minX - contentOffset.x, + y: caretRect.minY - contentOffset.y, + width: caretRect.width, + height: caretRect.height + ) } func visibleLineRange(lineHeight: Float, viewportHeight: Float) -> Range { diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+SourceInteraction.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+SourceInteraction.swift index c9bc0e82f..ba1777821 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+SourceInteraction.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+SourceInteraction.swift @@ -16,6 +16,16 @@ extension TextEditorViewNode { } let localPoint = self.convertPointFromRoot(event.mousePosition) + if event.phase == .changed, event.button == .none { + let hoveredLine = gutterLine(at: localPoint) + updateHoveredGutterLine(hoveredLine) + if hoveredLine != nil { + notifySourceHover(nil) + resetSourceCursorIfNeeded() + resetTextCursorIfNeeded() + return true + } + } if let line = gutterLine(at: localPoint), let action = sourceInteraction.onGutterClick { if event.phase == .began, event.button == .left { action(line) @@ -48,6 +58,7 @@ extension TextEditorViewNode { break } } else if event.phase == .changed, event.button == .none { + updateHoveredGutterLine(nil) self.notifySourceHover(nil) self.resetSourceCursorIfNeeded() } @@ -55,6 +66,14 @@ extension TextEditorViewNode { return false } + func updateHoveredGutterLine(_ line: Int?) { + guard hoveredGutterLine != line else { + return + } + hoveredGutterLine = line + requestDisplay() + } + func gutterLine(at point: Point) -> Int? { guard showsLineNumbers, sourceInteraction?.onGutterClick != nil else { return nil @@ -83,13 +102,16 @@ extension TextEditorViewNode { self.sourceInteraction?.onSelectionChange?(nil, nil) } + let position = self.position(forOffset: self.selectionHead, lines: self.lines()) + let sourcePosition = TextEditorSourcePosition(line: position.line, column: position.column) + self.sourceInteraction?.onCaretViewportRectChange?(sourcePosition, self.caretViewportRect()) + guard requestsCompletion else { return } - let position = self.position(forOffset: self.selectionHead, lines: self.lines()) self.sourceInteraction?.onCaretChange?( - TextEditorSourcePosition(line: position.line, column: position.column), + sourcePosition, self.text ) } diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift index 71a15cc70..f0dea00bb 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode+Touch.swift @@ -54,6 +54,7 @@ extension TextEditorViewNode { } func handleTextEditorMouseLeave() { + self.updateHoveredGutterLine(nil) self.notifySourceHover(nil) self.resetSourceCursorIfNeeded() self.resetTextCursorIfNeeded() diff --git a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift index 60232d78f..b11a04d43 100644 --- a/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift +++ b/Sources/AdaUI/DSL/Nodes/TextEditorViewNode.swift @@ -68,6 +68,7 @@ final class TextEditorViewNode: ViewNode { var isSelectingWithMouse = false var isSelectingWithTouch = false var gutterTouchLine: Int? + var hoveredGutterLine: Int? var mousePressStartPoint: Point? var touchPressStartPoint: Point? var lastTapTime: AdaUtils.TimeInterval? @@ -422,6 +423,14 @@ final class TextEditorViewNode: ViewNode { color: marker.color, thickness: marker.isFilled ? 1 : 0.22 ) + } else if self.showsLineNumbers, + self.hoveredGutterLine == lineIndex, + let color = self.sourceInteraction?.gutterHoverColor { + clippedContext.drawEllipse( + in: Rect(x: contentRect.minX, y: rowY + (lineHeight - 10) * 0.5, width: 10, height: 10), + color: color, + thickness: 0.22 + ) } self.drawSourceHighlightsIfNeeded( diff --git a/Sources/AdaUI/DSL/TextEditor.swift b/Sources/AdaUI/DSL/TextEditor.swift index a241eb07b..94e9f3287 100644 --- a/Sources/AdaUI/DSL/TextEditor.swift +++ b/Sources/AdaUI/DSL/TextEditor.swift @@ -7,6 +7,7 @@ import AdaText import AdaUtils +import Math /// Colors used by a text editor primitive. public struct TextEditorColors: Hashable, Sendable { @@ -143,6 +144,7 @@ public struct TextEditorSelectionHint: Sendable { /// Optional source-aware interactions for ``TextEditor``. public struct TextEditorSourceInteraction { public var lineMarkers: [TextEditorLineMarker] + public var gutterHoverColor: Color? public var executionLine: Int? public var onGutterClick: ((Int) -> Void)? public var highlightedRanges: [TextEditorSourceRange] @@ -151,6 +153,8 @@ public struct TextEditorSourceInteraction { public var focusedRange: TextEditorSourceRange? public var onHover: ((TextEditorSourcePosition?) -> Void)? public var onPrimaryClick: ((TextEditorSourcePosition) -> Void)? + /// Called when the caret changes, with its source position and bounds in the visible editor viewport. + public var onCaretViewportRectChange: ((TextEditorSourcePosition, Rect) -> Void)? public var onCaretChange: ((TextEditorSourcePosition, String) -> Void)? public var onRequestCompletion: ((TextEditorSourcePosition, String) -> Void)? public var onMoveCompletionSelection: ((Int) -> Bool)? @@ -162,6 +166,7 @@ public struct TextEditorSourceInteraction { public init( lineMarkers: [TextEditorLineMarker] = [], + gutterHoverColor: Color? = nil, executionLine: Int? = nil, onGutterClick: ((Int) -> Void)? = nil, highlightedRanges: [TextEditorSourceRange] = [], @@ -170,6 +175,7 @@ public struct TextEditorSourceInteraction { focusedRange: TextEditorSourceRange? = nil, onHover: ((TextEditorSourcePosition?) -> Void)? = nil, onPrimaryClick: ((TextEditorSourcePosition) -> Void)? = nil, + onCaretViewportRectChange: ((TextEditorSourcePosition, Rect) -> Void)? = nil, onCaretChange: ((TextEditorSourcePosition, String) -> Void)? = nil, onRequestCompletion: ((TextEditorSourcePosition, String) -> Void)? = nil, onMoveCompletionSelection: ((Int) -> Bool)? = nil, @@ -180,6 +186,7 @@ public struct TextEditorSourceInteraction { selectionHint: TextEditorSelectionHint? = nil ) { self.lineMarkers = lineMarkers + self.gutterHoverColor = gutterHoverColor self.executionLine = executionLine self.onGutterClick = onGutterClick self.highlightedRanges = highlightedRanges @@ -188,6 +195,7 @@ public struct TextEditorSourceInteraction { self.focusedRange = focusedRange self.onHover = onHover self.onPrimaryClick = onPrimaryClick + self.onCaretViewportRectChange = onCaretViewportRectChange self.onCaretChange = onCaretChange self.onRequestCompletion = onRequestCompletion self.onMoveCompletionSelection = onMoveCompletionSelection diff --git a/Tests/AdaECSTests/EditorComponentReflectionTests.swift b/Tests/AdaECSTests/ComponentReflectionTests.swift similarity index 63% rename from Tests/AdaECSTests/EditorComponentReflectionTests.swift rename to Tests/AdaECSTests/ComponentReflectionTests.swift index 8bbc2b6c1..cd5208709 100644 --- a/Tests/AdaECSTests/EditorComponentReflectionTests.swift +++ b/Tests/AdaECSTests/ComponentReflectionTests.swift @@ -3,7 +3,7 @@ import AdaUtils import Math import Testing -private enum ReflectionMode: String, CaseIterable, EditorEnumReflectable, Codable, Sendable { +private enum ReflectionMode: String, CaseIterable, ReflectedEnum, Codable, Sendable { case idle case active } @@ -37,11 +37,11 @@ private struct ReflectedEditableComponent: Codable, Sendable { } } -@Suite("Editor component reflection") -struct EditorComponentReflectionTests { - @Test("component macro exposes editable field descriptors") +@Suite("Component reflection") +struct ComponentReflectionTests { + @Test("component macro exposes reflected field descriptors") func generatedDescriptorContainsExpectedFieldKinds() throws { - let descriptor = ReflectedEditableComponent.editorComponentDescriptor + let descriptor = ReflectedEditableComponent.componentDescriptor #expect(descriptor.typeName == String(reflecting: ReflectedEditableComponent.self)) #expect(descriptor.fields.map(\.key) == ["isEnabled", "count", "speed", "title", "position", "tint", "mode"]) @@ -50,33 +50,33 @@ struct EditorComponentReflectionTests { #expect(descriptor.fields.first { $0.key == "speed" }?.kind == .float) #expect(descriptor.fields.first { $0.key == "title" }?.kind == .string) #expect(descriptor.fields.first { $0.key == "position" }?.kind == .vector3) - #expect(descriptor.fields.first { $0.key == "position" }?.isEditable == true) + #expect(descriptor.fields.first { $0.key == "position" }?.isWritable == true) #expect(descriptor.fields.first { $0.key == "tint" }?.kind == .color) #expect(descriptor.fields.first { $0.key == "mode" }?.kind == .enumeration(["idle", "active"])) } @Test("reflection registry stores descriptors by type name") func registryLookup() throws { - let descriptor = ReflectedEditableComponent.editorComponentDescriptor - EditorComponentReflectionRegistry.register(descriptor) + let descriptor = ReflectedEditableComponent.componentDescriptor + ComponentReflectionRegistry.register(descriptor) - let registered = try #require(EditorComponentReflectionRegistry.descriptor(named: descriptor.typeName)) + let registered = try #require(ComponentReflectionRegistry.descriptor(named: descriptor.typeName)) #expect(registered.displayName == "ReflectedEditableComponent") #expect(registered.fields.map(\.key).contains("tint")) } - @Test("component registration stores generated editor descriptor") + @Test("component registration stores generated reflection descriptor") @MainActor - func componentRegistrationStoresGeneratedEditorDescriptor() throws { + func componentRegistrationStoresGeneratedReflectionDescriptor() throws { ReflectedEditableComponent.registerComponent() - let registered = try #require(EditorComponentReflectionRegistry.descriptor(named: String(reflecting: ReflectedEditableComponent.self))) + let registered = try #require(ComponentReflectionRegistry.descriptor(named: String(reflecting: ReflectedEditableComponent.self))) #expect(registered.fields.map(\.key).contains("mode")) } @Test("descriptor reads and writes component values") func descriptorReadWrite() throws { - let descriptor = ReflectedEditableComponent.editorComponentDescriptor + let descriptor = ReflectedEditableComponent.componentDescriptor let component = ReflectedEditableComponent() let payload = descriptor.readPayload(from: component) @@ -92,7 +92,7 @@ struct EditorComponentReflectionTests { @Test("descriptor writes component field back to world") func descriptorWritesToWorld() throws { - let descriptor = ReflectedEditableComponent.editorComponentDescriptor + let descriptor = ReflectedEditableComponent.componentDescriptor let world = World() let entity = world.spawn { ReflectedEditableComponent() @@ -107,12 +107,12 @@ struct EditorComponentReflectionTests { @Test("integer conversion rejects non-finite and out-of-range doubles") func integerConversionRejectsInvalidDoubles() { - #expect(EditorFieldValue.double(.nan).intValue == nil) - #expect(EditorFieldValue.double(.infinity).intValue == nil) - #expect(EditorFieldValue.double(-.infinity).intValue == nil) - #expect(EditorFieldValue.double(Double(Int.max) + 1).intValue == nil) - #expect(EditorFieldValue.double(Double(Int.min)).intValue == Int.min) - #expect(EditorFieldValue.double(42.75).intValue == 42) + #expect(ReflectedFieldValue.double(.nan).intValue == nil) + #expect(ReflectedFieldValue.double(.infinity).intValue == nil) + #expect(ReflectedFieldValue.double(-.infinity).intValue == nil) + #expect(ReflectedFieldValue.double(Double(Int.max) + 1).intValue == nil) + #expect(ReflectedFieldValue.double(Double(Int.min)).intValue == Int.min) + #expect(ReflectedFieldValue.double(42.75).intValue == 42) } @Test("floating-point conversion rejects non-finite and out-of-range values") @@ -122,14 +122,14 @@ struct EditorComponentReflectionTests { var vector = Vector3(1, 2, 3) var color = Color.white - #expect(!EditorComponentReflection.write(.double(.nan), to: &float)) - #expect(!EditorComponentReflection.write(.double(.infinity), to: &float)) - #expect(!EditorComponentReflection.write(.double(Double(Float.greatestFiniteMagnitude) * 2), to: &float)) - #expect(EditorComponentReflection.write(.double(Double(Float.greatestFiniteMagnitude)), to: &float)) - #expect(!EditorComponentReflection.write(.double(.infinity), to: &double)) - #expect(EditorComponentReflection.write(.double(Double(Float.greatestFiniteMagnitude) * 2), to: &double)) - #expect(!EditorComponentReflection.write(.array([.double(1), .double(.nan), .double(3)]), to: &vector)) - #expect(!EditorComponentReflection.write(.array([.double(1), .double(1), .double(1), .double(.infinity)]), to: &color)) + #expect(!ComponentReflection.write(.double(.nan), to: &float)) + #expect(!ComponentReflection.write(.double(.infinity), to: &float)) + #expect(!ComponentReflection.write(.double(Double(Float.greatestFiniteMagnitude) * 2), to: &float)) + #expect(ComponentReflection.write(.double(Double(Float.greatestFiniteMagnitude)), to: &float)) + #expect(!ComponentReflection.write(.double(.infinity), to: &double)) + #expect(ComponentReflection.write(.double(Double(Float.greatestFiniteMagnitude) * 2), to: &double)) + #expect(!ComponentReflection.write(.array([.double(1), .double(.nan), .double(3)]), to: &vector)) + #expect(!ComponentReflection.write(.array([.double(1), .double(1), .double(1), .double(.infinity)]), to: &color)) #expect(float.isFinite) #expect(double.isFinite) #expect(vector == Vector3(1, 2, 3)) diff --git a/Tests/AdaECSTests/DynamicQueryTests.swift b/Tests/AdaECSTests/DynamicQueryTests.swift index f0a9dbce2..66eca23c3 100644 --- a/Tests/AdaECSTests/DynamicQueryTests.swift +++ b/Tests/AdaECSTests/DynamicQueryTests.swift @@ -29,7 +29,7 @@ struct DynamicQueryTests { query.update(from: world) let descriptor = try #require( - DynamicQueryPosition.editorComponentDescriptor.fields.first { $0.key == "value" } + DynamicQueryPosition.componentDescriptor.fields.first { $0.key == "value" } ) let cursor = query.wrappedValue.makeCursor() var visited = 0 diff --git a/Tests/AdaECSTests/RuntimeComponentConstructorPerformanceTests.swift b/Tests/AdaECSTests/RuntimeComponentConstructorPerformanceTests.swift new file mode 100644 index 000000000..6333e3d72 --- /dev/null +++ b/Tests/AdaECSTests/RuntimeComponentConstructorPerformanceTests.swift @@ -0,0 +1,59 @@ +import AdaECS +import Foundation +import Math +import Testing + +@Suite("Runtime component constructor performance") +struct RuntimeComponentConstructorPerformanceTests { + @Test("Generated constructor avoids reflection lookup overhead") + func generatedConstructorProbe() throws { + guard ProcessInfo.processInfo.environment["ADAENGINE_RUN_PERF_PROBES"] == "1" else { + return + } + + let iterations = 100_000 + let arguments: [ReflectedFieldValue?] = [ + .array([.double(4), .double(5), .double(6)]), + .int(7), + ] + let descriptor = RuntimeConstructorBenchmarkProbe.runtimeComponentConstructor + let reflectedFields = RuntimeConstructorBenchmarkProbe.componentDescriptor.fields + var generatedChecksum = 0 + let generatedDuration = ContinuousClock().measure { + for _ in 0..()) + renderWorld.insertResource(SortedRenderItems()) + renderWorld.insertResource(RenderPipelines(configurator: SpriteRenderPipeline())) + renderWorld.insertResource(SpriteDrawPass()) + renderWorld.insertResource(SpriteBatches()) + renderWorld.insertResource(SpriteDrawData.defaultValue) + renderWorld.addSystem(ClearTransparent2dRenderItemsSystem.self, on: .extract) + renderWorld.addSystem(ExtractTileMapSpritesSystem.self, on: .extract) + renderWorld.addSystem(PrepareSpritesSystem.self, on: .preUpdate) + renderWorld.addSystem(Transparent2DBatchingSystem.self, on: .batching) + renderWorld.addSystem(SpriteRenderSystem.self, on: .update) + renderWorld.spawn { + Camera() + VisibleEntities(entityIds: [owner.id]) + } + await renderWorld.runScheduler(.extract) + + #expect(renderWorld.getResource(AdditionalExtractedSprites.self)?.sprites.count == 2) + await renderWorld.runScheduler(.preUpdate) + #expect(renderWorld.getResource(RenderItems.self)?.items.count == 2) + await renderWorld.runScheduler(.batching) + await renderWorld.runScheduler(.update) + #expect(renderWorld.getResource(SpriteBatches.self)?.batches.count == 1) + #expect(renderWorld.getResource(SpriteDrawData.self)?.vertexBuffer.count == 8) + #expect(renderWorld.getResource(SpriteDrawData.self)?.indexBuffer.count == 12) + } + @Test func explicitTileSourceIDsAdvanceAutomaticIDs() { let tileSet = TileSet() @@ -361,7 +417,18 @@ struct TileMapTests { } private static func root(in owner: Entity) -> Entity? { - guard let rootID = rootID(for: owner) else { return nil } + guard let rootID = rootID(for: owner) else { + return nil + } return owner.world?.getEntityByID(rootID) } + + private static func setupHeadlessRenderEngineIfNeeded() throws { + guard unsafe RenderEngine.shared == nil else { + return + } + + unsafe RenderEngine.configurations.preferredBackend = .headless + try RenderEngine.setupRenderEngine() + } } diff --git a/Tests/AdaMultiplayerTests/AdaMultiplayerTests.swift b/Tests/AdaMultiplayerTests/AdaMultiplayerTests.swift new file mode 100644 index 000000000..0f7eaf51d --- /dev/null +++ b/Tests/AdaMultiplayerTests/AdaMultiplayerTests.swift @@ -0,0 +1,357 @@ +import AdaApp +import AdaECS +@testable import AdaMultiplayer +import Foundation +import Testing + +@Component +private struct TestPosition: Codable, Equatable, Sendable { + var x: Int +} + +@Component +private struct LocalOnlyState: Codable, Equatable, Sendable { + var secret: Int +} + +private struct MoveCommand: NetworkCommand, Equatable { + static let networkIdentifier = "tests.move" + var x: Int +} + +private struct PingRequest: NetworkRequest { + static let networkIdentifier = "tests.ping" + typealias Response = String + var value: String +} + +private struct CapturedCommands: Resource { + var values: [MoveCommand] = [] +} + +@PlainSystem +struct CaptureCommandsSystem { + @RemoteCommands + private var commands + + @ResMut + private var captured + + init(world _: World) {} + + func update(context _: UpdateContext) async { + captured.values.append(contentsOf: commands.map(\.value)) + } +} + +@PlainSystem +struct RespondToPingSystem { + @RemoteRequests + private var requests + + init(world _: World) {} + + func update(context _: UpdateContext) async { + for request in requests { + try? await request.responder.respond("pong:" + request.value.value) + } + } +} + +private struct TestNetworkingPlugin: Plugin { + func setup(in app: borrowing AppWorlds) { + app + .registerReplicatedComponent(TestPosition.self, id: "tests.position") + .registerNetworkCommand(MoveCommand.self) + .registerNetworkRequest(PingRequest.self) + .insertResource(CapturedCommands()) + .addSystem(CaptureCommandsSystem.self, on: .update) + .addSystem(RespondToPingSystem.self, on: .update) + } +} + +@Suite("AdaMultiplayer") +@MainActor +struct AdaMultiplayerTests { + @Test("binary envelope rejects invalid data") + func wireEnvelope() throws { + let original = NetworkFrame(kind: .event, sequence: 42, payload: Data([1, 2, 3])) + let encoded = try NetworkWireCodec.encode(original) + let decoded = try NetworkWireCodec.decode(encoded) + + #expect(decoded.kind == .event) + #expect(decoded.sequence == 42) + #expect(decoded.payload == Data([1, 2, 3])) + #expect(throws: MultiplayerError.invalidPayload) { + try NetworkWireCodec.decode(Data([0, 1, 2])) + } + } + + @Test("cloud relay routing uses scalar UUID fields") + func cloudRelayWireShape() throws { + let session = UUID() + let peer = UUID() + let hello = CloudRelayHello( + ticket: "ticket", + sessionID: session, + peerID: peer, + role: .peer + ) + let object = try #require( + JSONSerialization.jsonObject(with: JSONEncoder().encode(hello)) as? [String: Any] + ) + #expect(object["sessionID"] as? String == session.uuidString) + #expect(object["peerID"] as? String == peer.uuidString) + + let control = try JSONDecoder().decode( + CloudRelayControl.self, + from: Data("{\"kind\":\"connected\",\"peerID\":\"\(peer.uuidString)\"}".utf8) + ) + #expect(control.peerID == peer) + } + + @Test("peer can only route traffic through host") + func transportTopology() async throws { + let hub = InMemoryTransportHub() + let hostID = PeerID() + let peerID = PeerID() + let sessionID = SessionID() + let host = InMemoryTransport(hub: hub) + let peer = InMemoryTransport(hub: hub) + let hostEvents = await host.eventStream() + + try await host.start(configuration: .init(role: .host, sessionID: sessionID, localPeerID: hostID)) + try await peer.start(configuration: .init(role: .peer, sessionID: sessionID, localPeerID: peerID)) + try await peer.send(Data([7]), to: .host) + + var iterator = hostEvents.makeAsyncIterator() + #expect(await iterator.next().isConnected(to: peerID)) + #expect(await iterator.next().isPayload(Data([7]), source: peerID)) + await #expect(throws: MultiplayerError.invalidDirection) { + try await peer.send(Data(), to: .allPeers) + } + } + + @Test("marker replicates only registered components") + func markerReplication() async throws { + let hub = InMemoryTransportHub() + let sessionID = SessionID() + let compatibility = NetworkCompatibility( + gameIdentifier: "tests", + buildIdentifier: "1" + ) + let host = try await makeApp( + role: .host, + sessionID: sessionID, + compatibility: compatibility, + transport: InMemoryTransport(hub: hub) + ) + let peer = try await makeApp( + role: .peer, + sessionID: sessionID, + compatibility: compatibility, + transport: InMemoryTransport(hub: hub) + ) + + await exchangeHandshake(host: host, peer: peer) + host.main.spawn("Player") { + ReplicatedEntity() + TestPosition(x: 10) + LocalOnlyState(secret: 42) + } + await host.main.runScheduler(.networkSend) + await peer.main.runScheduler(.networkReceive) + + let entities = Array(peer.main.performQuery(EntityQuery(where: .has(TestPosition.self)))) + let replica = try #require(entities.first) + #expect(replica.name == "Player") + #expect(peer.main.get(TestPosition.self, from: replica.id) == TestPosition(x: 10)) + #expect(peer.main.get(LocalOnlyState.self, from: replica.id) == nil) + } + + @Test("delta covers update, removal, and despawn") + func deltaLifecycle() async throws { + let (host, peer) = try await makeConnectedApps() + let entity = host.main.spawn("Player") { + ReplicatedEntity() + TestPosition(x: 1) + } + await sendSnapshot(host: host, peer: peer) + let replica = try #require( + Array(peer.main.performQuery(EntityQuery(where: .has(TestPosition.self)))).first + ) + + host.main.insert(TestPosition(x: 2), for: entity.id) + await sendSnapshot(host: host, peer: peer) + #expect(peer.main.get(TestPosition.self, from: replica.id) == TestPosition(x: 2)) + + host.main.remove(TestPosition.self, from: entity.id) + await sendSnapshot(host: host, peer: peer) + #expect(peer.main.get(TestPosition.self, from: replica.id) == nil) + + host.main.removeEntity(entity) + await sendSnapshot(host: host, peer: peer) + #expect(peer.main.getEntityByID(replica.id) == nil) + } + + @Test("typed command and request response use the host") + func rpcRoundTrip() async throws { + let (host, peer) = try await makeConnectedApps() + let peerSession = try #require(peer.main.getResource(MultiplayerSession.self)) + + try await peerSession.sendCommand(MoveCommand(x: 9)) + await host.main.runScheduler(.networkReceive) + await host.main.runScheduler(.update) + #expect(host.main.getResource(CapturedCommands.self)?.values == [MoveCommand(x: 9)]) + + let response = Task { try await peerSession.request(PingRequest(value: "hello")) } + try await Task.sleep(for: .milliseconds(10)) + await host.main.runScheduler(.networkReceive) + await host.main.runScheduler(.update) + try await Task.sleep(for: .milliseconds(10)) + await peer.main.runScheduler(.networkReceive) + #expect(try await response.value == "pong:hello") + } + + @Test("AdaScript bridge routes detached commands and authoritative snapshots") + func adaScriptBridgeRoundTrip() async throws { + let hub = InMemoryTransportHub() + let sessionID = SessionID() + let hostID = PeerID() + let peerID = PeerID() + let compatibility = NetworkCompatibility(gameIdentifier: "script-tests", buildIdentifier: "1") + let host = try await makeScriptBridgeApp( + configuration: MultiplayerConfiguration( + role: .host, + sessionID: sessionID, + localPeerID: hostID, + compatibility: compatibility + ), + transport: InMemoryTransport(hub: hub) + ) + let peer = try await makeScriptBridgeApp( + configuration: MultiplayerConfiguration( + role: .peer, + sessionID: sessionID, + localPeerID: peerID, + compatibility: compatibility + ), + transport: InMemoryTransport(hub: hub) + ) + await exchangeHandshake(host: host, peer: peer) + + peer.main.getRefResource(AdaScriptMultiplayerState.self).wrappedValue.outgoingCommand = [ + .double(0.75), .double(-0.25), .int(3), + ] + peer.main.getRefResource(AdaScriptMultiplayerState.self).wrappedValue.outgoingCommandSequence = 1 + await peer.main.runScheduler(.networkSend) + await host.main.runScheduler(.networkReceive) + + let commands = host.main.getResource(AdaScriptMultiplayerState.self)?.receivedCommands + #expect(commands == [ + .array([ + .string(peerID.rawValue.uuidString), + .int(1), + .array([.double(0.75), .double(-0.25), .int(3)]), + ]), + ]) + + host.main.getRefResource(AdaScriptMultiplayerState.self).wrappedValue.publishedSnapshot = [ + .string(peerID.rawValue.uuidString), .double(12), .double(8), .int(2), + ] + host.main.getRefResource(AdaScriptMultiplayerState.self).wrappedValue.publishedSnapshotSequence = 1 + await host.main.runScheduler(.networkSend) + await peer.main.runScheduler(.networkReceive) + + #expect(peer.main.getResource(AdaScriptMultiplayerState.self)?.receivedSnapshot == [ + .string(peerID.rawValue.uuidString), .double(12), .double(8), .int(2), + ]) + } + + private func makeApp( + role: NetworkRole, + sessionID: SessionID, + compatibility: NetworkCompatibility, + transport: any MultiplayerTransport + ) async throws -> AppWorlds { + let app = AppWorlds(main: World(name: role.rawValue)) + app + .addPlugin( + MultiplayerPlugin( + configuration: MultiplayerConfiguration( + role: role, + sessionID: sessionID, + compatibility: compatibility, + snapshotsPerSecond: 1_000 + ), + transport: transport + ) + ) + .addPlugin(TestNetworkingPlugin()) + try await app.build() + return app + } + + private func makeScriptBridgeApp( + configuration: MultiplayerConfiguration, + transport: any MultiplayerTransport + ) async throws -> AppWorlds { + let app = AppWorlds(main: World(name: configuration.role.rawValue)) + app + .addPlugin(MultiplayerPlugin(configuration: configuration, transport: transport)) + .addPlugin(AdaScriptMultiplayerBridgePlugin(configuration: configuration)) + try await app.build() + return app + } + + private func exchangeHandshake(host: AppWorlds, peer: AppWorlds) async { + await host.main.runScheduler(.networkReceive) + await peer.main.runScheduler(.networkReceive) + await host.main.runScheduler(.networkReceive) + await peer.main.runScheduler(.networkReceive) + await host.main.runScheduler(.networkReceive) + } + + private func makeConnectedApps() async throws -> (AppWorlds, AppWorlds) { + let hub = InMemoryTransportHub() + let sessionID = SessionID() + let compatibility = NetworkCompatibility(gameIdentifier: "tests", buildIdentifier: "1") + let host = try await makeApp( + role: .host, + sessionID: sessionID, + compatibility: compatibility, + transport: InMemoryTransport(hub: hub) + ) + let peer = try await makeApp( + role: .peer, + sessionID: sessionID, + compatibility: compatibility, + transport: InMemoryTransport(hub: hub) + ) + await exchangeHandshake(host: host, peer: peer) + return (host, peer) + } + + private func sendSnapshot(host: AppWorlds, peer: AppWorlds) async { + try? await Task.sleep(for: .milliseconds(2)) + await host.main.runScheduler(.networkSend) + await peer.main.runScheduler(.networkReceive) + await peer.main.runScheduler(.networkInterpolate) + } +} + +private extension MultiplayerTransportEvent? { + func isConnected(to peer: PeerID) -> Bool { + guard case let .connected(value) = self else { + return false + } + return value == peer + } + + func isPayload(_ payload: Data, source: PeerID) -> Bool { + guard case let .received(valueSource, valuePayload) = self else { + return false + } + return valueSource == source && valuePayload == payload + } +} diff --git a/Tests/AdaScriptingTests/AdaScriptSchemaParserTests.swift b/Tests/AdaScriptingTests/AdaScriptSchemaParserTests.swift index 46d754c45..e39cec90d 100644 --- a/Tests/AdaScriptingTests/AdaScriptSchemaParserTests.swift +++ b/Tests/AdaScriptingTests/AdaScriptSchemaParserTests.swift @@ -118,7 +118,7 @@ struct AdaScriptSchemaParserTests { @system class CleanupSystem { func update(context) { - context.world.commands.despawn(42); + context.world.spawn([]); } } diff --git a/Tests/AdaScriptingTests/GravityResourceBindingTests.swift b/Tests/AdaScriptingTests/GravityResourceBindingTests.swift index 3352847ed..c37c043a4 100644 --- a/Tests/AdaScriptingTests/GravityResourceBindingTests.swift +++ b/Tests/AdaScriptingTests/GravityResourceBindingTests.swift @@ -70,21 +70,21 @@ struct GravityResourceBindingTests { } @safe - private static let gravityField = unsafe EditorComponentFieldDescriptor( + private static let gravityField = unsafe ReflectedComponentField( key: "gravity", label: "gravity", kind: .float, - isEditable: true, - accepts: { EditorComponentReflection.accepts($0, for: Double.self) }, + isWritable: true, + accepts: { ComponentReflection.accepts($0, for: Double.self) }, read: { _ in nil }, write: { _, _ in nil }, readPointer: { pointer in let resource = unsafe pointer.assumingMemoryBound(to: ScriptBalance.self) - return EditorComponentReflection.read(unsafe resource.pointee.gravity) + return ComponentReflection.read(unsafe resource.pointee.gravity) }, writePointer: { pointer, value in let resource = unsafe pointer.assumingMemoryBound(to: ScriptBalance.self) - return unsafe EditorComponentReflection.write(value, to: &resource.pointee.gravity) + return unsafe ComponentReflection.write(value, to: &resource.pointee.gravity) } ) } diff --git a/Tests/AdaScriptingTests/GravityScriptableObjectTests.swift b/Tests/AdaScriptingTests/GravityScriptableObjectTests.swift index 495d33f77..27ac7c595 100644 --- a/Tests/AdaScriptingTests/GravityScriptableObjectTests.swift +++ b/Tests/AdaScriptingTests/GravityScriptableObjectTests.swift @@ -129,21 +129,21 @@ struct GravityScriptableObjectTests { } @safe - private static let resourceValueField = unsafe EditorComponentFieldDescriptor( + private static let resourceValueField = unsafe ReflectedComponentField( key: "value", label: "value", kind: .float, - isEditable: true, - accepts: { EditorComponentReflection.accepts($0, for: Double.self) }, + isWritable: true, + accepts: { ComponentReflection.accepts($0, for: Double.self) }, read: { _ in nil }, write: { _, _ in nil }, readPointer: { pointer in let resource = unsafe pointer.assumingMemoryBound(to: ScriptableBoundResource.self) - return EditorComponentReflection.read(unsafe resource.pointee.value) + return ComponentReflection.read(unsafe resource.pointee.value) }, writePointer: { pointer, value in let resource = unsafe pointer.assumingMemoryBound(to: ScriptableBoundResource.self) - return unsafe EditorComponentReflection.write(value, to: &resource.pointee.value) + return unsafe ComponentReflection.write(value, to: &resource.pointee.value) } ) } diff --git a/Tests/AdaScriptingTests/GravityWorldCommandsTests.swift b/Tests/AdaScriptingTests/GravityWorldCommandsTests.swift index 75871f5db..b45ff0e81 100644 --- a/Tests/AdaScriptingTests/GravityWorldCommandsTests.swift +++ b/Tests/AdaScriptingTests/GravityWorldCommandsTests.swift @@ -1,6 +1,7 @@ @testable import AdaApp import AdaECS import AdaScripting +import Math import Testing @Suite("Gravity world commands", .serialized) @@ -65,6 +66,60 @@ struct GravityWorldCommandsTests { #expect(world.get(CommandExtra.self, from: spawned.id) == nil) } + @Test("Spawns initialized component values through the world facade") + @MainActor + func spawnsInitializedComponents() async throws { + registerComponents() + let plugin = try AdaScriptPlugin( + source: """ + @system(id: "typed.spawn.system") + class TypedSpawnSystem { + func update(context) { + context.world.spawn([ + CommandConfigured(position: Vector3(4, 5, 6), count: 7) + ]); + } + } + """, + name: "TypedDeferredSpawn" + ) + let world = World(name: "Typed deferred spawn") + + plugin.setup(in: AppWorlds(main: world)) + await world.runScheduler(.update) + + let spawned = try #require(world.getEntities().first) + let component = try #require(world.get(CommandConfigured.self, from: spawned.id)) + #expect(plugin.diagnostics.isEmpty) + #expect(component.position == Vector3(4, 5, 6)) + #expect(component.count == 7) + } + + @Test("Supports Vector3.ZERO in component constructors") + @MainActor + func supportsVectorZero() async throws { + registerComponents() + let plugin = try AdaScriptPlugin( + source: """ + @system(id: "zero.spawn.system") + class ZeroSpawnSystem { + func update(context) { + context.world.spawn([CommandConfigured(position: Vector3.ZERO)]); + } + } + """, + name: "ZeroDeferredSpawn" + ) + let world = World(name: "Zero deferred spawn") + + plugin.setup(in: AppWorlds(main: world)) + await world.runScheduler(.update) + + let spawned = try #require(world.getEntities().first) + #expect(plugin.diagnostics.isEmpty) + #expect(world.get(CommandConfigured.self, from: spawned.id)?.position == .zero) + } + @Test("Rejects a retained commands capability after its callback") @MainActor func rejectsRetainedCapability() async throws { @@ -140,6 +195,12 @@ struct GravityWorldCommandsTests { names: ["CommandExtra", "test.extra"], makeDefault: { CommandExtra() } ) + RuntimeTypeRegistry.registerComponent( + CommandConfigured.self, + names: ["CommandConfigured"], + makeDefault: { CommandConfigured() } + ) + ComponentReflectionRegistry.register(CommandConfigured.componentDescriptor) } } @@ -151,3 +212,9 @@ private struct CommandSpawned {} @Component private struct CommandExtra {} + +@Component +private struct CommandConfigured { + var position: Vector3 = .zero + var count: Int = 0 +} diff --git a/Tests/AdaScriptingTests/MedievalArenaScriptTests.swift b/Tests/AdaScriptingTests/MedievalArenaScriptTests.swift new file mode 100644 index 000000000..666ccad9e --- /dev/null +++ b/Tests/AdaScriptingTests/MedievalArenaScriptTests.swift @@ -0,0 +1,209 @@ +import AdaApp +import AdaECS +@_spi(Internal) import AdaInput +import AdaMultiplayer +import AdaRender +import AdaScripting +import AdaSprite +import AdaTransform +import Foundation +import Testing +import Yams + +@Suite("Medieval Arena AdaScript boundary", .serialized) +struct MedievalArenaScriptTests { + @Test("Gameplay is authored in the project, not engine or editor") + @MainActor + func gameplayBelongsToAdaScriptProject() throws { + Self.registerRuntimeTypes() + #expect(Transform.runtimeComponentConstructor.parameters.map(\.name) == ["rotation", "scale", "position"]) + #expect(Sprite.runtimeComponentConstructor.parameters.map(\.name) == ["tintColor", "flipX", "flipY"]) + try AdaScriptPlugin.validate( + sources: [ + AdaScriptSource( + path: "BridgeProbe.ada", + source: """ + @system(id: "bridge.probe") + class BridgeProbeSystem { + @res var multiplayer: AdaScriptMultiplayerState; + func update(context) {} + } + """ + ) + ], + name: "BridgeProbe" + ) + let gameRoot = Self.repositoryRoot.appendingPathComponent("Demos/MedievalArena", isDirectory: true) + let sourceRoot = gameRoot.appendingPathComponent("Sources", isDirectory: true) + let sourceURLs = try FileManager.default.contentsOfDirectory( + at: sourceRoot, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "ada" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + let sources = try sourceURLs.map { + AdaScriptSource(path: $0.lastPathComponent, source: try String(contentsOf: $0, encoding: .utf8)) + } + let gameSource = sources.map(\.source).joined(separator: "\n") + + #expect(sourceURLs.map(\.lastPathComponent) == [ + "ArenaGameplay.ada", + "ArenaInput.ada", + "ArenaPresentation.ada", + "ArenaState.ada", + ]) + #expect(gameSource.contains("class ArenaGameplaySystem")) + #expect(gameSource.contains("func applyAttack")) + #expect(gameSource.contains("target[2] -= 1")) + #expect(gameSource.contains("class ArenaPresentationSystem")) + #expect(gameSource.contains("ArenaGame.swords")) + try AdaScriptPlugin.validate(sources: sources, name: "MedievalArenaGame") + + for relativeDirectory in ["Sources", "Editor/Sources"] { + let url = Self.repositoryRoot.appendingPathComponent(relativeDirectory, isDirectory: true) + let enumerator = try #require(FileManager.default.enumerator(at: url, includingPropertiesForKeys: nil)) + let swiftSources = try enumerator.compactMap { value -> String? in + guard let fileURL = value as? URL, fileURL.pathExtension == "swift" else { + return nil + } + return try String(contentsOf: fileURL, encoding: .utf8) + } + let nativeSource = swiftSources.joined(separator: "\n") + #expect(!nativeSource.contains("ArenaGameplaySystem")) + #expect(!nativeSource.contains("ArenaPlayerState")) + #expect(!nativeSource.contains("ArenaSwordEffect")) + #expect(!nativeSource.contains("setupArenaContent")) + } + } + + @Test("Scene owns one populated TileMap and explicit spawn markers") + func sceneContainsAuthoredArena() throws { + struct ArenaScene: Decodable { + struct Entity: Decodable { + struct Components: Decodable { + struct TileMap: Decodable { + var cells: [[Int]] + } + + var tileMap: TileMap? + + enum CodingKeys: String, CodingKey { + case tileMap = "AdaTilemap.TileMapComponent" + } + } + + var components: Components? + var id: String + } + + var entities: [Entity] + } + + let sceneURL = Self.repositoryRoot + .appendingPathComponent("Demos/MedievalArena/Assets/Scenes/Main.ascn") + let scene = try YAMLDecoder().decode( + ArenaScene.self, + from: String(contentsOf: sceneURL, encoding: .utf8) + ) + let cells = try #require( + scene.entities.first { $0.id == "arena-tilemap" }?.components?.tileMap?.cells + ) + let entityIDs = Set(scene.entities.map(\.id)) + + #expect(cells.count == 19 * 11) + #expect(entityIDs.contains("host-spawn")) + #expect(entityIDs.contains("peer-spawn")) + #expect(scene.entities.count == 5) + } + + @Test("Project owns its input bindings") + func projectOwnsInputBindings() throws { + struct ProjectInput: Decodable { + var inputActions: [InputAction] + } + let projectURL = Self.repositoryRoot + .appendingPathComponent("Demos/MedievalArena/.ada/project.json") + let project = try JSONDecoder().decode(ProjectInput.self, from: Data(contentsOf: projectURL)) + + #expect(project.inputActions.map(\.name) == ["MoveUp", "MoveDown", "MoveLeft", "MoveRight", "Attack"]) + #expect(project.inputActions.last?.bindings == [.key(.space)]) + } + + @MainActor + @Test("Host gameplay executes through the pure AdaScript systems") + func hostGameplayExecutes() async throws { + if unsafe RenderEngine.shared == nil { + unsafe RenderEngine.configurations.preferredBackend = .headless + RenderWorldPlugin().setup(in: AppWorlds(main: World(name: "MedievalArenaRenderSetup"))) + } + Self.registerRuntimeTypes() + + let world = World(name: "MedievalArenaScriptHost") + let app = AppWorlds(main: world) + InputPlugin(actions: [InputAction(name: "Attack", bindings: [.key(.space)])]).setup(in: app) + world.insertResource(DeltaTime(deltaTime: 1.0 / 60.0)) + let peerUUID = try #require(UUID(uuidString: "4D415045-4552-4000-8000-000000000001")) + world.insertResource( + AdaScriptMultiplayerState( + role: .host, + localPeerID: PeerID(rawValue: peerUUID) + ) + ) + let plugin = try AdaScriptPlugin(sources: try Self.gameSources(), name: "MedievalArenaGame") + plugin.setup(in: app) + world.getRefResource(Input.self).wrappedValue.receiveEvent( + KeyEvent( + window: .empty, + keyCode: .space, + modifiers: [], + status: .down, + time: 0, + isRepeated: false + ) + ) + + for _ in 0..<40 { + await world.runScheduler(.preUpdate) + await world.runScheduler(.update) + await world.runScheduler(.postUpdate) + } + + #expect(plugin.diagnostics.isEmpty, Comment(rawValue: plugin.diagnostics.joined(separator: "\n"))) + // Four persistent visuals are required. A fifth entity can be the + // short-lived sword presentation when parallel input tests overlap. + #expect((4...5).contains(world.getEntities().count)) + #expect(world.getResource(AdaScriptMultiplayerState.self)?.publishedSnapshot.count == 8) + } + + @MainActor + private static func registerRuntimeTypes() { + RuntimeTypeRegistry.registerComponent( + Transform.self, + names: ["Transform"], + makeDefault: { Transform() } + ) + RuntimeTypeRegistry.registerComponent( + Sprite.self, + names: ["Sprite"], + makeDefault: { Sprite(texture: Texture2D.whiteTexture) } + ) + ComponentReflectionRegistry.register(Transform.componentDescriptor) + ComponentReflectionRegistry.register(Sprite.componentDescriptor) + AdaScriptMultiplayerState.registerRuntimeType() + } + + private static var repositoryRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private static func gameSources() throws -> [AdaScriptSource] { + let sourceRoot = repositoryRoot.appendingPathComponent("Demos/MedievalArena/Sources", isDirectory: true) + return try FileManager.default.contentsOfDirectory(at: sourceRoot, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "ada" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .map { AdaScriptSource(path: $0.lastPathComponent, source: try String(contentsOf: $0, encoding: .utf8)) } + } +} diff --git a/Tests/AdaSpriteTests/SpriteRenderSystemTests.swift b/Tests/AdaSpriteTests/SpriteRenderSystemTests.swift index 4064e3737..3dd7076aa 100644 --- a/Tests/AdaSpriteTests/SpriteRenderSystemTests.swift +++ b/Tests/AdaSpriteTests/SpriteRenderSystemTests.swift @@ -40,6 +40,28 @@ struct SpriteRenderSystemTests { #expect(items.items.first?.sortKey == 12) } + @Test("Additional extracted sprites render without backing ECS entities") + func additionalExtractedSpritesArePrepared() async throws { + try Self.setupHeadlessRenderEngineIfNeeded() + Camera.registerComponent() + VisibleEntities.registerComponent() + let renderID = Int.min + let sprite = Self.extractedSprite(id: renderID, texture: .whiteTexture) + let world = try Self.makeRenderWorld(extractedSprites: [:], items: []) + world.insertResource(AdditionalExtractedSprites(sprites: [renderID: sprite])) + world.insertResource(RenderItems()) + world.addSystem(PrepareSpritesSystem.self, on: .preUpdate) + world.spawn { + Camera() + VisibleEntities() + } + + await world.runScheduler(.preUpdate) + + let items = try #require(world.getResource(RenderItems.self)) + #expect(items.items.map(\.entity) == [renderID]) + } + @Test("A non-sprite item separates sprite batches") func nonSpriteItemsSeparateSpriteBatches() async throws { try Self.setupHeadlessRenderEngineIfNeeded() @@ -225,6 +247,7 @@ struct SpriteRenderSystemTests { let world = World() world .insertResource(ExtractedSprites(sprites: SparseSet(extractedSprites))) + .insertResource(AdditionalExtractedSprites()) .insertResource(SortedRenderItems(items: RenderItems(items: items))) .insertResource(SpriteDrawPass()) .insertResource(SpriteBatches()) diff --git a/Tests/AdaUITests/DebuggerGutterTests.swift b/Tests/AdaUITests/DebuggerGutterTests.swift index 414d77adb..df41373d5 100644 --- a/Tests/AdaUITests/DebuggerGutterTests.swift +++ b/Tests/AdaUITests/DebuggerGutterTests.swift @@ -48,6 +48,34 @@ struct EditorDebuggerGutterTests { #expect(node.sourceInteraction?.lineMarkers.first?.line == 1) } + @Test func mouseHoverShowsProspectiveBreakpointLine() throws { + var text = "first\nsecond\nthird" + let tester = ViewTester { + TextEditor( + text: Binding(get: { text }, set: { text = $0 }), + sourceInteraction: .init(gutterHoverColor: .red.opacity(0.4), onGutterClick: { _ in }) + ) + .font(.system(size: 12)) + .frame(width: 360, height: 160) + }.setSize(Size(width: 380, height: 180)).performLayout() + let node = try #require(tester.click(at: Point(20, 28)) as? TextEditorViewNode) + let lineHeight = node.lineHeight(for: node.resolvedFontPointSize()) + let gutterPoint = Point( + node.visualAbsoluteFrame().minX + node.textContentRect().minX + 5, + node.visualAbsoluteFrame().minY + node.textContentRect().minY + lineHeight * 1.5 + ) + + tester.sendMouseEvent(at: gutterPoint, button: .none, phase: .changed) + #expect(node.hoveredGutterLine == 1) + + let textPoint = Point( + node.visualAbsoluteFrame().minX + node.textRect().minX + 20, + gutterPoint.y + ) + tester.sendMouseEvent(at: textPoint, button: .none, phase: .changed) + #expect(node.hoveredGutterLine == nil) + } + @Test func touchTogglesOnlyOnReleaseAndCancellationDoesNothing() throws { var text = "first\nsecond" var clicked: [Int] = [] diff --git a/Tests/AdaUITests/ImageSkinTests.swift b/Tests/AdaUITests/ImageSkinTests.swift index 93b93baff..7c6eedaa9 100644 --- a/Tests/AdaUITests/ImageSkinTests.swift +++ b/Tests/AdaUITests/ImageSkinTests.swift @@ -58,6 +58,22 @@ struct ImageSkinTests { #expect(textures.allSatisfy { $0.atlas === first.atlas }) } + @Test func imageNodeReplacesTextureDuringViewUpdates() { + let firstImage = Image(width: 16, height: 16, color: .red) + let secondImage = Image(width: 16, height: 16, color: .blue) + let node = ImageViewNode(image: firstImage, isResizable: false, renderMode: .original, tintColor: nil, content: firstImage) + let replacement = ImageViewNode(image: secondImage, isResizable: true, renderMode: .template, tintColor: .green, content: secondImage) + let firstTexture = node.texture + + node.update(from: replacement) + + #expect(node.texture === replacement.texture) + #expect(node.texture !== firstTexture) + #expect(node.isResizable) + #expect(node.renderMode == .template) + #expect(node.tintColor == .green) + } + @Test func statePriorityAndFallback() { let style = TextureButtonStyle(normal: Image(width: 1, height: 1), highlighted: Image(width: 2, height: 1), pressed: Image(width: 3, height: 1), disabled: Image(width: 4, height: 1)) #expect(style.image(for: .normal).width == 1) diff --git a/Tests/AdaUITests/TextEditorTests.swift b/Tests/AdaUITests/TextEditorTests.swift index af0bec8b4..fdccad3cc 100644 --- a/Tests/AdaUITests/TextEditorTests.swift +++ b/Tests/AdaUITests/TextEditorTests.swift @@ -282,6 +282,53 @@ struct TextEditorTests { #expect(range == node.lines().count.. 0) + #expect(reportedPosition == TextEditorSourcePosition(line: 50, column: 4)) + #expect(abs(viewportRect.minX - (contentRect.minX - scrollOffset.x)) < 0.01) + #expect(abs(viewportRect.minY - (contentRect.minY - scrollOffset.y)) < 0.01) + #expect(viewportRect.minY >= 0) + #expect(viewportRect.maxY <= 160) + } + @Test func textEditor_reusesVisibleGlyphLayoutsAcrossScrollFrames() throws { final class Model {