diff --git a/internal/gameassets/download.go b/internal/gameassets/download.go new file mode 100644 index 0000000..87a7c45 --- /dev/null +++ b/internal/gameassets/download.go @@ -0,0 +1,58 @@ +package gameassets + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "time" +) + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +// DownloadFile fetches a URL and writes the response body to destPath. +// Only HTTPS URLs are allowed. The response must be 200 OK with a non-empty body. +func DownloadFile(ctx context.Context, rawURL, destPath string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + if parsed.Scheme != "https" { + return fmt.Errorf("only HTTPS URLs are allowed, got %q", parsed.Scheme) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned HTTP %d for %s", resp.StatusCode, rawURL) + } + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer out.Close() + + n, err := io.Copy(out, resp.Body) + if err != nil { + os.Remove(destPath) + return fmt.Errorf("failed to write file: %w", err) + } + if n == 0 { + os.Remove(destPath) + return fmt.Errorf("downloaded file is empty") + } + + return nil +} diff --git a/internal/gameassets/placement.go b/internal/gameassets/placement.go new file mode 100644 index 0000000..0a59671 --- /dev/null +++ b/internal/gameassets/placement.go @@ -0,0 +1,166 @@ +package gameassets + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +// PlaceImageAsset copies srcPath into Assets.xcassets/.imageset/ +// and writes the corresponding Contents.json. It converts non-PNG images +// to PNG using macOS sips. Returns the relative path to the placed file. +func PlaceImageAsset(projectDir, appName, assetName, srcPath string) (string, error) { + assetName = SanitizeAssetName(assetName) + imagesetDir := filepath.Join(projectDir, appName, "Assets.xcassets", assetName+".imageset") + if err := os.MkdirAll(imagesetDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create imageset directory: %w", err) + } + + dstName := assetName + ".png" + dstPath := filepath.Join(imagesetDir, dstName) + + if err := convertToPNG(srcPath, dstPath); err != nil { + return "", fmt.Errorf("failed to convert image: %w", err) + } + + contentsJSON := ImageSetContentsJSON(dstName) + contentsPath := filepath.Join(imagesetDir, "Contents.json") + if err := os.WriteFile(contentsPath, contentsJSON, 0o644); err != nil { + return "", fmt.Errorf("failed to write Contents.json: %w", err) + } + + relPath := filepath.Join(appName, "Assets.xcassets", assetName+".imageset", dstName) + return relPath, nil +} + +// PlaceSoundAsset copies srcPath into /Sounds/.. +// Returns the relative path to the placed file. +func PlaceSoundAsset(projectDir, appName, assetName, srcPath string) (string, error) { + assetName = SanitizeAssetName(assetName) + soundsDir := filepath.Join(projectDir, appName, "Sounds") + if err := os.MkdirAll(soundsDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create Sounds directory: %w", err) + } + + ext := filepath.Ext(srcPath) + if ext == "" { + ext = ".wav" + } + dstName := assetName + ext + dstPath := filepath.Join(soundsDir, dstName) + + if err := copyFile(srcPath, dstPath); err != nil { + return "", fmt.Errorf("failed to copy sound file: %w", err) + } + + relPath := filepath.Join(appName, "Sounds", dstName) + return relPath, nil +} + +// PlaceModelAsset copies srcPath into /Models/.. +// SceneKit loads USDZ/OBJ/DAE/SCN files natively — no conversion needed. +// Returns the relative path to the placed file. +func PlaceModelAsset(projectDir, appName, assetName, srcPath string) (string, error) { + assetName = SanitizeAssetName(assetName) + modelsDir := filepath.Join(projectDir, appName, "Models") + if err := os.MkdirAll(modelsDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create Models directory: %w", err) + } + + ext := filepath.Ext(srcPath) + if ext == "" { + ext = ".usdz" + } + dstName := assetName + ext + dstPath := filepath.Join(modelsDir, dstName) + + if err := copyFile(srcPath, dstPath); err != nil { + return "", fmt.Errorf("failed to copy model file: %w", err) + } + + relPath := filepath.Join(appName, "Models", dstName) + return relPath, nil +} + +// ImageSetContentsJSON returns a standard Xcode imageset Contents.json +// for a universal idiom with the given filename. +func ImageSetContentsJSON(filename string) []byte { + contents := map[string]any{ + "images": []map[string]string{ + { + "filename": filename, + "idiom": "universal", + }, + }, + "info": map[string]any{ + "version": 1, + "author": "xcode", + }, + } + data, _ := json.MarshalIndent(contents, "", " ") + return data +} + +// SanitizeAssetName converts a name to a valid Xcode asset catalog name. +// Lowercase, spaces/underscores to hyphens, strip non-alphanumeric except hyphens. +func SanitizeAssetName(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + name = strings.ReplaceAll(name, " ", "-") + name = strings.ReplaceAll(name, "_", "-") + re := regexp.MustCompile(`[^a-z0-9\-]`) + name = re.ReplaceAllString(name, "") + // Collapse multiple hyphens + re = regexp.MustCompile(`-{2,}`) + name = re.ReplaceAllString(name, "-") + name = strings.Trim(name, "-") + if name == "" { + name = "asset" + } + return name +} + +// convertToPNG converts an image file to PNG using macOS sips. +// If the source is already PNG, it copies directly. +func convertToPNG(src, dst string) error { + ext := strings.ToLower(filepath.Ext(src)) + if ext == ".png" { + return copyFile(src, dst) + } + + // Strip xattrs that can cause sips to reject the file + _ = exec.Command("xattr", "-c", src).Run() + + cmd := exec.Command("sips", "-s", "format", "png", src, "--out", dst) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("sips conversion failed: %s: %w", string(out), err) + } + + info, err := os.Stat(dst) + if err != nil || info.Size() == 0 { + return fmt.Errorf("sips output file missing or empty after conversion") + } + return nil +} + +func copyFile(src, dst string) error { + if src == dst { + return nil + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/internal/gameassets/placement_test.go b/internal/gameassets/placement_test.go new file mode 100644 index 0000000..16a2b54 --- /dev/null +++ b/internal/gameassets/placement_test.go @@ -0,0 +1,196 @@ +package gameassets + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestSanitizeAssetName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"paddle", "paddle"}, + {"Ping Pong Ball", "ping-pong-ball"}, + {"my_asset_name", "my-asset-name"}, + {" spaces ", "spaces"}, + {"UPPER", "upper"}, + {"special!@#chars", "specialchars"}, + {"multi---hyphens", "multi-hyphens"}, + {"-leading-trailing-", "leading-trailing"}, + {"", "asset"}, + {" ", "asset"}, + {"hello world 123", "hello-world-123"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := SanitizeAssetName(tt.input) + if got != tt.want { + t.Errorf("SanitizeAssetName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestImageSetContentsJSON(t *testing.T) { + data := ImageSetContentsJSON("paddle.png") + + var contents struct { + Images []struct { + Filename string `json:"filename"` + Idiom string `json:"idiom"` + } `json:"images"` + Info struct { + Version int `json:"version"` + Author string `json:"author"` + } `json:"info"` + } + + if err := json.Unmarshal(data, &contents); err != nil { + t.Fatalf("failed to unmarshal Contents.json: %v", err) + } + + if len(contents.Images) != 1 { + t.Fatalf("expected 1 image entry, got %d", len(contents.Images)) + } + if contents.Images[0].Filename != "paddle.png" { + t.Errorf("filename = %q, want %q", contents.Images[0].Filename, "paddle.png") + } + if contents.Images[0].Idiom != "universal" { + t.Errorf("idiom = %q, want %q", contents.Images[0].Idiom, "universal") + } + if contents.Info.Version != 1 { + t.Errorf("version = %d, want 1", contents.Info.Version) + } + if contents.Info.Author != "xcode" { + t.Errorf("author = %q, want %q", contents.Info.Author, "xcode") + } +} + +func TestPlaceImageAsset(t *testing.T) { + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "project") + appName := "MyGame" + + // Create a fake PNG source file. + srcDir := filepath.Join(tmpDir, "src") + os.MkdirAll(srcDir, 0o755) + srcPath := filepath.Join(srcDir, "test.png") + os.WriteFile(srcPath, []byte("fake-png-data"), 0o644) + + relPath, err := PlaceImageAsset(projectDir, appName, "paddle", srcPath) + if err != nil { + t.Fatalf("PlaceImageAsset failed: %v", err) + } + + expectedRel := filepath.Join(appName, "Assets.xcassets", "paddle.imageset", "paddle.png") + if relPath != expectedRel { + t.Errorf("relPath = %q, want %q", relPath, expectedRel) + } + + // Verify the file was placed. + placedPath := filepath.Join(projectDir, relPath) + if _, err := os.Stat(placedPath); err != nil { + t.Errorf("placed file does not exist: %v", err) + } + + // Verify Contents.json was written. + contentsPath := filepath.Join(projectDir, appName, "Assets.xcassets", "paddle.imageset", "Contents.json") + contentsData, err := os.ReadFile(contentsPath) + if err != nil { + t.Fatalf("Contents.json not found: %v", err) + } + if len(contentsData) == 0 { + t.Error("Contents.json is empty") + } +} + +func TestPlaceModelAsset(t *testing.T) { + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "project") + appName := "MyGame" + + // Create a fake USDZ source file. + srcDir := filepath.Join(tmpDir, "src") + os.MkdirAll(srcDir, 0o755) + srcPath := filepath.Join(srcDir, "car.usdz") + os.WriteFile(srcPath, []byte("fake-usdz-data"), 0o644) + + relPath, err := PlaceModelAsset(projectDir, appName, "race-car", srcPath) + if err != nil { + t.Fatalf("PlaceModelAsset failed: %v", err) + } + + expectedRel := filepath.Join(appName, "Models", "race-car.usdz") + if relPath != expectedRel { + t.Errorf("relPath = %q, want %q", relPath, expectedRel) + } + + // Verify the file was placed. + placedPath := filepath.Join(projectDir, relPath) + if _, err := os.Stat(placedPath); err != nil { + t.Errorf("placed file does not exist: %v", err) + } + + // Verify the content was copied correctly. + data, err := os.ReadFile(placedPath) + if err != nil { + t.Fatalf("failed to read placed file: %v", err) + } + if string(data) != "fake-usdz-data" { + t.Errorf("placed file content = %q, want %q", string(data), "fake-usdz-data") + } +} + +func TestPlaceModelAssetDefaultExt(t *testing.T) { + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "project") + appName := "MyGame" + + // Create a source file without extension. + srcDir := filepath.Join(tmpDir, "src") + os.MkdirAll(srcDir, 0o755) + srcPath := filepath.Join(srcDir, "model") + os.WriteFile(srcPath, []byte("fake-model-data"), 0o644) + + relPath, err := PlaceModelAsset(projectDir, appName, "spaceship", srcPath) + if err != nil { + t.Fatalf("PlaceModelAsset failed: %v", err) + } + + expectedRel := filepath.Join(appName, "Models", "spaceship.usdz") + if relPath != expectedRel { + t.Errorf("relPath = %q, want %q", relPath, expectedRel) + } +} + +func TestPlaceSoundAsset(t *testing.T) { + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "project") + appName := "MyGame" + + // Create a fake WAV source file. + srcDir := filepath.Join(tmpDir, "src") + os.MkdirAll(srcDir, 0o755) + srcPath := filepath.Join(srcDir, "hit.wav") + os.WriteFile(srcPath, []byte("fake-wav-data"), 0o644) + + relPath, err := PlaceSoundAsset(projectDir, appName, "hit-sound", srcPath) + if err != nil { + t.Fatalf("PlaceSoundAsset failed: %v", err) + } + + expectedRel := filepath.Join(appName, "Sounds", "hit-sound.wav") + if relPath != expectedRel { + t.Errorf("relPath = %q, want %q", relPath, expectedRel) + } + + // Verify the file was placed. + placedPath := filepath.Join(projectDir, relPath) + if _, err := os.Stat(placedPath); err != nil { + t.Errorf("placed file does not exist: %v", err) + } +} diff --git a/internal/mcpregistry/nanowave_tools.go b/internal/mcpregistry/nanowave_tools.go index 341ba4a..d1bdd4d 100644 --- a/internal/mcpregistry/nanowave_tools.go +++ b/internal/mcpregistry/nanowave_tools.go @@ -10,12 +10,14 @@ func NanowaveTools() Server { "mcp__nanowave-tools__nw_setup_workspace", "mcp__nanowave-tools__nw_enrich_workspace", "mcp__nanowave-tools__nw_scaffold_project", + "mcp__nanowave-tools__nw_get_skills", "mcp__nanowave-tools__nw_verify_files", "mcp__nanowave-tools__nw_xcode_build", "mcp__nanowave-tools__nw_capture_screenshots", "mcp__nanowave-tools__nw_finalize_project", "mcp__nanowave-tools__nw_project_info", "mcp__nanowave-tools__nw_validate_platform", + "mcp__nanowave-tools__nw_download_asset", }, } } diff --git a/internal/nwtool/gameassets_tool.go b/internal/nwtool/gameassets_tool.go new file mode 100644 index 0000000..5baf53d --- /dev/null +++ b/internal/nwtool/gameassets_tool.go @@ -0,0 +1,131 @@ +package nwtool + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/moasq/nanowave/internal/gameassets" +) + +func registerGameAssetTools(r *Registry) { + r.Register(downloadAssetTool()) +} + +func downloadAssetTool() *Tool { + return &Tool{ + Name: "nw_download_asset", + Description: `Download an asset from a URL and place it in the Xcode project. +For images (sprite/background/texture): placed in Assets.xcassets as a named image set. + Use via SKTexture(imageNamed: "name") in SpriteKit or Image("name") in SwiftUI. + Non-PNG images are automatically converted to PNG. +For sounds: placed in /Sounds/ directory. + Use via Bundle.main.url(forResource: "name", withExtension: "wav"). +For 3D models (model): placed in /Models/ directory. + Use via SCNScene(named: "name.usdz") in SceneKit. + Supports USDZ, OBJ, DAE, SCN formats — no conversion needed. +Supports PNG, JPEG, SVG, WAV, MP3, AIFF, CAF, USDZ, OBJ, DAE, SCN. Only HTTPS URLs are allowed.`, + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "project_dir": {"type": "string", "description": "Absolute path to the project directory"}, + "app_name": {"type": "string", "description": "PascalCase app name"}, + "url": {"type": "string", "description": "HTTPS URL to download the asset from"}, + "asset_name": {"type": "string", "description": "Name for the asset in the project (e.g. 'paddle', 'ball', 'hit-sound', 'car-model')"}, + "asset_kind": {"type": "string", "enum": ["sprite", "background", "texture", "sound", "model"], "description": "How the asset will be used. sprite/background/texture go to Assets.xcassets. sound goes to Sounds/. model goes to Models/ (USDZ/OBJ/DAE/SCN for SceneKit)."} + }, + "required": ["project_dir", "app_name", "url", "asset_name", "asset_kind"] +}`), + Handler: handleDownloadAsset, + } +} + +func handleDownloadAsset(ctx context.Context, input json.RawMessage) (json.RawMessage, error) { + var in struct { + ProjectDir string `json:"project_dir"` + AppName string `json:"app_name"` + URL string `json:"url"` + AssetName string `json:"asset_name"` + AssetKind string `json:"asset_kind"` + } + if err := json.Unmarshal(input, &in); err != nil { + return jsonError(fmt.Sprintf("invalid input: %v", err)) + } + + if in.ProjectDir == "" || in.AppName == "" || in.URL == "" || in.AssetName == "" || in.AssetKind == "" { + return jsonError("all fields are required: project_dir, app_name, url, asset_name, asset_kind") + } + + // Download to a temp file. + tmpFile, err := os.CreateTemp("", "nw-asset-*") + if err != nil { + return jsonError(fmt.Sprintf("failed to create temp file: %v", err)) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + + if err := gameassets.DownloadFile(ctx, in.URL, tmpPath); err != nil { + return jsonError(fmt.Sprintf("download failed: %v", err)) + } + + sanitizedName := gameassets.SanitizeAssetName(in.AssetName) + + switch in.AssetKind { + case "sprite", "background", "texture": + relPath, err := gameassets.PlaceImageAsset(in.ProjectDir, in.AppName, sanitizedName, tmpPath) + if err != nil { + return jsonError(fmt.Sprintf("failed to place image asset: %v", err)) + } + return jsonOK(map[string]any{ + "success": true, + "file_path": relPath, + "asset_name": sanitizedName, + "usage_spritekit": fmt.Sprintf("SKTexture(imageNamed: %q)", sanitizedName), + "usage_swiftui": fmt.Sprintf("Image(%q)", sanitizedName), + }) + + case "sound": + relPath, err := gameassets.PlaceSoundAsset(in.ProjectDir, in.AppName, sanitizedName, tmpPath) + if err != nil { + return jsonError(fmt.Sprintf("failed to place sound asset: %v", err)) + } + ext := filepath.Ext(relPath) + if ext != "" { + ext = ext[1:] // strip leading dot + } + return jsonOK(map[string]any{ + "success": true, + "file_path": relPath, + "asset_name": sanitizedName, + "usage_spritekit": fmt.Sprintf( + "SKAction.playSoundFileNamed(%q, waitForCompletion: false)", + sanitizedName+"."+ext, + ), + "usage_bundle": fmt.Sprintf( + "Bundle.main.url(forResource: %q, withExtension: %q)", + sanitizedName, ext, + ), + }) + + case "model": + relPath, err := gameassets.PlaceModelAsset(in.ProjectDir, in.AppName, sanitizedName, tmpPath) + if err != nil { + return jsonError(fmt.Sprintf("failed to place model asset: %v", err)) + } + ext := filepath.Ext(relPath) + modelFile := sanitizedName + ext + return jsonOK(map[string]any{ + "success": true, + "file_path": relPath, + "asset_name": sanitizedName, + "usage_scenekit": fmt.Sprintf("SCNScene(named: %q)", modelFile), + "usage_modelio": fmt.Sprintf("MDLAsset(url: Bundle.main.url(forResource: %q, withExtension: %q)!)", sanitizedName, ext[1:]), + }) + + default: + return jsonError(fmt.Sprintf("unknown asset_kind %q — use sprite, background, texture, sound, or model", in.AssetKind)) + } +} diff --git a/internal/nwtool/tools.go b/internal/nwtool/tools.go index f53eb4b..b60a9f3 100644 --- a/internal/nwtool/tools.go +++ b/internal/nwtool/tools.go @@ -30,6 +30,7 @@ func NewDefaultRegistry() *Registry { registerXcodeGenTools(r) registerAppleDocsTool(r) registerIntegrationTools(r) + registerGameAssetTools(r) return r } diff --git a/internal/orchestration/integration_golden_test.go b/internal/orchestration/integration_golden_test.go index c591699..74cb6d7 100644 --- a/internal/orchestration/integration_golden_test.go +++ b/internal/orchestration/integration_golden_test.go @@ -68,7 +68,7 @@ func TestGolden_WriteMCPConfig_NoIntegrations(t *testing.T) { if err != nil { t.Fatalf("read .mcp.json: %v", err) } - // Normalize to stable JSON + // Normalize to stable JSON and replace the resolved binary path with "nanowave" got := normalizeJSON(t, data) assertGolden(t, "mcp_config_none", got) } diff --git a/internal/orchestration/phase_prompts.go b/internal/orchestration/phase_prompts.go index 46d9cdb..335523f 100644 --- a/internal/orchestration/phase_prompts.go +++ b/internal/orchestration/phase_prompts.go @@ -3,6 +3,8 @@ package orchestration import ( "fmt" "io/fs" + "os" + "sort" "strings" "github.com/moasq/nanowave/internal/skills" @@ -88,11 +90,14 @@ STEP 1 — CREATE THE XCODE PROJECT (do this FIRST, before anything else): d. Create Assets.xcassets with AppIcon.appiconset and AccentColor.colorset e. Run: cd && xcodegen generate -STEP 2 — WRITE ALL SWIFT FILES: - Follow the architecture and AppTheme rules from the system prompt. +STEP 2 — LOAD SKILLS: + Call nw_get_skills with list_available:true. Review the returned keys and load ALL skills relevant to the app you are building. Skills provide architecture patterns, framework guidance, and tool instructions that may override defaults. Do this BEFORE writing any Swift files. + +STEP 3 — WRITE ALL SWIFT FILES: + Follow the architecture and AppTheme rules from the system prompt, UNLESS a loaded skill specifies an override. File structure: AppName/App/, AppName/Theme/, AppName/Models/, AppName/Features/FeatureName/ -STEP 3 — BUILD: +STEP 4 — BUILD: If the project uses SPM packages, first resolve them separately (this can take several minutes): xcodebuild -project MyApp.xcodeproj -scheme MyApp -resolvePackageDependencies Then build: @@ -100,13 +105,13 @@ STEP 3 — BUILD: IMPORTANT: Use a timeout of at least 600 seconds (10 minutes) for build commands — SPM resolution and compilation of large packages like Lottie can be slow. Fix any errors and rebuild until it succeeds. -STEP 4 — SCREENSHOT & REVIEW (iOS only): +STEP 5 — SCREENSHOT & REVIEW (iOS only): Build for simulator, boot sim, install, launch, capture screenshot, review UI, fix issues. -STEP 5 — FINALIZE: +STEP 6 — FINALIZE: git init && git add -A && git commit -m "Initial commit" -IMPORTANT: Do NOT spend time searching for MCP tools, reading other projects, or exploring the filesystem. Go directly to Step 1.`, catalogRoot) +IMPORTANT: Do NOT read other projects or explore the filesystem. Go directly to Step 1.`, catalogRoot) // Add platform-specific build guidance switch { @@ -157,7 +162,7 @@ VISIONOS BUILD NOTES: appendPromptSection(&b, "Build Workflow — MANDATORY", buildWorkflow) } - skillsHint := `Feature-specific skills (camera, authentication, media, charts, widgets, navigation-patterns, etc.) are available via the nw_get_skills tool. Call nw_get_skills with list_available:true to discover all available skills. Load relevant skills BEFORE implementing features. + skillsHint := `Skills provide architecture guidance, framework-specific patterns, and tool usage instructions for specialized features. ALWAYS call nw_get_skills with list_available:true to discover available skills, then load ALL relevant skills BEFORE writing any code. Skills may override default architecture patterns (e.g. a skill may specify a different app structure than standard MVVM). When the user pastes images, determine whether each image is a design reference (visual guide) or an asset to embed in the app (icon, logo, background). For assets, call nw_get_skills with key "user-assets" for step-by-step integration instructions.` if platform != PlatformIOS { @@ -194,11 +199,18 @@ When the user pastes images, determine whether each image is a design reference } appendPromptSection(&b, "Edit Context", editCtx) } else if catalogRoot != "" { - appendPromptSection(&b, "Project Location", fmt.Sprintf( + locationHint := fmt.Sprintf( `CRITICAL: Create the project directory inside %[1]s. For example, if the app is called MyApp, create it at %[1]s/MyApp/. Do NOT create projects anywhere else. WARNING: The working directory %[1]s may contain other project directories from previous builds. Do NOT read, browse, or reference any existing directories. Start fresh — create your own new project directory and work exclusively inside it.`, - catalogRoot)) + catalogRoot) + + // List existing project names so the agent avoids collisions + if existing := listExistingProjectNames(catalogRoot); len(existing) > 0 { + locationHint += "\n\nThese project names are already taken — you MUST choose a different name: " + strings.Join(existing, ", ") + } + + appendPromptSection(&b, "Project Location", locationHint) } return b.String() @@ -366,3 +378,19 @@ func loadCoreRulesWithDiagnostics(platform string) (string, PromptDiagnostics) { diag.CoreRulesChars = b.Len() return b.String(), diag } + +// listExistingProjectNames returns sorted directory names in catalogRoot. +func listExistingProjectNames(catalogRoot string) []string { + entries, err := os.ReadDir(catalogRoot) + if err != nil { + return nil + } + var names []string + for _, e := range entries { + if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names +} diff --git a/internal/orchestration/phase_prompts_test.go b/internal/orchestration/phase_prompts_test.go index 993c7a4..8a614d7 100644 --- a/internal/orchestration/phase_prompts_test.go +++ b/internal/orchestration/phase_prompts_test.go @@ -84,3 +84,21 @@ func TestComposeAgenticSystemPromptNoIntegrationsMentionsSetup(t *testing.T) { t.Fatal("expected setup instruction in system prompt") } } + +func TestComposeAgenticSystemPromptIncludesSpriteKitRuleForGames(t *testing.T) { + ac := ActionContext{} + prompt := ComposeAgenticSystemPrompt(ac, "/tmp/projects") + + if !strings.Contains(prompt, "For 2D games") { + t.Fatal("expected 2D game planning rule in system prompt") + } + if !strings.Contains(prompt, "SpriteKit with SpriteView") { + t.Fatal("expected SpriteKit requirement in system prompt") + } + if !strings.Contains(prompt, "2D game apps use SpriteKit scenes embedded via SpriteView") { + t.Fatal("expected game architecture rule in system prompt") + } + if !strings.Contains(prompt, "3D game apps use SceneKit scenes embedded via SceneView") { + t.Fatal("expected 3D game architecture rule in system prompt") + } +} diff --git a/internal/orchestration/prompts.go b/internal/orchestration/prompts.go index 75c2d7f..b30caf4 100644 --- a/internal/orchestration/prompts.go +++ b/internal/orchestration/prompts.go @@ -30,6 +30,8 @@ const planningConstraints = `PLATFORM & SCOPE: - visionOS only if user EXPLICITLY mentions Vision Pro, visionOS, spatial, or Apple Vision. - macOS only if user EXPLICITLY mentions Mac, macOS, desktop app, or Mac app. - iPadOS / universal only if user EXPLICITLY mentions iPad, iPadOS, or universal. +- For 2D games (arcade, sports, puzzle, platformer, shooter, 2D racing): use SpriteKit with SpriteView for gameplay, not plain SwiftUI views, Canvas, or timer loops. +- For 3D games (racing, 3D sports, board games, marble maze, bowling, tower defense): use SceneKit with SceneView, not SpriteKit. - Apple frameworks preferred. SPM packages allowed when they provide a significantly better experience than native frameworks alone (e.g. complex animations, rich media processing, advanced UI effects). No external services. No API keys/secrets. - All functionality must work 100% offline using local data and on-device frameworks UNLESS the user explicitly requests cloud/backend/multi-device features. - Build the minimum product that matches user intent. User wording overrides defaults.` @@ -37,6 +39,7 @@ const planningConstraints = `PLATFORM & SCOPE: // sharedConstraints provides cross-phase safety and architecture guardrails. const sharedConstraints = `ARCHITECTURE: - App structure: @main App -> RootView -> MainView -> content. NEVER embed feature views directly in the @main App body. Always create RootView.swift and MainView.swift as intermediary layers. +- 2D game apps use SpriteKit scenes embedded via SpriteView for gameplay. 3D game apps use SceneKit scenes embedded via SceneView. SwiftUI is for app shell, menus, HUD, and settings. - Apple frameworks + approved SPM packages. No external services, external AI SDKs, or secrets. - App-wide settings (@AppStorage) must be wired at the root app level. - User-requested styling overrides defaults. @@ -113,4 +116,3 @@ func composeSelfCheck(platform string) string { } return base } - diff --git a/internal/orchestration/testdata/agentic_tools_none.golden b/internal/orchestration/testdata/agentic_tools_none.golden index 5be3db2..10c3273 100644 --- a/internal/orchestration/testdata/agentic_tools_none.golden +++ b/internal/orchestration/testdata/agentic_tools_none.golden @@ -24,9 +24,11 @@ mcp__xcodegen__regenerate_project mcp__nanowave-tools__nw_setup_workspace mcp__nanowave-tools__nw_enrich_workspace mcp__nanowave-tools__nw_scaffold_project +mcp__nanowave-tools__nw_get_skills mcp__nanowave-tools__nw_verify_files mcp__nanowave-tools__nw_xcode_build mcp__nanowave-tools__nw_capture_screenshots mcp__nanowave-tools__nw_finalize_project mcp__nanowave-tools__nw_project_info mcp__nanowave-tools__nw_validate_platform +mcp__nanowave-tools__nw_download_asset diff --git a/internal/orchestration/testdata/settings_shared_none.golden b/internal/orchestration/testdata/settings_shared_none.golden index 271ad09..41d3f54 100644 --- a/internal/orchestration/testdata/settings_shared_none.golden +++ b/internal/orchestration/testdata/settings_shared_none.golden @@ -20,12 +20,14 @@ "mcp__nanowave-tools__nw_setup_workspace", "mcp__nanowave-tools__nw_enrich_workspace", "mcp__nanowave-tools__nw_scaffold_project", + "mcp__nanowave-tools__nw_get_skills", "mcp__nanowave-tools__nw_verify_files", "mcp__nanowave-tools__nw_xcode_build", "mcp__nanowave-tools__nw_capture_screenshots", "mcp__nanowave-tools__nw_finalize_project", "mcp__nanowave-tools__nw_project_info", "mcp__nanowave-tools__nw_validate_platform", + "mcp__nanowave-tools__nw_download_asset", "SlashCommand", "Task", "ViewImage", diff --git a/internal/orchestration/testdata/settings_shared_supabase.golden b/internal/orchestration/testdata/settings_shared_supabase.golden index 42c0cc0..165f928 100644 --- a/internal/orchestration/testdata/settings_shared_supabase.golden +++ b/internal/orchestration/testdata/settings_shared_supabase.golden @@ -20,12 +20,14 @@ "mcp__nanowave-tools__nw_setup_workspace", "mcp__nanowave-tools__nw_enrich_workspace", "mcp__nanowave-tools__nw_scaffold_project", + "mcp__nanowave-tools__nw_get_skills", "mcp__nanowave-tools__nw_verify_files", "mcp__nanowave-tools__nw_xcode_build", "mcp__nanowave-tools__nw_capture_screenshots", "mcp__nanowave-tools__nw_finalize_project", "mcp__nanowave-tools__nw_project_info", "mcp__nanowave-tools__nw_validate_platform", + "mcp__nanowave-tools__nw_download_asset", "SlashCommand", "Task", "ViewImage", diff --git a/internal/skills/data/features/game-assets/SKILL.md b/internal/skills/data/features/game-assets/SKILL.md new file mode 100644 index 0000000..37dfde1 --- /dev/null +++ b/internal/skills/data/features/game-assets/SKILL.md @@ -0,0 +1,225 @@ +--- +name: "game-assets" +description: "Download free game sprites/textures/3D models and generate procedural assets. Covers nw_download_asset tool, texture factories, sprite atlas organization, 3D model loading, and programmatic asset creation." +--- +# Game Asset Management + +## Downloading Assets + +Use `nw_download_asset` to download sprites, backgrounds, textures, sounds, and 3D models from free CC0 sources: + +``` +nw_download_asset: + project_dir: /path/to/project + app_name: MyGame + url: https://example.com/sprite.png (HTTPS only) + asset_name: paddle (becomes Assets.xcassets/paddle.imageset/) + asset_kind: sprite | background | texture | sound | model +``` + +After download, use in SpriteKit: +```swift +let texture = SKTexture(imageNamed: "paddle") +let sprite = SKSpriteNode(texture: texture) +``` + +After download, use in SceneKit (3D models): +```swift +let scene = SCNScene(named: "car.usdz")! +let modelNode = scene.rootNode.childNodes.first! +``` + +Trusted free sources: +- **2D**: Kenney.nl (CC0), OpenGameArt.org (mixed CC0/CC-BY), GitHub game asset repos +- **3D**: Sketchfab (CC0/CC-BY USDZ/OBJ), Poly Pizza (CC0), Kenney.nl 3D assets (CC0 OBJ/DAE) + +## Procedural Asset Generation + +Generate sprites in code — no external files needed: + +```swift +import UIKit +import SpriteKit + +class TextureFactory { + static func circle(diameter: CGFloat, color: UIColor) -> SKTexture { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter)) + let image = renderer.image { ctx in + color.setFill() + ctx.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: diameter, height: diameter)) + } + return SKTexture(image: image) + } + + static func roundedRect(size: CGSize, color: UIColor, cornerRadius: CGFloat = 8) -> SKTexture { + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + color.setFill() + UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: cornerRadius).fill() + } + return SKTexture(image: image) + } + + static func gradient(size: CGSize, colors: [UIColor]) -> SKTexture { + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + let cgColors = colors.map { $0.cgColor } as CFArray + let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: cgColors, locations: nil)! + ctx.cgContext.drawLinearGradient(gradient, + start: .zero, end: CGPoint(x: 0, y: size.height), + options: []) + } + return SKTexture(image: image) + } +} +``` + +**Always use SKSpriteNode with generated textures** — SKShapeNode is significantly slower and drops FPS. Convert shapes: +```swift +let shape = SKShapeNode(circleOfRadius: 15) +shape.fillColor = .white +let texture = view.texture(from: shape)! +let sprite = SKSpriteNode(texture: texture) // Use this instead of shape +``` + +## Asset Catalog Organization + +``` +Assets.xcassets/ +├── AppIcon.appiconset/ +├── AccentColor.colorset/ +├── Game/ +│ ├── player.imageset/ (individual game sprites) +│ ├── enemy.imageset/ +│ ├── background.imageset/ +│ └── UI_Atlas.spriteatlas/ (grouped UI sprites for performance) +``` + +Rules: +- Group related sprites into `.spriteatlas` folders for rendering performance +- Split atlases by scene/level — do NOT put everything in one atlas +- Keep texture dimensions in powers of 2 when possible (256, 512, 1024) +- Cache generated textures — do not regenerate each frame + +## Sound Effects — Procedural Generation + +Generate WAV data in memory — no audio files needed: + +```swift +func generateTone(frequency: Float, duration: Float, volume: Float = 0.5) -> AVAudioPlayer? { + let sampleRate: Float = 44100 + let samples = Int(sampleRate * duration) + var data = Data() + // ... WAV header + PCM samples (see spritekit-game skill for full implementation) + return try? AVAudioPlayer(data: data) +} +``` + +Common game sound recipes: +- **Hit/bounce**: 440-880 Hz, 0.05-0.1s +- **Score point**: Two tones (660 Hz → 880 Hz), 0.1s each +- **Game over**: 220 Hz descending envelope, 0.3s +- **Power-up**: Rising sweep 330→880 Hz, 0.2s + +Alternative: `SKAction.playSoundFileNamed("hit.wav", waitForCompletion: false)` if you download WAV files via `nw_download_asset`. + +## Fallback: Programmatic Sprites + +If no suitable download exists, generate colored shapes: + +```swift +// Paddle +let paddle = SKSpriteNode(texture: TextureFactory.roundedRect( + size: CGSize(width: 120, height: 20), + color: .systemBlue, + cornerRadius: 10 +)) + +// Ball +let ball = SKSpriteNode(texture: TextureFactory.circle( + diameter: 20, + color: .white +)) + +// Background +let bg = SKSpriteNode(texture: TextureFactory.gradient( + size: scene.size, + colors: [UIColor(red: 0, green: 0.3, blue: 0, alpha: 1), UIColor(red: 0, green: 0.2, blue: 0, alpha: 1)] +)) +``` + +Downloaded or well-crafted procedural assets always look better than plain colored rectangles. + +## 3D Models for SceneKit + +Use `nw_download_asset` with `asset_kind: "model"` to download 3D models (USDZ, OBJ, DAE, SCN): + +``` +nw_download_asset: + project_dir: /path/to/project + app_name: MyGame + url: https://example.com/car.usdz + asset_name: car + asset_kind: model +``` + +Models are placed in `/Models/` and loaded via: +```swift +// USDZ/OBJ via ModelIO +import ModelIO +import SceneKit.ModelIO +let url = Bundle.main.url(forResource: "car", withExtension: "usdz")! +let modelNode = SCNReferenceNode(url: url) + +// SCN/DAE directly +let scene = SCNScene(named: "car.scn")! +let modelNode = scene.rootNode.childNodes.first! +``` + +### Procedural 3D — No Models Needed + +Many 3D games can be built entirely from SceneKit primitives: +```swift +// Bowling pin from cylinder + sphere +let pinBody = SCNCylinder(radius: 0.15, height: 0.8) +let pinHead = SCNSphere(radius: 0.18) + +// Race car from boxes +let carBody = SCNBox(width: 1.5, height: 0.4, length: 3.0, chamferRadius: 0.1) +let wheel = SCNCylinder(radius: 0.3, height: 0.15) + +// Board game piece from capsule +let piece = SCNCapsule(capRadius: 0.2, height: 0.6) +``` + +Use programmatic materials (diffuse color, metalness, roughness) instead of texture files for clean, stylized looks. + +### 3D Material Textures + +Generate textures for 3D materials programmatically: +```swift +class TextureFactory3D { + static func solidColor(_ color: UIColor, size: CGFloat = 64) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + return renderer.image { ctx in + color.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: size, height: size)) + } + } + + static func woodGrain(size: CGFloat = 256) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + return renderer.image { ctx in + UIColor.brown.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: size, height: size)) + UIColor(white: 0.3, alpha: 0.3).setFill() + for i in stride(from: 0, to: size, by: 8) { + ctx.fill(CGRect(x: 0, y: i, width: size, height: 2)) + } + } + } +} + +// Apply to material +material.diffuse.contents = TextureFactory3D.woodGrain() +``` diff --git a/internal/skills/data/features/game-ui/SKILL.md b/internal/skills/data/features/game-ui/SKILL.md new file mode 100644 index 0000000..cbea89b --- /dev/null +++ b/internal/skills/data/features/game-ui/SKILL.md @@ -0,0 +1,156 @@ +--- +name: "game-ui" +description: "Game UI patterns: SwiftUI HUD overlays on SpriteKit, menus (main/pause/game-over), virtual joystick/d-pad, score displays, health bars, tutorial onboarding." +--- +# Game UI Patterns + +## HUD Overlay Architecture + +Use SwiftUI overlays on SpriteView — NOT SKLabelNode/SKSpriteNode for HUD: + +```swift +struct GameView: View { + @State var gameState = GameState() + + var body: some View { + ZStack { + SpriteView(scene: scene, isPaused: gameState.isPaused) + .ignoresSafeArea() + + // HUD overlay — uses AppTheme + VStack { + HStack { + ScoreView(score: gameState.score) + Spacer() + LivesView(lives: gameState.lives) + } + .padding(.horizontal, AppTheme.Spacing.lg) + .padding(.top, AppTheme.Spacing.md) + + Spacer() + } + .allowsHitTesting(false) // Let touches pass through to SpriteView + + // Pause/Game Over modals + if gameState.phase == .paused { PauseMenuView(gameState: gameState) } + if gameState.phase == .gameOver { GameOverView(gameState: gameState) } + } + } +} +``` + +Rules: +- HUD elements in SwiftUI use AppTheme tokens (colors, fonts, spacing) +- Use `.allowsHitTesting(false)` on non-interactive overlays +- Interactive buttons (pause, menu) need `.allowsHitTesting(true)` +- Score/lives/timer update via `@Observable` GameState shared with SKScene + +## Menu Flow + +``` +MainMenu → Game (Playing) → Pause → Resume / Quit + → GameOver → PlayAgain / MainMenu +``` + +Implementation: +```swift +@Observable +class GameState { + var phase: GamePhase = .menu + + enum GamePhase: Equatable { + case menu + case playing + case paused + case gameOver(winner: String) + } +} + +// In MainView +switch gameState.phase { +case .menu: + MainMenuView(gameState: gameState) +case .playing, .paused, .gameOver: + GameView(gameState: gameState) +} +``` + +## Score Display + +```swift +struct ScoreView: View { + let score: Int + + var body: some View { + Text("\(score)") + .font(AppTheme.Fonts.largeTitle) + .foregroundStyle(AppTheme.Colors.textPrimary) + .contentTransition(.numericText()) + .animation(.spring(duration: 0.3), value: score) + } +} +``` + +## Virtual Joystick + +For games needing continuous directional input (platformers, top-down): + +```swift +struct JoystickView: View { + @Binding var direction: CGVector + @State private var knobOffset: CGSize = .zero + let radius: CGFloat = 50 + + var body: some View { + ZStack { + Circle() + .fill(AppTheme.Colors.surface.opacity(0.3)) + .frame(width: radius * 2, height: radius * 2) + Circle() + .fill(AppTheme.Colors.primary.opacity(0.7)) + .frame(width: radius * 0.8, height: radius * 0.8) + .offset(knobOffset) + } + .gesture( + DragGesture() + .onChanged { value in + let vector = CGSize(width: value.translation.width, height: value.translation.height) + let distance = sqrt(vector.width * vector.width + vector.height * vector.height) + if distance <= radius { + knobOffset = vector + } else { + knobOffset = CGSize( + width: vector.width / distance * radius, + height: vector.height / distance * radius + ) + } + direction = CGVector(dx: knobOffset.width / radius, dy: -knobOffset.height / radius) + } + .onEnded { _ in + knobOffset = .zero + direction = .zero + } + ) + } +} +``` + +## Tutorial Onboarding + +```swift +@AppStorage("hasCompletedTutorial") private var tutorialDone = false + +// Show tutorial overlay on first launch +if !tutorialDone { + TutorialOverlay(onComplete: { tutorialDone = true }) +} +``` + +Pattern: highlight target area, show instruction text, require player action to advance. + +## iPad Considerations + +- Use `.scaleMode = .resizeFill` — scene adapts to any iPad size +- Test both portrait and landscape — use GeometryReader for responsive positioning +- Virtual controls: place joystick bottom-left, action buttons bottom-right +- Larger touch targets for iPad (minimum 60pt for game controls) diff --git a/internal/skills/data/features/scenekit-game/SKILL.md b/internal/skills/data/features/scenekit-game/SKILL.md new file mode 100644 index 0000000..40201b9 --- /dev/null +++ b/internal/skills/data/features/scenekit-game/SKILL.md @@ -0,0 +1,447 @@ +--- +name: "scenekit-game" +description: "Use for 3D games: racing, 3D sports, board games, marble maze, tower defense, bowling. SceneKit + SceneView architecture, 3D scene hierarchy, physics, game loop, primitives, materials, cameras, particles, audio." +--- +# SceneKit 3D Game Development + +**ARCHITECTURE OVERRIDE**: 3D games use SceneKit (SCNScene + SceneView), NOT SpriteKit or plain SwiftUI views with timers or Canvas. The standard @main App → RootView → MainView pattern still applies, but MainView hosts a SceneView instead of standard SwiftUI content. AppTheme rules apply to SwiftUI parts (menus, overlays, HUD) but NOT to SceneKit scene internals where you use SCNVector3, SCNMaterial colors, etc. directly. + +Also load the `game-assets` skill for downloading 3D models and textures via `nw_download_asset`. +Also load the `game-ui` skill for SwiftUI HUD overlays, menus, and virtual controls. + +## SceneView — SwiftUI Bridge + +```swift +import SceneKit +import SwiftUI + +struct GameView: View { + @State var gameState = GameState() + @State private var scene = GameScene() + + var body: some View { + ZStack { + SceneView( + scene: scene.scnScene, + pointOfView: scene.cameraNode, + options: [.allowsCameraControl, .autoenablesDefaultLighting] + ) + .ignoresSafeArea() + + // SwiftUI overlays for HUD, menus (use AppTheme here) + if gameState.phase == .paused { + PauseMenuView(gameState: gameState) + } + } + .onAppear { scene.gameState = gameState } + } +} +``` + +Rules: +- Use `SceneView(scene:pointOfView:options:)` — the native SwiftUI bridge for SceneKit +- `.allowsCameraControl` enables orbit/pan/zoom (remove for fixed-camera games) +- `.autoenablesDefaultLighting` adds ambient + directional light (remove when adding custom lights) +- Pass data between SwiftUI and SceneKit via `@Observable` game state objects +- SwiftUI overlays (menus, HUD, pause) sit in a ZStack above SceneView +- SwiftUI parts use AppTheme. SceneKit scene internals use SCNMaterial/SCNVector3 directly. + +## Scene Architecture + +```swift +@Observable +class GameScene: NSObject, SCNSceneRendererDelegate { + let scnScene = SCNScene() + let cameraNode = SCNNode() + var gameState: GameState? + + override init() { + super.init() + setupCamera() + setupLighting() + setupEnvironment() + } + + private func setupCamera() { + cameraNode.camera = SCNCamera() + cameraNode.position = SCNVector3(0, 10, 15) + cameraNode.look(at: SCNVector3Zero) + scnScene.rootNode.addChildNode(cameraNode) + } + + private func setupLighting() { + let ambientLight = SCNNode() + ambientLight.light = SCNLight() + ambientLight.light?.type = .ambient + ambientLight.light?.intensity = 500 + scnScene.rootNode.addChildNode(ambientLight) + + let directionalLight = SCNNode() + directionalLight.light = SCNLight() + directionalLight.light?.type = .directional + directionalLight.light?.intensity = 1000 + directionalLight.light?.castsShadow = true + directionalLight.eulerAngles = SCNVector3(-Float.pi / 4, 0, 0) + scnScene.rootNode.addChildNode(directionalLight) + } +} +``` + +Node hierarchy — organize by purpose: +``` +SCNScene.rootNode +├── environmentNode — floor, skybox, static scenery +├── gameplayNode — player, enemies, projectiles, pickups +├── cameraNode — camera + attached HUD elements +└── lightingNode — ambient, directional, spot lights +``` + +Rules: +- Group nodes under parent SCNNode containers for organization +- Use `SCNNode.addChildNode()` to build the hierarchy +- Camera: create an SCNCamera, attach to an SCNNode, set `scnScene.rootNode.addChildNode()` +- Lighting: always add ambient + directional lights. Use `castsShadow = true` for key light. + +## Built-in 3D Primitives + +Build complete games from SceneKit primitives — no external 3D models needed: + +```swift +// Box (crates, buildings, platforms) +let box = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0.05) + +// Sphere (balls, planets, projectiles) +let sphere = SCNSphere(radius: 0.5) + +// Cylinder (pins, pillars, coins) +let cylinder = SCNCylinder(radius: 0.3, height: 1.5) + +// Cone (trees, rockets, markers) +let cone = SCNCone(topRadius: 0, bottomRadius: 0.5, height: 1) + +// Torus (rings, donuts, orbits) +let torus = SCNTorus(ringRadius: 1, pipeRadius: 0.2) + +// Plane (walls, cards, billboards) +let plane = SCNPlane(width: 2, height: 2) + +// Floor (infinite ground plane with reflections) +let floor = SCNFloor() +floor.reflectivity = 0.2 + +// Tube (hollow cylinder, pipes) +let tube = SCNTube(innerRadius: 0.4, outerRadius: 0.5, height: 1) + +// Capsule (characters, rounded obstacles) +let capsule = SCNCapsule(capRadius: 0.3, height: 1.5) + +// Pyramid (obstacles, decorations) +let pyramid = SCNPyramid(width: 1, height: 1.5, length: 1) + +// Text (3D text labels in the scene) +let text = SCNText(string: "GOAL!", extrusionDepth: 0.2) +text.font = UIFont.systemFont(ofSize: 1.0, weight: .bold) +``` + +Creating a node from geometry: +```swift +let node = SCNNode(geometry: box) +node.position = SCNVector3(0, 0.5, 0) +scnScene.rootNode.addChildNode(node) +``` + +## Materials + +```swift +let material = SCNMaterial() +material.diffuse.contents = UIColor.systemBlue // Base color +material.specular.contents = UIColor.white // Shiny highlights +material.roughness.contents = 0.4 // 0 = mirror, 1 = matte +material.metalness.contents = 0.1 // 0 = plastic, 1 = metal + +let node = SCNNode(geometry: sphere) +node.geometry?.firstMaterial = material +``` + +Programmatic textures for materials: +```swift +func checkerboardTexture(size: CGFloat, colors: (UIColor, UIColor)) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + return renderer.image { ctx in + let half = size / 2 + colors.0.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: half, height: half)) + ctx.fill(CGRect(x: half, y: half, width: half, height: half)) + colors.1.setFill() + ctx.fill(CGRect(x: half, y: 0, width: half, height: half)) + ctx.fill(CGRect(x: 0, y: half, width: half, height: half)) + } +} + +// Apply to floor +floor.firstMaterial?.diffuse.contents = checkerboardTexture(size: 256, colors: (.darkGray, .gray)) +``` + +Multi-material per geometry: +```swift +let box = SCNBox(width: 1, height: 1, length: 1, chamferRadius: 0) +box.materials = [frontMat, rightMat, backMat, leftMat, topMat, bottomMat] +``` + +## Physics + +```swift +struct PhysicsCategory { + static let none: Int = 0 + static let player: Int = 1 << 0 + static let enemy: Int = 1 << 1 + static let ball: Int = 1 << 2 + static let floor: Int = 1 << 3 + static let wall: Int = 1 << 4 +} + +// Dynamic body (moves, affected by gravity) +node.physicsBody = SCNPhysicsBody(type: .dynamic, shape: SCNPhysicsShape(geometry: sphere, options: nil)) +node.physicsBody?.mass = 1.0 +node.physicsBody?.restitution = 0.8 // Bounciness +node.physicsBody?.friction = 0.5 +node.physicsBody?.categoryBitMask = PhysicsCategory.ball +node.physicsBody?.contactTestBitMask = PhysicsCategory.floor | PhysicsCategory.enemy +node.physicsBody?.collisionBitMask = PhysicsCategory.floor | PhysicsCategory.wall + +// Static body (immovable — floors, walls) +floorNode.physicsBody = SCNPhysicsBody(type: .static, shape: nil) +floorNode.physicsBody?.categoryBitMask = PhysicsCategory.floor + +// Kinematic body (moved by code, not gravity — moving platforms) +platform.physicsBody = SCNPhysicsBody(type: .kinematic, shape: nil) +``` + +Contact detection: +```swift +extension GameScene: SCNPhysicsContactDelegate { + func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) { + let nodeA = contact.nodeA + let nodeB = contact.nodeB + // Handle collision based on category bitmasks + } +} + +// Set delegate +scnScene.physicsWorld.contactDelegate = self +``` + +## Game Loop + +```swift +extension GameScene: SCNSceneRendererDelegate { + private var lastUpdateTime: TimeInterval { get set } // Store as property + + func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { + let dt = lastUpdateTime == 0 ? 0 : time - lastUpdateTime + lastUpdateTime = time + + updateGameLogic(deltaTime: dt) + } +} +``` + +Wire the delegate in SceneView: +```swift +SceneView( + scene: scene.scnScene, + pointOfView: scene.cameraNode, + options: [.allowsCameraControl], + delegate: scene // SCNSceneRendererDelegate +) +``` + +Rules: +- Always calculate delta time — never assume fixed frame rate +- `renderer(_:updateAtTime:)` runs every frame — keep it lightweight +- Use `renderer(_:didApplyAnimations:)` for post-animation logic +- Use `renderer(_:didSimulatePhysics:)` for post-physics corrections + +## Game State with @Observable + +```swift +@Observable +class GameState { + var score: Int = 0 + var lives: Int = 3 + var isPaused: Bool = false + var phase: GamePhase = .menu + + enum GamePhase: Equatable { + case menu, playing, paused, gameOver + } +} +``` + +Share between SwiftUI and SCNScene — scene updates properties, SwiftUI reacts automatically. + +## Camera Systems + +Third-person follow camera: +```swift +func updateCamera(following target: SCNNode) { + let offset = SCNVector3(0, 5, 10) + let targetPos = SCNVector3( + target.position.x + offset.x, + target.position.y + offset.y, + target.position.z + offset.z + ) + cameraNode.position = SCNVector3( + cameraNode.position.x + (targetPos.x - cameraNode.position.x) * 0.1, + cameraNode.position.y + (targetPos.y - cameraNode.position.y) * 0.1, + cameraNode.position.z + (targetPos.z - cameraNode.position.z) * 0.1 + ) + cameraNode.look(at: target.position) +} +``` + +Fixed overhead camera (board games, tower defense): +```swift +cameraNode.position = SCNVector3(0, 20, 0) +cameraNode.eulerAngles = SCNVector3(-Float.pi / 2, 0, 0) +``` + +## Animations + +SCNAction (like SKAction for 3D): +```swift +// Move +let move = SCNAction.move(to: SCNVector3(0, 2, 0), duration: 0.5) + +// Rotate +let rotate = SCNAction.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 1.0) + +// Scale +let scale = SCNAction.scale(to: 1.5, duration: 0.3) + +// Sequence and group +let bounceUp = SCNAction.moveBy(x: 0, y: 1, z: 0, duration: 0.2) +let bounceDown = bounceUp.reversed() +let bounce = SCNAction.sequence([bounceUp, bounceDown]) +let spinAndBounce = SCNAction.group([rotate, bounce]) + +// Repeat +node.runAction(.repeatForever(rotate)) + +// Run with completion +node.runAction(move) { + // Done +} +``` + +SCNTransaction for implicit animations: +```swift +SCNTransaction.begin() +SCNTransaction.animationDuration = 0.5 +node.position = SCNVector3(5, 0, 0) +node.opacity = 0.5 +SCNTransaction.commit() +``` + +## Particle Effects + +```swift +func createExplosion(at position: SCNVector3) -> SCNParticleSystem { + let particles = SCNParticleSystem() + particles.birthRate = 500 + particles.emissionDuration = 0.1 + particles.particleLifeSpan = 0.5 + particles.spreadingAngle = 180 + particles.particleSize = 0.1 + particles.particleColor = .orange + particles.particleColorVariation = SCNVector4(0.2, 0.2, 0, 0) + particles.particleVelocity = 5 + particles.particleVelocityVariation = 2 + particles.isAffectedByGravity = true + + let particleNode = SCNNode() + particleNode.position = position + particleNode.addParticleSystem(particles) + + // Auto-remove after emission + particleNode.runAction(.sequence([ + .wait(duration: 1.0), + .removeFromParentNode() + ])) + + return particles +} +``` + +## Audio + +Positional 3D audio: +```swift +let audioSource = SCNAudioSource(named: "engine.wav")! +audioSource.isPositional = true +audioSource.shouldStream = false +audioSource.load() + +let audioPlayer = SCNAudioPlayer(source: audioSource) +engineNode.addAudioPlayer(audioPlayer) +``` + +For procedural sound generation, use the same `generateTone()` pattern from the `spritekit-game` skill with AVAudioPlayer. + +## Game Feel + +Camera shake: +```swift +func cameraShake(intensity: Float = 0.3, duration: TimeInterval = 0.15) { + let shake = SCNAction.sequence([ + .moveBy(x: CGFloat(intensity), y: CGFloat(intensity), z: 0, duration: duration / 4), + .moveBy(x: CGFloat(-intensity * 2), y: CGFloat(-intensity), z: 0, duration: duration / 4), + .moveBy(x: CGFloat(intensity), y: CGFloat(-intensity), z: 0, duration: duration / 4), + .moveBy(x: 0, y: CGFloat(intensity), z: 0, duration: duration / 4), + ]) + cameraNode.runAction(shake) +} +``` + +Haptic feedback: +```swift +let impact = UIImpactFeedbackGenerator(style: .medium) +impact.impactOccurred() +``` + +## 3D Model Loading + +Load USDZ/OBJ/DAE/SCN files from the app bundle: +```swift +// .scn or .dae files +let scene = SCNScene(named: "model.scn")! +let modelNode = scene.rootNode.childNodes.first! + +// USDZ via ModelIO +import ModelIO +import SceneKit.ModelIO + +let url = Bundle.main.url(forResource: "model", withExtension: "usdz")! +let mdlAsset = MDLAsset(url: url) +let modelScene = SCNScene(mdlAsset: mdlAsset) +``` + +Use `nw_download_asset` with `asset_kind: "model"` to download 3D models into the project's Models/ directory. + +## Performance Rules + +- Use simple physics shapes (box, sphere) instead of mesh-based shapes +- `flattenedClone()` for static geometry groups — merges into single draw call +- Reuse SCNGeometry and SCNMaterial instances across nodes +- Use `SCNNode.isHidden = true` for off-screen nodes (skips rendering) +- Limit shadow-casting lights (1-2 max) +- Use `SCNCamera.fieldOfView` to control visible area (smaller FOV = less to render) +- Profile with Xcode's SceneKit statistics overlay: `sceneView.showsStatistics = true` + +## Genre-Specific Patterns + +**Bowling/Sports**: Lane as SCNFloor, pins as SCNCylinder, ball as SCNSphere with `.dynamic` physics, `applyForce()` for throw +**Racing**: SCNPhysicsVehicle for car physics, SCNFloor for track, checkpoint nodes with contact detection +**Board Games**: SCNPlane/SCNBox tiles, overhead fixed camera, tap gestures via `hitTest()` for piece selection +**Marble Maze**: SCNCapsule/SCNSphere player, SCNBox platforms, tilt controls via CoreMotion accelerometer +**Tower Defense**: Grid-based SCNPlane tiles, SCNBox/SCNCylinder towers, pathfinding for enemies, projectile SCNSphere nodes diff --git a/internal/skills/data/features/spritekit-game/SKILL.md b/internal/skills/data/features/spritekit-game/SKILL.md index c88bb70..996d805 100644 --- a/internal/skills/data/features/spritekit-game/SKILL.md +++ b/internal/skills/data/features/spritekit-game/SKILL.md @@ -1,115 +1,240 @@ --- name: "spritekit-game" -description: "SpriteKit 2D game development: SpriteView integration, scene architecture, entity-component system, physics, game loop. Use when building 2D games." +description: "Use for 2D games: arcade, puzzle, sports, ping pong, platformer, shooter, 2D racing. SpriteKit + SpriteView architecture, scene hierarchy, physics, game loop, audio, particles, game feel." --- -# SpriteKit Game Development +# SpriteKit 2D Game Development -## SpriteView — SwiftUI Bridge +**This skill is for 2D games only.** For 3D games (racing, bowling, board games, marble maze, tower defense, 3D sports), load the `scenekit-game` skill instead. + +**ARCHITECTURE OVERRIDE**: 2D games use SpriteKit (SKScene + SpriteView), NOT plain SwiftUI views with timers or Canvas. The standard @main App → RootView → MainView pattern still applies, but MainView hosts a SpriteView instead of standard SwiftUI content. AppTheme rules apply to SwiftUI parts (menus, overlays) but NOT to SpriteKit scene internals where you use SKColor, SKLabelNode fonts, etc. directly. -SpriteView is the bridge between SwiftUI and SpriteKit. It embeds an SKScene inside a SwiftUI view hierarchy. +Also load the `game-assets` skill for downloading sprites and textures via `nw_download_asset`. + +## SpriteView — SwiftUI Bridge ```swift import SpriteKit import SwiftUI struct GameView: View { + @State var gameState = GameState() + var body: some View { - SpriteView(scene: GameScene(size: CGSize(width: 390, height: 844))) - .ignoresSafeArea() + ZStack { + SpriteView(scene: makeScene(), isPaused: gameState.isPaused) + .ignoresSafeArea() + + // SwiftUI overlays for HUD, menus (use AppTheme here) + if gameState.isPaused { + PauseMenuView(gameState: gameState) + } + } + } + + private func makeScene() -> GameScene { + let scene = GameScene(size: UIScreen.main.bounds.size) + scene.scaleMode = .resizeFill + scene.gameState = gameState + return scene } } ``` -Key rules: -- One `SpriteView` per game screen — it owns and manages the SKScene lifecycle -- Use `SpriteView(scene:, transition:, isPaused:, preferredFramesPerSecond:, options:, debugOptions:)` for full control -- SwiftUI views (menus, settings, HUD overlays) still use MVVM + AppTheme -- Game screens use SKScene architecture (not MVVM) +Rules: +- Use `.scaleMode = .resizeFill` so the scene adapts to actual view size - Pass data between SwiftUI and SpriteKit via `@Observable` game state objects +- SwiftUI overlays (menus, HUD, pause) sit in a ZStack above SpriteView +- Use `SpriteView(scene:, isPaused:)` to control pause from SwiftUI +- SwiftUI parts use AppTheme. SpriteKit scene internals use SKColor/SKLabelNode directly. ## Scene Architecture -Every game scene uses a layer-based node hierarchy: +Layer-based node hierarchy — create in `didMove(to:)`: ``` SKScene (GameScene) -├── backgroundLayer (SKNode) — z: -100, parallax backgrounds, sky -├── gameplayLayer (SKNode) — z: 0, player, enemies, items, platforms -└── hudLayer (SKNode) — z: 100, score, health bars, controls +├── backgroundLayer (SKNode) — z: -100 +├── gameplayLayer (SKNode) — z: 0 +└── hudLayer (SKNode) — z: 100 ``` Rules: -- Create layer nodes in `didMove(to:)`, add all game objects as children of the appropriate layer -- Use `zPosition` on layers, not on individual sprites -- Camera: attach `SKCameraNode` to the gameplay layer, set `scene.camera` -- Scene transitions: `SKTransition` for moving between scenes +- Set `zPosition` on layers, not individual sprites +- Camera: attach `SKCameraNode` to gameplayLayer, set `scene.camera` +- Scene transitions: `view?.presentScene(newScene, transition: .push(with: .up, duration: 0.3))` +- Clean up in `willMove(from:)` — remove actions, nil out references -## Entity-Component System (GameplayKit) - -Use GKEntity + GKComponent for game object composition: - -```swift -import GameplayKit - -class PlayerEntity: GKEntity { - init(spriteNode: SKSpriteNode) { - super.init() - addComponent(SpriteComponent(node: spriteNode)) - addComponent(HealthComponent(maxHealth: 100)) - addComponent(MovementComponent(speed: 200)) - } -} -``` - -Rules: -- Prefer composition (components) over inheritance (subclassing SKSpriteNode) -- GKComponentSystem updates all components of a type in the game loop -- Use GKStateMachine for game states (Menu, Playing, Paused, GameOver) - -## Physics System - -SKPhysicsBody with category bitmasks for collision detection: +## Physics ```swift struct PhysicsCategory { + static let none: UInt32 = 0 static let player: UInt32 = 0x1 << 0 static let enemy: UInt32 = 0x1 << 1 - static let item: UInt32 = 0x1 << 2 - static let ground: UInt32 = 0x1 << 3 + static let ball: UInt32 = 0x1 << 2 + static let wall: UInt32 = 0x1 << 3 } ``` Rules: - Set `categoryBitMask`, `contactTestBitMask`, `collisionBitMask` on every physics body -- Implement `SKPhysicsContactDelegate` on the scene for contact callbacks -- Use `physicsWorld.gravity` for platformers, set to `.zero` for top-down games +- Implement `SKPhysicsContactDelegate` — use Double Dispatch (delegate to nodes, not massive if-else) +- Use rectangle/circle bodies for performance — avoid texture-based bodies +- Enable `usesPreciseCollisionDetection = true` only for fast objects (balls, bullets) +- Set `physicsWorld.gravity = .zero` for top-down games, `CGVector(dx: 0, dy: -9.8)` for platformers ## Game Loop -The update cycle runs every frame via `update(_:)`: - ```swift +private var lastUpdateTime: TimeInterval = 0 + override func update(_ currentTime: TimeInterval) { - let dt = currentTime - lastUpdateTime + let dt = lastUpdateTime == 0 ? 0 : currentTime - lastUpdateTime lastUpdateTime = currentTime - componentSystem.update(deltaTime: dt) + + updateGameLogic(deltaTime: dt) } ``` Rules: - Always calculate delta time — never assume fixed frame rate -- Use `update()` for game logic, `didEvaluateActions()` for post-action cleanup -- `didSimulatePhysics()` for post-physics position corrections - -## References - -Detailed references are available for each subsystem: -- Scene architecture and lifecycle -- SpriteView SwiftUI integration -- Physics and collisions -- Entity-Component system -- Actions and animations -- Particle effects -- Tile maps -- Audio -- Performance optimization +- `update()` → game logic, `didEvaluateActions()` → post-action, `didSimulatePhysics()` → position corrections +- Use `@MainActor @objc` for CADisplayLink targets in Swift 6 + +## Game State with @Observable + +```swift +@Observable +class GameState { + var score: Int = 0 + var lives: Int = 3 + var isPaused: Bool = false + var phase: GamePhase = .menu + + enum GamePhase { + case menu, playing, paused, gameOver + } +} +``` + +Share between SwiftUI and SKScene — SKScene updates properties, SwiftUI reacts automatically. + +## Audio — Procedural Sound Effects + +Generate sounds programmatically — no audio files needed: + +```swift +import AVFoundation + +func generateTone(frequency: Float, duration: Float) -> AVAudioPlayer? { + let sampleRate: Float = 44100 + let samples = Int(sampleRate * duration) + var data = Data() + + // WAV header + let dataSize = samples * 2 + let fileSize = 36 + dataSize + data.append(contentsOf: "RIFF".utf8) + data.append(contentsOf: withUnsafeBytes(of: Int32(fileSize).littleEndian) { Array($0) }) + data.append(contentsOf: "WAVEfmt ".utf8) + data.append(contentsOf: withUnsafeBytes(of: Int32(16).littleEndian) { Array($0) }) + data.append(contentsOf: withUnsafeBytes(of: Int16(1).littleEndian) { Array($0) }) // PCM + data.append(contentsOf: withUnsafeBytes(of: Int16(1).littleEndian) { Array($0) }) // mono + data.append(contentsOf: withUnsafeBytes(of: Int32(44100).littleEndian) { Array($0) }) + data.append(contentsOf: withUnsafeBytes(of: Int32(88200).littleEndian) { Array($0) }) + data.append(contentsOf: withUnsafeBytes(of: Int16(2).littleEndian) { Array($0) }) + data.append(contentsOf: withUnsafeBytes(of: Int16(16).littleEndian) { Array($0) }) + data.append(contentsOf: "data".utf8) + data.append(contentsOf: withUnsafeBytes(of: Int32(dataSize).littleEndian) { Array($0) }) + + for i in 0.. SKEmitterNode { + let emitter = SKEmitterNode() + emitter.particleBirthRate = 200 + emitter.numParticlesToEmit = 20 + emitter.particleLifetime = 0.3 + emitter.particleSpeed = 150 + emitter.particleSpeedRange = 50 + emitter.emissionAngleRange = .pi * 2 + emitter.particleScale = 0.1 + emitter.particleScaleRange = 0.05 + emitter.particleAlphaSpeed = -3.0 + emitter.particleColor = .white + emitter.position = position + // Auto-remove after emission completes + emitter.run(.sequence([.wait(forDuration: 0.5), .removeFromParent()])) + return emitter +} +``` + +## Game Feel — Juice Effects + +Screen shake on impact: +```swift +func screenShake(intensity: CGFloat = 8, duration: TimeInterval = 0.15) { + let shake = SKAction.sequence([ + SKAction.moveBy(x: intensity, y: intensity, duration: duration / 4), + SKAction.moveBy(x: -intensity * 2, y: -intensity, duration: duration / 4), + SKAction.moveBy(x: intensity, y: -intensity, duration: duration / 4), + SKAction.moveBy(x: 0, y: intensity, duration: duration / 4), + ]) + camera?.run(shake) +} +``` + +Hit pause (freeze frame) — 50-100ms pause on impact: +```swift +func hitPause(duration: TimeInterval = 0.05) { + isPaused = true + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { self.isPaused = false } +} +``` + +Haptic feedback: +```swift +let impact = UIImpactFeedbackGenerator(style: .medium) +impact.impactOccurred() +``` + +## Performance Rules + +- **SKSpriteNode** for all game objects — SKShapeNode is slow (drops FPS significantly) +- Convert shapes to textures: `let texture = view.texture(from: shapeNode)` +- Preload textures before gameplay to avoid frame drops +- Use texture atlases for sprite sheets (group by scene/level, not one giant atlas) +- Verify scene deallocation: override `deinit` during development +- Static bodies for walls/platforms, dynamic only for moving objects +- Disable physics on off-screen nodes + +## Genre-Specific Patterns + +**Sports/Pong**: `gravity = .zero`, rectangle paddles, ball with `restitution = 1.0`, AI tracks ball with delay +**Platformer**: `gravity = CGVector(dx: 0, dy: -9.8)`, `applyImpulse()` for jumps, ground detection via contact +**Endless Runner**: Parallax scrolling with duplicate background sprites leap-frogging, SKCameraNode follows player +**Puzzle/Match-3**: Grid data structure, nested loop match detection, SKAction chains for animations +**Shooter**: Contact (not collision) tests for bullets, spawn timers for enemy waves, projectile pooling + +## Game Assets + +For visual assets (sprites, backgrounds, textures), load the `game-assets` skill to use the `nw_download_asset` tool. For sounds, generate programmatically as shown above.