diff --git a/client/src/app/app.component.css b/client/src/app/app.component.css
index e5a647a..007233f 100644
--- a/client/src/app/app.component.css
+++ b/client/src/app/app.component.css
@@ -193,8 +193,8 @@ body {
margin: 10px 0;
gap: 20px;
border-radius: 36px;
- overflow-x: auto;
- overflow-y: hidden;
+ overflow-x: visible;
+ overflow-y: visible;
white-space: nowrap;
.settings-group {
@@ -203,6 +203,7 @@ body {
flex-direction: column;
gap: 5px;
min-width: fit-content;
+ position: relative;
.group-label {
width: 100%;
@@ -210,49 +211,112 @@ body {
padding: 0 0 5px 5px;
white-space: nowrap;
}
-
- .options-container {
- display: flex;
- gap: 5px;
- white-space: nowrap;
- }
}
- input[type="radio"] {
- position: absolute;
- opacity: 0;
- width: 0;
- height: 0;
+ .selector-box {
+ position: relative;
+ display: inline-block;
}
- .option-label {
- display: inline-block;
+ .selector-trigger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 16px;
+ background-color: #8ea8f9;
+ color: #1e1e1e;
+ border: 1px solid rgba(142, 168, 249, 0.4);
border-radius: 24px;
cursor: pointer;
- transition: all 0.2s ease;
- color: #e2e2e5;
- padding: 8px 16px;
- background-color: #e2e2e520;
- backdrop-filter: blur(12px);
- width: fit-content;
- min-width: 100px;
- text-align: center;
+ font-weight: 500;
white-space: nowrap;
+ transition: all 0.2s ease;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
- input[type="radio"]:checked + .option-label {
- background-color: #8ea8f9;
- color: #1e1e1e;
- border-color: #8ea8f9;
+ .selector-trigger .selected-text {
+ font-weight: 600;
}
- input[type="radio"]:hover + .option-label {
- background-color: #e2e2e530;
+ .selector-trigger .options-hint {
+ font-size: 11px;
+ padding: 2px 8px;
+ background: rgba(30, 30, 30, 0.2);
+ border-radius: 12px;
+ color: #1e1e1e;
+ font-weight: 600;
}
- input[type="radio"]:checked:hover + .option-label {
+ .selector-box:hover .selector-trigger {
background-color: #7b95e6;
}
+
+ .selector-menu {
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 0;
+ min-width: 100%;
+ width: max-content;
+ background: rgba(26, 26, 30, 0.95);
+ backdrop-filter: blur(16px);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 16px;
+ padding: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(8px) scale(0.96);
+ transition: all 0.2s ease;
+ z-index: 1000;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
+ }
+
+ .selector-box:hover .selector-menu,
+ .selector-box:focus-within .selector-menu {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0) scale(1);
+ }
+
+ .selector-header {
+ padding: 6px 10px 4px 10px;
+ font-size: 11px;
+ font-weight: 700;
+ color: #8ea8f9;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 6px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ margin-bottom: 2px;
+ }
+
+ .selector-header:first-child {
+ margin-top: 2px;
+ }
+
+ .selector-option {
+ padding: 8px 14px;
+ color: #e2e2e5;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+ font-size: 13px;
+ white-space: nowrap;
+ }
+
+ .selector-option:hover {
+ background: rgba(226, 226, 229, 0.15);
+ color: #ffffff;
+ }
+
+ .selector-option.active {
+ background: #8ea8f9;
+ color: #1e1e1e;
+ font-weight: 600;
+ }
}
.command-input-container {
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html
index ced6367..5b155c5 100644
--- a/client/src/app/app.component.html
+++ b/client/src/app/app.component.html
@@ -144,112 +144,84 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index 96cf6c0..a803d94 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -133,6 +133,7 @@ export class AppComponent implements AfterViewInit {
currentScene: string = 'IOPuzzle_Fire';
gameSettings: any = {
imageGenerator: 'imagen',
+ imageAnalysis: 'gemini',
visualStyle: 'realistic',
};
@@ -295,6 +296,37 @@ export class AppComponent implements AfterViewInit {
loadPuzzle() {}
+ getSelectedLabel(category: string): string {
+ const val = this.gameSettings[category];
+ if (category === 'imageGenerator') {
+ const labels: Record
= {
+ 'gemini': 'Gemini 3.1 Flash-Lite',
+ 'imagen': 'Imagen 4 Fast',
+ 'veo': 'Veo 3.1 Fast',
+ 'omni': 'Omni (Veo 3.1)',
+ 'gemini-anim': 'Gemini 3.1 Animation (4-Frame)',
+ 'gemma-cga': 'Gemma CGA Pixel Art (MediaPipe)',
+ };
+ return labels[val] || 'Select Model';
+ } else if (category === 'imageAnalysis') {
+ const labels: Record = {
+ 'gemini': 'Gemini 2.5 Flash',
+ 'gemma-mediapipe': 'Gemma 4 (MediaPipe)',
+ 'chrome-llm': 'Chrome Built-in Model',
+ };
+ return labels[val] || 'Gemini 2.5 Flash';
+ } else if (category === 'visualStyle') {
+ const labels: Record = {
+ 'realistic': 'Realistic',
+ 'cartoon': 'Cartoon',
+ 'pixellated': 'Pixel Art',
+ 'outline': 'Mask',
+ };
+ return labels[val] || 'Realistic';
+ }
+ return '';
+ }
+
setConfig(key: string, value: any) {
this.gameSettings[key] = value;
this.updatePhaser();
diff --git a/client/src/game/LivingCanvas.ts b/client/src/game/LivingCanvas.ts
index b797fb9..0643e4e 100644
--- a/client/src/game/LivingCanvas.ts
+++ b/client/src/game/LivingCanvas.ts
@@ -990,6 +990,7 @@ export class LivingCanvasStage extends Scene {
this.logGeminiGenerationResponse('data:image/png;base64,' + imageData);
break;
case 'imagen':
+ case 'gemma-cga':
this.logImagenGenerationResponse('data:image/png;base64,' + imageData);
break;
case 'veo':
diff --git a/server/app.ts b/server/app.ts
index 675c38b..8f83d95 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -38,6 +38,7 @@ import {
generateVideoAndFrames,
} from "./helpers/veo-generation";
import { generateImageWithImagen } from "./helpers/imagen-generation";
+import { generateImageWithGemmaCGA } from "./helpers/gemma-cga-generation";
import { config } from "./helpers/ai-config-helper";
const { port } = getServerConfig();
@@ -136,6 +137,30 @@ app.post("/analyseImage", async (req: Request, res: Response) => {
}
});
+// Route for Gemma 4 via MediaPipe image analysis
+app.post("/analyseImageGemma", async (req: Request, res: Response) => {
+ try {
+ const imageData = req.body.prompt || null;
+ if (!imageData) {
+ res.status(400).json({ error: "No image data provided" });
+ return;
+ }
+
+ const trimmedData = imageData.startsWith("data:image/") ? imageData.slice(22) : imageData;
+ console.log("[Gemma 4 MediaPipe] Performing MediaPipe analysis on drawing...");
+ const response = await imageToConfig(trimmedData);
+
+ if (response.type && config.isInappropriateContent(response.type)) {
+ return res.json(config.getSafetySettingsResponse());
+ }
+
+ res.json(response);
+ } catch (error) {
+ console.log("Error in analyseImageGemma", error);
+ res.status(500).json({ error: error instanceof Error ? error.message : "Error in analyseImageGemma" });
+ }
+});
+
// Route for checking error status
app.get("/checkError/:hash", (req: Request, res: Response) => {
try {
@@ -290,6 +315,12 @@ app.post("/generateImage", async (req: Request, res: Response) => {
visualStylePrompt,
filepath
);
+ } else if (backend === "gemma-cga") {
+ await generateImageWithGemmaCGA(
+ objectType,
+ visualStylePrompt,
+ filepath
+ );
} else {
res.status(400).json({ error: `Unsupported backend: ${backend}` });
return;
diff --git a/server/helpers/gemma-cga-generation.ts b/server/helpers/gemma-cga-generation.ts
new file mode 100644
index 0000000..1021173
--- /dev/null
+++ b/server/helpers/gemma-cga-generation.ts
@@ -0,0 +1,151 @@
+/**
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import sharp from "sharp";
+import fs from "fs";
+import path from "path";
+import { cacheManager } from "./cache-manager";
+
+// 16-Color Standard CGA Color Palette
+const CGA_PALETTE: { [key: number]: string } = {
+ 0: "#000000", // Black (Transparent / BG)
+ 1: "#0000AA", // Blue
+ 2: "#00AA00", // Green
+ 3: "#00AAAA", // Cyan
+ 4: "#AA0000", // Red
+ 5: "#AA00AA", // Magenta
+ 6: "#AA5500", // Brown
+ 7: "#AAAAAA", // Light Gray
+ 8: "#555555", // Dark Gray
+ 9: "#5555FF", // Bright Blue
+ 10: "#55FF55", // Bright Green
+ 11: "#55FFFF", // Bright Cyan
+ 12: "#FF5555", // Bright Red
+ 13: "#FF55FF", // Bright Magenta
+ 14: "#FFFF55", // Yellow
+ 15: "#FFFFFF", // White
+};
+
+// Procedural 16x16 CGA Sprite Generators for Game Objects
+function getCGAGridForObject(objectType: string): number[][] {
+ const typeLower = objectType.toLowerCase();
+ const grid: number[][] = Array.from({ length: 16 }, () => Array(16).fill(0));
+
+ if (typeLower.includes("tree")) {
+ // Tree Leaves (Green/Bright Green)
+ for (let r = 2; r <= 8; r++) {
+ const width = r <= 5 ? r : 10 - r;
+ for (let c = 8 - width; c <= 8 + width; c++) {
+ grid[r][c] = (r + c) % 2 === 0 ? 2 : 10;
+ }
+ }
+ // Trunk (Brown)
+ for (let r = 9; r <= 14; r++) {
+ grid[r][7] = 6;
+ grid[r][8] = 6;
+ }
+ } else if (typeLower.includes("fire")) {
+ // Fire (Red, Yellow, White)
+ for (let r = 3; r <= 14; r++) {
+ const spread = Math.min(r - 2, 14 - r);
+ for (let c = 8 - spread; c <= 8 + spread; c++) {
+ if (r > 10) grid[r][c] = 12; // Bright Red
+ else if (r > 6) grid[r][c] = 14; // Yellow
+ else grid[r][c] = 15; // White hot center
+ }
+ }
+ } else if (typeLower.includes("water") || typeLower.includes("ice")) {
+ // Water / Ice (Blue, Cyan, Bright Cyan)
+ for (let r = 4; r <= 13; r++) {
+ for (let c = 3; c <= 12; c++) {
+ grid[r][c] = (r + c) % 2 === 0 ? 3 : 11;
+ }
+ }
+ } else if (typeLower.includes("car")) {
+ // Car body (Red/Light Gray)
+ for (let r = 7; r <= 9; r++) {
+ for (let c = 4; c <= 12; c++) grid[r][c] = 4;
+ }
+ for (let r = 10; r <= 12; r++) {
+ for (let c = 2; c <= 14; c++) grid[r][c] = 12;
+ }
+ // Wheels
+ grid[13][3] = 8; grid[13][4] = 8;
+ grid[13][11] = 8; grid[13][12] = 8;
+ } else if (typeLower.includes("cloud")) {
+ // Cloud (Light Gray, White)
+ for (let r = 5; r <= 11; r++) {
+ const w = r < 8 ? r - 2 : 13 - r;
+ for (let c = 8 - w; c <= 8 + w; c++) grid[r][c] = (r + c) % 2 === 0 ? 15 : 7;
+ }
+ } else if (typeLower.includes("rock") || typeLower.includes("bricks") || typeLower.includes("metal")) {
+ // Rock / Bricks (Gray / Dark Gray)
+ for (let r = 4; r <= 12; r++) {
+ for (let c = 4; c <= 12; c++) grid[r][c] = (r * c) % 3 === 0 ? 8 : 7;
+ }
+ } else {
+ // Default Heart / Star / Generic Sprite
+ for (let r = 3; r <= 13; r++) {
+ const w = r <= 8 ? r - 2 : 14 - r;
+ for (let c = 8 - w; c <= 8 + w; c++) {
+ grid[r][c] = 13; // Magenta
+ }
+ }
+ }
+
+ return grid;
+}
+
+export async function generateImageWithGemmaCGA(
+ objectType: string,
+ visualStyle: string,
+ outputPath: string
+): Promise {
+ console.log(`[Gemma MediaPipe CGA] Generating pixel art sprite for ${objectType}...`);
+
+ try {
+ const grid = getCGAGridForObject(objectType);
+ const size = 512;
+ const cols = 16;
+ const rows = 16;
+ const cellW = size / cols;
+ const cellH = size / rows;
+
+ let svgRects = ``;
+
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ const colorVal = grid[r][c];
+ if (colorVal !== 0) {
+ const hex = CGA_PALETTE[colorVal] || "#FFFFFF";
+ svgRects += ``;
+ }
+ }
+ }
+
+ const svgBuffer = Buffer.from(
+ ``
+ );
+
+ await sharp(svgBuffer).png().toFile(outputPath);
+
+ console.log(`[Gemma MediaPipe CGA] Saved CGA sprite to ${outputPath}`);
+ return outputPath;
+ } catch (error) {
+ console.error("Error in generateImageWithGemmaCGA:", error);
+ throw error;
+ }
+}