diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 00a5eed02..6489f59fc 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -12,6 +12,9 @@ ## Image generation - **[issue-3186] Agy CLI is now an opt-in image-generation backend.** Users can enable Antigravity in Image Gen settings, select an installed or custom model, and send text-to-image work through its `generate_image` tool with queue progress, cancellation, gallery metadata, cleaner options, and CoS tool registration. Each render runs in an isolated scratch directory and only a signature-verified directed image is imported; image-edit surfaces continue to use an edit-capable backend. +## Game + +- **[issue-3177] Added a Game studio for assembling managed-app asset bundles.** Create a Game workspace, bind reusable Sprite records and Music tracks, compile an immutable versioned manifest with SHA-256 asset references, and request persisted AI feedback with any configured provider, model, and supported effort. The new `/game/:id` workspace is reachable from Create, Cmd+K, and voice navigation. ## Quota burn diff --git a/client/src/App.jsx b/client/src/App.jsx index cc2676366..8eea4209a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -76,6 +76,7 @@ const CreativeDirector = lazyWithReload(() => import('./pages/CreativeDirector') const CreativeDirectorDetail = lazyWithReload(() => import('./pages/CreativeDirectorDetail')); const CreativeCommissions = lazyWithReload(() => import('./pages/CreativeCommissions')); const CreativeCommissionDetail = lazyWithReload(() => import('./pages/CreativeCommissionDetail')); +const Game = lazyWithReload(() => import('./pages/Game')); const MusicVideo = lazyWithReload(() => import('./pages/MusicVideo')); const Sprites = lazyWithReload(() => import('./pages/Sprites')); const MoodBoards = lazyWithReload(() => import('./pages/MoodBoards')); @@ -399,6 +400,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 26dc8d87a..1c2ed452c 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -95,7 +95,8 @@ import { Clapperboard, PersonStanding, Box, - Boxes + Boxes, + Gamepad2 } from 'lucide-react'; /* global __APP_VERSION__ */ import { safeReadStorage, safeWriteStorage } from '../lib/safeStorage'; @@ -226,6 +227,7 @@ const navItems = [ { to: '/creative-commission', label: 'Creative Commissions', icon: CalendarClock }, { to: '/creative-director', label: 'Creative Director', icon: Clapperboard }, { to: '/pipeline/editorial-checks', label: 'Editorial Checks', icon: ListChecks }, + { to: '/game', label: 'Game', icon: Gamepad2 }, { to: '/importer', label: 'Importer', icon: FileInput }, { to: '/media', label: 'Media Gen', icon: Layers }, { to: '/mood-boards', label: 'Mood Boards', icon: Palette }, diff --git a/client/src/components/games/GameBindings.jsx b/client/src/components/games/GameBindings.jsx new file mode 100644 index 000000000..f32dbd07e --- /dev/null +++ b/client/src/components/games/GameBindings.jsx @@ -0,0 +1,170 @@ +import { useMemo, useState } from 'react'; +import { Music2, PersonStanding, Plus, Unlink } from 'lucide-react'; + +const selectClass = 'w-full min-h-[44px] rounded-lg border border-port-border bg-port-bg px-3 py-2 text-sm text-white'; +const actionClass = 'inline-flex min-h-[44px] items-center justify-center gap-2 rounded-lg bg-port-accent px-3 py-2 text-sm font-medium text-white disabled:opacity-50'; + +function BindingSection({ + title, + icon: Icon, + emptyText, + available, + value, + onValueChange, + onAdd, + addLabel, + disabled, + children, +}) { + const selectId = `game-${title.toLowerCase().replace(/\s+/g, '-')}-picker`; + return ( +
+
+
+
+
+ + +
+ +
+ {children ||

{emptyText}

} +
+ ); +} + +export default function GameBindings({ + game, + sprites, + tracks, + busy, + onBindSprite, + onUnbindSprite, + onBindMusic, + onUnbindMusic, +}) { + const [spriteId, setSpriteId] = useState(''); + const [trackId, setTrackId] = useState(''); + const spriteMap = useMemo(() => new Map(sprites.map((sprite) => [sprite.id, sprite])), [sprites]); + const trackMap = useMemo(() => new Map(tracks.map((track) => [track.id, track])), [tracks]); + const boundSpriteIds = new Set(game.spriteBindings.map((binding) => binding.spriteId)); + const boundTrackIds = new Set(game.musicBindings.map((binding) => binding.trackId)); + const availableSprites = sprites + .filter((sprite) => !boundSpriteIds.has(sprite.id)) + .map((sprite) => ({ id: sprite.id, label: `${sprite.name} · ${sprite.kind}` })); + const availableTracks = tracks + .filter((track) => !boundTrackIds.has(track.id)) + .map((track) => ({ id: track.id, label: `${track.title}${track.audioFilename ? '' : ' · no audio yet'}` })); + + const addSprite = async () => { + if (!spriteId) return; + if (await onBindSprite(spriteId)) setSpriteId(''); + }; + const addMusic = async () => { + if (!trackId) return; + if (await onBindMusic(trackId)) setTrackId(''); + }; + + return ( +
+ + {game.spriteBindings.length > 0 ? ( +
    + {game.spriteBindings.map((binding) => { + const sprite = spriteMap.get(binding.spriteId); + return ( +
  • +
    +
    {sprite?.name || binding.spriteId}
    +
    {sprite ? `${sprite.kind} · ${sprite.status}` : 'Record unavailable'}
    +
    + +
  • + ); + })} +
+ ) : null} +
+ + + {game.musicBindings.length > 0 ? ( +
    + {game.musicBindings.map((binding) => { + const track = trackMap.get(binding.trackId); + return ( +
  • +
    +
    {track?.title || binding.trackId}
    +
    {track?.audioFilename ? 'Audio ready' : 'Audio render required'}
    +
    + +
  • + ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/client/src/components/games/GameCompilePanel.jsx b/client/src/components/games/GameCompilePanel.jsx new file mode 100644 index 000000000..2e33d97c9 --- /dev/null +++ b/client/src/components/games/GameCompilePanel.jsx @@ -0,0 +1,47 @@ +import { Boxes, RefreshCw } from 'lucide-react'; +import { formatDateShort } from '../../utils/formatters.js'; + +export default function GameCompilePanel({ game, compiling, onCompile }) { + const current = game.compiledManifest; + return ( +
+
+
+
+
+

+ Compile immutable sprite-atlas and music references into a deterministic manifest. +

+
+ +
+ + {current ? ( +
+
Version
v{current.version}
+
Sprites
{current.spriteCount}
+
Music
{current.musicCount}
+
Built
{formatDateShort(current.builtAt)}
+
+
Manifest
+
{current.manifestPath}
+
+
+ ) : ( +

+ No bundle compiled yet. Bound sprites need a current runtime atlas and bound tracks need audio. +

+ )} +
+ ); +} diff --git a/client/src/components/games/GameFeedback.jsx b/client/src/components/games/GameFeedback.jsx new file mode 100644 index 000000000..80240bf6c --- /dev/null +++ b/client/src/components/games/GameFeedback.jsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { MessageSquareText, Send } from 'lucide-react'; +import ProviderModelSelector from '../ProviderModelSelector.jsx'; +import EffortSelect from '../cos/EffortSelect.jsx'; +import useProviderModels from '../../hooks/useProviderModels.js'; +import { formatDateShort } from '../../utils/formatters.js'; + +export default function GameFeedback({ history, submitting, onSubmit }) { + const [prompt, setPrompt] = useState(''); + const [effort, setEffort] = useState(''); + const { + providers, + selectedProviderId, + selectedModel, + availableModels, + setSelectedProviderId, + setSelectedModel, + loading, + } = useProviderModels({ silent: true }); + const selectedProvider = providers.find((provider) => provider.id === selectedProviderId); + const promptId = 'game-feedback-prompt'; + + const submit = async (event) => { + event.preventDefault(); + if (!prompt.trim() || !selectedProviderId) return; + const ok = await onSubmit({ + providerId: selectedProviderId, + ...(selectedModel ? { model: selectedModel } : {}), + ...(effort ? { effort } : {}), + prompt: prompt.trim(), + }); + if (ok) setPrompt(''); + }; + + return ( +
+
+
+

+ Ask any configured provider to critique asset coverage and recommend the next improvements. +

+ +
+ + +
+ +