🧱 Spielchen gefällig? Tetris und Defender auf dem Dashboard

Hallo zusammen,

ich habe mal die „künstliche Inkompetenz“ herausgefordert, mit der Aufgabe: „kannst du für das lovelace-dashbord von home assistant eine karte erstellen, die wie das spiel tetris funktioniert“

Dabei ist das dabei herausgekommen

Der folgende Code muss als tetris.html im www - Verzeichnis abgelegt werden.

<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>HA Tetris Pro - Final Layout</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <style>
        :root {
            --primary-bg: #101216;
            --card-bg: #1c2028;
            --text-color: #e1e1e1;
            --accent-color: #03a9f4;
            --danger-color: #f44336;
            --border-radius: 12px;
        }

        body {
            background-color: var(--primary-bg);
            color: var(--text-color);
            font-family: 'Roboto', sans-serif;
            margin: 0;
            display: flex;
            flex-direction: column;
            height: 100vh;
            width: 100vw;
            overflow: hidden;
        }

        #header {
            flex: 0 0 auto;
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 15px;
            background-color: var(--card-bg);
            box-shadow: 0 2px 10px rgba(0,0,0,0.5);
            z-index: 20;
        }

        .stats-group { display: flex; gap: 12px; }
        .stat-box { font-weight: bold; font-size: 0.8em; line-height: 1.2; }
        .stat-box span { color: var(--accent-color); font-size: 1.1em; display: block; }

        #reset-btn {
            background-color: transparent;
            color: var(--danger-color);
            border: 1px solid var(--danger-color);
            border-radius: 6px;
            padding: 4px 8px;
            cursor: pointer;
            font-weight: bold;
            font-size: 0.75em;
        }

        /* Hauptcontainer mit verbesserter Platzverteilung */
        #main-content {
            flex: 1 1 auto;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: flex-start; /* Startet oben */
            padding: 10px;
            min-height: 0;
            box-sizing: border-box;
        }

        #game-container {
            flex: 1 1 auto; /* Nimmt verfĂĽgbaren Platz ein */
            display: flex;
            align-items: center;
            justify-content: center;
            width: 100%;
            min-height: 0; /* Erlaubt dem Canvas zu schrumpfen */
            position: relative;
            margin-bottom: 10px; /* Sicherheitsabstand zu den Buttons */
        }

        canvas {
            max-height: 100%;
            max-width: 100%;
            aspect-ratio: 240 / 400;
            background-color: #000;
            border-radius: var(--border-radius);
            border: 2px solid #333;
            box-shadow: 0 4px 15px rgba(0,0,0,0.6);
            object-fit: contain;
        }

        #pause-overlay {
            position: absolute;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.7);
            display: none;
            align-items: center;
            justify-content: center;
            font-size: 2em;
            font-weight: bold;
            color: var(--accent-color);
            border-radius: var(--border-radius);
            pointer-events: none;
        }

        /* Feste Steuerung am unteren Rand */
        #controls {
            flex: 0 0 auto;
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 8px;
            width: 100%;
            max-width: 300px;
            padding: 5px 0 15px 0;
            z-index: 20;
        }

        .btn {
            background-color: var(--card-bg);
            color: var(--text-color);
            border: 1px solid #444;
            border-radius: var(--border-radius);
            font-size: 1.3em;
            padding: 14px;
            text-align: center;
            cursor: pointer;
            user-select: none;
            box-shadow: 0 3px 5px rgba(0,0,0,0.3);
        }

        .btn:active { background-color: var(--accent-color); transform: scale(0.95); }
        .btn-rotate { grid-column: 2; grid-row: 1; border-color: var(--accent-color); color: var(--accent-color); }
        .btn-left { grid-column: 1; grid-row: 2; }
        .btn-down { grid-column: 2; grid-row: 2; }
        .btn-right { grid-column: 3; grid-row: 2; }

    </style>
</head>
<body>

    <div id="header">
        <div class="stats-group">
            <div class="stat-box">Score <span id="score">0</span></div>
            <div class="stat-box">Lvl <span id="level">1</span></div>
        </div>
        <button id="reset-btn" onclick="confirmReset()">NEU</button>
    </div>

    <div id="main-content">
        <div id="game-container" onclick="togglePause()">
            <canvas id="tetris" width="240" height="400"></canvas>
            <div id="pause-overlay">PAUSE</div>
        </div>

        <div id="controls">
            <div class="btn btn-rotate" onclick="playerRotate(1)">↻</div>
            <div class="btn btn-left" onclick="playerMove(-1)">â—€</div>
            <div class="btn btn-down" onclick="playerDrop()">â–Ľ</div>
            <div class="btn btn-right" onclick="playerMove(1)">â–¶</div>
        </div>
    </div>

    <script>
        const canvas = document.getElementById('tetris');
        const context = canvas.getContext('2d');
        const pauseOverlay = document.getElementById('pause-overlay');
        context.scale(20, 20);

        let paused = false;
        let dropCounter = 0;
        let dropInterval = 1000;
        let lastTime = 0;

        const colors = [null, '#FF4081', '#00E5FF', '#00E676', '#D500F9', '#FF9100', '#FFEA00', '#2979FF'];
        const arena = createMatrix(12, 20);
        const player = { pos: {x: 0, y: 0}, matrix: null, score: 0, lines: 0, level: 1 };

        function togglePause() {
            paused = !paused;
            pauseOverlay.style.display = paused ? 'flex' : 'none';
        }

        function confirmReset() {
            if (player.score === 0 || confirm("Neues Spiel starten?")) {
                arena.forEach(row => row.fill(0));
                player.score = 0; player.lines = 0; player.level = 1;
                dropInterval = 1000;
                paused = false;
                pauseOverlay.style.display = 'none';
                playerReset();
                updateScore();
            }
        }

        function arenaSweep() {
            let rowCount = 0;
            outer: for (let y = arena.length - 1; y > 0; --y) {
                for (let x = 0; x < arena[y].length; ++x) {
                    if (arena[y][x] === 0) continue outer;
                }
                const row = arena.splice(y, 1)[0].fill(0);
                arena.unshift(row);
                ++y; rowCount++;
            }
            if(rowCount > 0) {
                player.score += (rowCount * 10) * player.level;
                player.lines += rowCount;
                player.level = Math.floor(player.lines / 10) + 1;
                dropInterval = Math.max(100, 1000 - (player.level - 1) * 100);
                updateScore();
            }
        }

        function collide(arena, player) {
            const [m, o] = [player.matrix, player.pos];
            for (let y = 0; y < m.length; ++y) {
                for (let x = 0; x < m[y].length; ++x) {
                    if (m[y][x] !== 0 && (arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) return true;
                }
            }
            return false;
        }

        function createMatrix(w, h) {
            const matrix = [];
            while (h--) matrix.push(new Array(w).fill(0));
            return matrix;
        }

        function createPiece(type) {
            if (type === 'T') return [[0, 1, 0], [1, 1, 1], [0, 0, 0]];
            if (type === 'O') return [[2, 2], [2, 2]];
            if (type === 'L') return [[0, 3, 0], [0, 3, 0], [0, 3, 3]];
            if (type === 'J') return [[0, 4, 0], [0, 4, 0], [4, 4, 0]];
            if (type === 'I') return [[0, 5, 0, 0], [0, 5, 0, 0], [0, 5, 0, 0], [0, 5, 0, 0]];
            if (type === 'S') return [[0, 6, 6], [6, 6, 0], [0, 0, 0]];
            if (type === 'Z') return [[7, 7, 0], [0, 7, 7], [0, 0, 0]];
        }

        function draw() {
            context.fillStyle = '#000';
            context.fillRect(0, 0, canvas.width, canvas.height);
            // Raster
            context.lineWidth = 0.05;
            context.strokeStyle = 'rgba(255,255,255,0.1)';
            for (let x = 0; x <= 12; x++) { context.beginPath(); context.moveTo(x, 0); context.lineTo(x, 20); context.stroke(); }
            for (let y = 0; y <= 20; y++) { context.beginPath(); context.moveTo(0, y); context.lineTo(12, y); context.stroke(); }
            drawMatrix(arena, {x: 0, y: 0});
            drawMatrix(player.matrix, player.pos);
        }

        function drawMatrix(matrix, offset) {
            matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        context.fillStyle = colors[value];
                        context.beginPath();
                        context.roundRect(x + offset.x, y + offset.y, 0.95, 0.95, 0.1);
                        context.fill();
                    }
                });
            });
        }

        function merge(arena, player) {
            player.matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) arena[y + player.pos.y][x + player.pos.x] = value;
                });
            });
        }

        function playerDrop() {
            if (paused) return;
            player.pos.y++;
            if (collide(arena, player)) {
                player.pos.y--; merge(arena, player);
                playerReset(); arenaSweep();
            }
            dropCounter = 0;
        }

        function playerMove(dir) {
            if (paused) return;
            player.pos.x += dir;
            if (collide(arena, player)) player.pos.x -= dir;
        }

        function playerReset() {
            const pieces = 'ILJOTSZ';
            player.matrix = createPiece(pieces[pieces.length * Math.random() | 0]);
            player.pos.y = 0;
            player.pos.x = (arena[0].length / 2 | 0) - (player.matrix[0].length / 2 | 0);
            if (collide(arena, player)) {
                arena.forEach(row => row.fill(0));
                player.score = 0; player.lines = 0; player.level = 1;
                dropInterval = 1000; updateScore();
            }
        }

        function playerRotate(dir) {
            if (paused) return;
            const pos = player.pos.x;
            let offset = 1;
            rotate(player.matrix, dir);
            while (collide(arena, player)) {
                player.pos.x += offset;
                offset = -(offset + (offset > 0 ? 1 : -1));
                if (offset > player.matrix[0].length) {
                    rotate(player.matrix, -dir);
                    player.pos.x = pos; return;
                }
            }
        }

        function rotate(matrix, dir) {
            for (let y = 0; y < matrix.length; ++y) {
                for (let x = 0; x < y; ++x) {
                    [matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
                }
            }
            if (dir > 0) matrix.forEach(row => row.reverse());
            else matrix.reverse();
        }

        function update(time = 0) {
            const deltaTime = time - lastTime;
            lastTime = time;
            if (!paused) {
                dropCounter += deltaTime;
                if (dropCounter > dropInterval) playerDrop();
                draw();
            }
            requestAnimationFrame(update);
        }

        function updateScore() {
            document.getElementById('score').innerText = player.score;
            document.getElementById('level').innerText = player.level;
        }

        playerReset(); updateScore(); update();
    </script>
</body>
</html>

Der Code fĂĽr die Karte:

type: iframe
url: /local/tetris.html?v=4
aspect_ratio: 16:9

Es gibt auch eine Pause-Funktion, indem man in das Spielfeld klickt.

Hier noch ein paar Tipps:

  1. Voraussetzungen: Die Datei tetris.html muss im Ordner /config/www/ liegen , damit sie ĂĽber /local/tetris.html?v=4 erreichbar ist.
  2. Neustart-Hinweis: Einige Nutzer wissen evtl. nicht, dass man Home Assistant einmal neu starten muss, wenn man den www-Ordner zum ersten Mal erstellt hat.
  3. Anpassbarkeit: Man kann die Farben im :root-Bereich des CSS ganz einfach an das eigene Dashboard-Theme anpassen.

„Es gibt 1000 Dinge, die die Welt nicht braucht. Dieses hier ist wahrscheinlich eines davon.“

13 „Gefällt mir“

Da kommt das Kind im Mann wieder hervor! :wink: aber auf die Idee muss mal erst mal kommen! :+1:

5 „Gefällt mir“

Ich bin froh, dass es mit 69 noch so ist. :slightly_smiling_face:

5 „Gefällt mir“

Zugabe:

Ein weiterer Klassiker, mit dem ich so manche Mark in Spielautomaten versenkt habe.

# Defender :rocket:

Kann auch mit Cursor-Tasten gespielt werden.

7 „Gefällt mir“

Coole Idee, das probiere ich auf jeden Fall mal aus, auch wenn man es eigentlich nicht wirklich braucht :grinning_face: Defender finde ich sogar noch besser. :slight_smile:

Ich glaub, ihr habt einfach zu viel Zeit… ich sollte euch mal ein paar Aufgaben geben :rofl:

Aber mal im Ernst, finde ich immer wieder mega, wenn ihr „Alten“ solche Sachen teilt! Kenn auch genug im höheren Alter, die mit Technik gar nichts anfangen können und schon Probleme haben, den PC überhaupt einzuschalten. Daher haltet euch das!

6 „Gefällt mir“

Freut mich wenn’s gefällt.

Hier der Code fĂĽr Defender.

<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>HA Defender Pro - Keyboard Support</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <style>
        :root {
            --primary-bg: #050507;
            --card-bg: #1c2028;
            --accent-color: #03a9f4;
            --danger-color: #f44336;
            --ship-color: #e1e1e1;
            --laser-color: #00ff00;
            --ufo-eye: #ffeb3b;
            --text-color: #e1e1e1;
            --border-radius: 12px;
        }
        body {
            background-color: var(--primary-bg);
            color: var(--text-color);
            font-family: 'Roboto', sans-serif;
            margin: 0;
            display: flex;
            flex-direction: column;
            height: 100vh;
            overflow: hidden;
        }
        #header {
            flex: 0 0 auto;
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 10px 15px;
            background: var(--card-bg);
            box-shadow: 0 2px 10px rgba(0,0,0,0.5);
            z-index: 10;
        }
        #score-box { font-size: 1.2em; font-weight: bold; }
        #score { color: var(--accent-color); }
        
        #game-container {
            flex: 1 1 auto;
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 10px;
            min-height: 0;
            background: radial-gradient(circle at center, #1a1a22 0%, #050507 100%);
        }
        canvas {
            background: transparent;
            width: 100%;
            max-height: 100%;
            border: 2px solid #333;
            border-radius: var(--border-radius);
            touch-action: none;
            box-shadow: 0 0 20px rgba(3, 169, 244, 0.2);
        }
        
        #controls {
            flex: 0 0 auto;
            display: grid;
            grid-template-columns: 1fr 1fr 1fr;
            gap: 10px;
            padding: 15px;
            max-width: 400px;
            margin: 0 auto;
            z-index: 10;
        }
        .btn {
            background: var(--card-bg);
            color: white;
            border: 1px solid #444;
            padding: 15px;
            text-align: center;
            border-radius: var(--border-radius);
            font-size: 1.5em;
            user-select: none;
            cursor: pointer;
            box-shadow: 0 4px 6px rgba(0,0,0,0.3);
            transition: background 0.1s;
        }
        .btn:active { background: #333; }
        .btn-up:active { background-color: var(--accent-color); }
        .btn-down:active { background-color: var(--accent-color); }
        .btn-fire { background: var(--danger-color); border-color: #b71c1c; grid-column: 2; font-weight: bold;}
        .btn-fire:active { background: #b71c1c; }

        #game-over-msg {
            position: absolute;
            color: white;
            font-size: 3em;
            font-weight: bold;
            text-align: center;
            display: none;
            text-shadow: 0 0 10px var(--danger-color);
            pointer-events: none;
        }
    </style>
</head>
<body>
    <div id="header">
        <div id="score-box">Score: <span id="score">0</span></div>
        <button id="reset" style="background:none; border:1px solid #444; color:white; padding: 5px 10px; border-radius:4px; cursor:pointer;" onclick="resetGame()">Neu starten</button>
    </div>
    <div id="game-container">
        <canvas id="gameCanvas"></canvas>
        <div id="game-over-msg">GAME OVER</div>
    </div>
    <div id="controls">
        <div class="btn btn-up" onmousedown="moveUp()" onmouseup="stopMove()" ontouchstart="moveUp()" ontouchend="stopMove()">â–˛</div>
        <div class="btn btn-fire" onclick="fire()">FEUER</div>
        <div class="btn btn-down" onmousedown="moveDown()" onmouseup="stopMove()" ontouchstart="moveDown()" ontouchend="stopMove()">â–Ľ</div>
    </div>

<script>
    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    const scoreEl = document.getElementById('score');
    const gameOverEl = document.getElementById('game-over-msg');

    canvas.width = 800;
    canvas.height = 400;

    let score = 0;
    let gameActive = true;
    let frameCount = 0;

    let ship = { x: 80, y: 200, w: 40, h: 18, dy: 0 };
    let bullets = [];
    let enemies = [];
    let stars = Array.from({length: 80}, () => ({
        x: Math.random() * 800, y: Math.random() * 400,
        s: Math.random() * 2 + 0.5, v: Math.random() * 0.5 + 0.5
    }));

    function getCol(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }

    // --- NEU: Tastatur Steuerung ---
    window.addEventListener('keydown', (e) => {
        if (!gameActive) return;
        if (e.key === 'ArrowUp') moveUp();
        if (e.key === 'ArrowDown') moveDown();
        if (e.key === ' ' || e.code === 'Space') {
            e.preventDefault(); // Verhindert Scrollen der Seite
            fire();
        }
    });

    window.addEventListener('keyup', (e) => {
        if (e.key === 'ArrowUp' || e.key === 'ArrowDown') stopMove();
    });

    // --- Funktionen ---
    function moveUp() { if(gameActive) ship.dy = -6; }
    function moveDown() { if(gameActive) ship.dy = 6; }
    function stopMove() { ship.dy = 0; }
    function fire() {
        if(!gameActive) return;
        bullets.push({ x: ship.x + ship.w - 5, y: ship.y + ship.h/2, w: 15, h: 2, speed: 12 });
    }

    function spawnEnemy() {
        if (Math.random() < 0.025 + (score/10000)) {
            enemies.push({ x: 800, y: Math.random() * 360 + 20, w: 30, h: 22, speed: 2.5 + Math.random() * 2, animFrame: Math.random() * 10 });
        }
    }

    function resetGame() {
        score = 0; scoreEl.innerText = score;
        ship.y = 200; ship.dy = 0;
        bullets = []; enemies = [];
        gameActive = true;
        gameOverEl.style.display = 'none';
    }

    function gameOver() { gameActive = false; gameOverEl.style.display = 'block'; }

    // --- Rendering ---
    function drawDefenderShip(ctx, s) {
        ctx.save();
        ctx.translate(s.x, s.y);
        if (gameActive && frameCount % 4 < 3) {
            ctx.fillStyle = (frameCount % 2 === 0) ? '#ff9800' : '#f44336';
            ctx.beginPath(); ctx.moveTo(-5, s.h/2 - 3); ctx.lineTo(-15 - (Math.random()*5), s.h/2); ctx.lineTo(-5, s.h/2 + 3); ctx.fill();
        }
        ctx.fillStyle = getCol('--ship-color');
        ctx.beginPath(); ctx.moveTo(0, s.h/2); ctx.lineTo(5, 0); ctx.lineTo(s.w - 10, 2); ctx.lineTo(s.w, s.h/2); ctx.lineTo(s.w - 10, s.h - 2); ctx.lineTo(5, s.h); ctx.closePath(); ctx.fill();
        ctx.fillStyle = getCol('--accent-color');
        ctx.beginPath(); ctx.moveTo(s.w - 15, 5); ctx.lineTo(s.w - 5, s.h/2); ctx.lineTo(s.w - 15, s.h - 5); ctx.fill();
        ctx.restore();
    }

    function drawUfoEnemy(ctx, e) {
        ctx.save();
        ctx.translate(e.x, e.y);
        const f = Math.floor((frameCount + e.animFrame) / 6) % 3;
        ctx.fillStyle = getCol('--danger-color');
        ctx.beginPath(); ctx.ellipse(e.w/2, e.h/2 - 2, e.w/2, e.h/3, 0, 0, Math.PI * 2); ctx.fill();
        ctx.fillStyle = '#ffcdd2';
        ctx.beginPath(); ctx.arc(e.w/2, e.h/2 - 4, e.w/4, Math.PI, 0); ctx.fill();
        if (frameCount % 10 < 8) { ctx.fillStyle = getCol('--ufo-eye'); ctx.fillRect(e.w/2 - 2, e.h/2 - 4, 4, 4); }
        ctx.strokeStyle = getCol('--danger-color'); ctx.lineWidth = 2; ctx.beginPath();
        ctx.moveTo(5, e.h - 5); ctx.lineTo(5 + (f===0?2:0), e.h);
        ctx.moveTo(e.w - 5, e.h - 5); ctx.lineTo(e.w - 5 - (f===1?2:0), e.h);
        ctx.moveTo(e.w/2, e.h - 4); ctx.lineTo(e.w/2, e.h - (f===2?0:3)); ctx.stroke();
        ctx.restore();
    }

    function update() {
        if (!gameActive) return;
        frameCount++;
        ship.y += ship.dy;
        if (ship.y < 20) ship.y = 20;
        if (ship.y > canvas.height - ship.h - 20) ship.y = canvas.height - ship.h - 20;
        bullets.forEach((b, i) => { b.x += b.speed; if (b.x > 800) bullets.splice(i, 1); });
        enemies.forEach((e, i) => {
            e.x -= e.speed;
            if (e.x < -40) enemies.splice(i, 1);
            if (ship.x < e.x + e.w && ship.x + ship.w > e.x && ship.y < e.y + e.h && ship.y + ship.h > e.y) gameOver();
            bullets.forEach((b, bi) => {
                if (b.x > e.x && b.x < e.x + e.w && b.y > e.y - 2 && b.y < e.y + e.h + 2) {
                    enemies.splice(i, 1); bullets.splice(bi, 1);
                    score += 100; scoreEl.innerText = score;
                }
            });
        });
        stars.forEach(s => { s.x -= s.v; if (s.x < 0) s.x = 800; });
        spawnEnemy();
    }

    function draw() {
        ctx.fillStyle = 'rgba(5, 5, 7, 0.3)';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.fillStyle = 'white';
        stars.forEach(s => ctx.fillRect(s.x, s.y, s.s, s.s));
        ctx.strokeStyle = getCol('--laser-color'); ctx.lineWidth = 2;
        bullets.forEach(b => { ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x + b.w, b.y); ctx.stroke(); });
        enemies.forEach(e => drawUfoEnemy(ctx, e));
        drawDefenderShip(ctx, ship);
    }

    function loop() { update(); draw(); requestAnimationFrame(loop); }
    resetGame(); loop();
</script>
</body>
</html>
7 „Gefällt mir“

Da geht 'ne Menge:
Space Invaders ____________________Pac Man

Asteroids __________________________Centipede

3 „Gefällt mir“

Geiler Scheixx :wink:

Der Pac Man Code wĂĽrde mich interessieren

Coole Idee… klappt nur nicht bei mir :slight_smile:

Wirklich lustig und klappt mit Tastatur einwandfrei. Leider bewegt sich auch der Bildschirm, wenn ich die Maustasten hoch/runter nutze. Muss wohl ein Spiel auswählen, wo ich rechts, links statt oben, unten klicken muss. :rofl:

Das ist schade. Läuft bei mir sogar auf’m Handy mit der Copmanion-App.
Evtl. tut sich ja was, wenn du aspect-ratio änderst- 16;9 - 5:4 oder so

Ich schaue mal, ob noch was geht.

1 „Gefällt mir“

Das war der Fehler → so geht es url: /local/tetris.html?v=4

1 „Gefällt mir“

Danke, jetzt geht es auch bei mir.

1 „Gefällt mir“
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>HA Pac-Man</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <style>
        :root {
            --bg-color: #050507;
            --wall-color: #2121ff;
            --pacman-color: #ffff00;
            --dot-color: #ffb8ae;
            --card-bg: #1c2028;
        }
        body {
            background-color: var(--bg-color);
            color: white;
            margin: 0;
            display: flex;
            flex-direction: column;
            height: 100vh;
            font-family: 'Courier New', Courier, monospace;
            overflow: hidden;
        }
        #header {
            flex: 0 0 auto;
            display: flex;
            justify-content: space-between;
            padding: 10px 20px;
            background: #111;
            border-bottom: 2px solid #333;
        }
        #game-container {
            flex: 1 1 auto;
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            min-height: 0;
        }
        canvas {
            background: #000;
            max-height: 100%;
            max-width: 100%;
            border: 2px solid #333;
            image-rendering: pixelated;
        }
        #controls {
            flex: 0 0 auto;
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 10px;
            padding: 15px;
            max-width: 300px;
            margin: 0 auto;
        }
        .btn {
            background: var(--card-bg);
            color: white;
            border: 1px solid #444;
            padding: 15px;
            text-align: center;
            border-radius: 8px;
            font-size: 1.5em;
            user-select: none;
        }
        .btn:active { background: #444; }
        .btn-up { grid-column: 2; }
        .btn-left { grid-column: 1; }
        .btn-down { grid-column: 2; }
        .btn-right { grid-column: 3; }
    </style>
</head>
<body>
    <div id="header">
        <div>SCORE: <span id="score">0</span></div>
        <button onclick="resetGame()" style="background:none; border:1px solid #444; color:white; border-radius:4px;">RESET</button>
    </div>
    <div id="game-container">
        <canvas id="pacmanCanvas"></canvas>
    </div>
    <div id="controls">
        <div class="btn btn-up" onclick="changeDir(0, -1)">â–˛</div>
        <div class="btn btn-left" onclick="changeDir(-1, 0)">â—€</div>
        <div class="btn btn-down" onclick="changeDir(0, 1)">â–Ľ</div>
        <div class="btn btn-right" onclick="changeDir(1, 0)">â–¶</div>
    </div>

<script>
    const canvas = document.getElementById('pacmanCanvas');
    const ctx = canvas.getContext('2d');
    const scoreEl = document.getElementById('score');

    const TILE_SIZE = 20;
    const map = [
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
        [1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
        [1,2,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,2,1],
        [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
        [1,0,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,0,1],
        [1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1],
        [1,1,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,1,1],
        [1,1,1,1,0,1,0,0,0,0,0,0,0,1,0,1,1,1,1],
        [1,1,1,1,0,1,0,1,1,0,1,1,0,1,0,1,1,1,1],
        [0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0],
        [1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1],
        [1,1,1,1,0,1,0,0,0,0,0,0,0,1,0,1,1,1,1],
        [1,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,1,1,1],
        [1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
        [1,0,1,1,0,1,1,1,0,1,0,1,1,1,0,1,1,0,1],
        [1,2,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,2,1],
        [1,1,0,1,0,1,0,1,1,1,1,1,0,1,0,1,0,1,1],
        [1,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0,1],
        [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
    ];

    canvas.width = map[0].length * TILE_SIZE;
    canvas.height = map.length * TILE_SIZE;

    let score = 0;
    let powerMode = 0;
    let gameActive = true;

    const pacman = { x: 9, y: 15, dirX: 0, dirY: 0, nextDirX: 0, nextDirY: 0, anim: 0 };
    const ghosts = [
        { x: 9, y: 9, color: 'red', dirX: 1, dirY: 0 },
        { x: 8, y: 9, color: 'pink', dirX: -1, dirY: 0 },
        { x: 10, y: 9, color: 'cyan', dirX: 0, dirY: -1 }
    ];

    let dots = [];
    for(let y=0; y<map.length; y++) {
        for(let x=0; x<map[y].length; x++) {
            if(map[y][x] === 0 || map[y][x] === 2) dots.push({x, y, power: map[y][x] === 2, eaten: false});
        }
    }

    function changeDir(x, y) {
        pacman.nextDirX = x;
        pacman.nextDirY = y;
    }

    window.addEventListener('keydown', (e) => {
        if(e.key === 'ArrowUp') changeDir(0, -1);
        if(e.key === 'ArrowDown') changeDir(0, 1);
        if(e.key === 'ArrowLeft') changeDir(-1, 0);
        if(e.key === 'ArrowRight') changeDir(1, 0);
    });

    function update() {
        if(!gameActive) return;

        // Pacman Bewegung Logik
        if(Number.isInteger(pacman.x) && Number.isInteger(pacman.y)) {
            if(map[pacman.y + pacman.nextDirY]?.[pacman.x + pacman.nextDirX] !== 1) {
                pacman.dirX = pacman.nextDirX;
                pacman.dirY = pacman.nextDirY;
            }
            if(map[pacman.y + pacman.dirY]?.[pacman.x + pacman.dirX] === 1) {
                pacman.dirX = 0; pacman.dirY = 0;
            }
        }

        pacman.x += pacman.dirX * 0.1;
        pacman.y += pacman.dirY * 0.1;
        pacman.x = Math.round(pacman.x * 10) / 10;
        pacman.y = Math.round(pacman.y * 10) / 10;

        // Tunnel
        if(pacman.x < 0) pacman.x = map[0].length - 1;
        if(pacman.x > map[0].length - 1) pacman.x = 0;

        // Essen
        dots.forEach(d => {
            if(!d.eaten && Math.abs(pacman.x - d.x) < 0.5 && Math.abs(pacman.y - d.y) < 0.5) {
                d.eaten = true;
                score += d.power ? 50 : 10;
                if(d.power) powerMode = 500;
                scoreEl.innerText = score;
            }
        });

        if(powerMode > 0) powerMode--;

        // Geister
        ghosts.forEach(g => {
            if(Number.isInteger(g.x) && Number.isInteger(g.y)) {
                const dirs = [[0,1], [0,-1], [1,0], [-1,0]].filter(d => map[g.y+d[1]]?.[g.x+d[0]] !== 1 && (d[0] !== -g.dirX || d[1] !== -g.dirY));
                const dir = dirs[Math.floor(Math.random() * dirs.length)];
                if(dir) { g.dirX = dir[0]; g.dirY = dir[1]; }
            }
            g.x += g.dirX * 0.08;
            g.y += g.dirY * 0.08;
            g.x = Math.round(g.x * 100) / 100;
            g.y = Math.round(g.y * 100) / 100;

            if(Math.abs(pacman.x - g.x) < 0.6 && Math.abs(pacman.y - g.y) < 0.6) {
                if(powerMode > 0) { g.x = 9; g.y = 9; score += 200; }
                else gameActive = false;
            }
        });
    }

    function draw() {
        ctx.fillStyle = 'black';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        // Map
        for(let y=0; y<map.length; y++) {
            for(let x=0; x<map[y].length; x++) {
                if(map[y][x] === 1) {
                    ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--wall-color');
                    ctx.fillRect(x*TILE_SIZE+2, y*TILE_SIZE+2, TILE_SIZE-4, TILE_SIZE-4);
                }
            }
        }

        // Dots
        dots.forEach(d => {
            if(!d.eaten) {
                ctx.fillStyle = d.power ? 'white' : getComputedStyle(document.documentElement).getPropertyValue('--dot-color');
                ctx.beginPath();
                ctx.arc(d.x*TILE_SIZE+TILE_SIZE/2, d.y*TILE_SIZE+TILE_SIZE/2, d.power ? 6 : 2, 0, Math.PI*2);
                ctx.fill();
            }
        });

        // Pacman
        ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--pacman-color');
        ctx.beginPath();
        ctx.arc(pacman.x*TILE_SIZE+TILE_SIZE/2, pacman.y*TILE_SIZE+TILE_SIZE/2, TILE_SIZE/2-2, 0.2 * Math.PI, 1.8 * Math.PI);
        ctx.lineTo(pacman.x*TILE_SIZE+TILE_SIZE/2, pacman.y*TILE_SIZE+TILE_SIZE/2);
        ctx.fill();

        // Geister
        ghosts.forEach(g => {
            ctx.fillStyle = powerMode > 0 ? (powerMode < 100 && frameCount % 20 < 10 ? 'white' : 'blue') : g.color;
            ctx.beginPath();
            ctx.arc(g.x*TILE_SIZE+TILE_SIZE/2, g.y*TILE_SIZE+TILE_SIZE/2, TILE_SIZE/2-2, Math.PI, 0);
            ctx.lineTo(g.x*TILE_SIZE+TILE_SIZE-2, g.y*TILE_SIZE+TILE_SIZE-2);
            ctx.lineTo(g.x*TILE_SIZE+2, g.y*TILE_SIZE+TILE_SIZE-2);
            ctx.fill();
        });

        if(!gameActive) {
            ctx.fillStyle = 'white';
            ctx.font = '24px Courier';
            ctx.textAlign = 'center';
            ctx.fillText('GAME OVER', canvas.width/2, canvas.height/2);
        }
    }

    let frameCount = 0;
    function loop() {
        frameCount++;
        update();
        draw();
        requestAnimationFrame(loop);
    }
    loop();

    function resetGame() {
        pacman.x = 9; pacman.y = 15; pacman.dirX = 0; pacman.dirY = 0;
        dots.forEach(d => d.eaten = false);
        score = 0; scoreEl.innerText = score;
        gameActive = true;
        powerMode = 0;
        ghosts.forEach((g, i) => { g.x = 8+i; g.y = 9; });
    }
</script>
</body>
</html>
1 „Gefällt mir“

Guten Morgen, @Schorsch war so nett und hat mir alle Spiele zur Verfügung gestellt. Also habe ich mal gebastelt. So hat man ein kleines Game-Center und muss sich nicht für ein Spiel entscheiden bzw. braucht nur eine Datei für alle Spiele. Die HTML Datei fasst also alle Spiele zusammen und die Card ist im Dark-Mode gestaltet. Wenn ihr lieber den Light-Mode habt, könnt ihr die Farben oben im ersten Teil vom HTML anpassen. Die Tastatursteuerung funktioniert überall, bis auf Tetris, aber da hatte ich jetzt noch nicht weiter geschaut.

Die games.html dann einfach wieder in den www-Ordner packen. Ich kann sie hier nicht bereitsstellen, weil die Datei zu viele Zeichen hat. Ihr könnt sie hier bei mir im github finden und kopieren oder herunterladen.

https://github.com/jayjojayson/HomeAssistant-Tools_Utilities_Gadgets/blob/main/Dashboard-Custom-Cards/HA_Games-Center/games.html

Ich habe es bei mir so eingebunden:

type: iframe
url: /local/games.html
aspect_ratio: "1:1"
grid_options:
  columns: full
  rows: 9

So haben alle Spiele-Fans am Wochenende etwas zu tun. :wink:

8 „Gefällt mir“

@Schorsch und @jayjojayson - GroĂźartige Idee! und tolle Umsetzung! :folded_hands::+1:

2 „Gefällt mir“

Hallo Jan,

Kompliment fĂĽr die Umsetzung. Sehr gut gelungen.
Es ist bemerkenswert, wie viel Charme und Magie in Retro-Games steckt.

Hier der Code fĂĽr Tetris mit Tastatursteuerung:

<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>HA Tetris Pro - Keyboard Support</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <style>
        :root {
            --primary-bg: #101216;
            --card-bg: #1c2028;
            --text-color: #e1e1e1;
            --accent-color: #03a9f4;
            --danger-color: #f44336;
            --border-radius: 12px;
        }

        body {
            background-color: var(--primary-bg);
            color: var(--text-color);
            font-family: 'Roboto', sans-serif;
            margin: 0;
            display: flex;
            flex-direction: column;
            height: 100vh;
            width: 100vw;
            overflow: hidden;
        }

        #header {
            flex: 0 0 auto;
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 8px 15px;
            background-color: var(--card-bg);
            box-shadow: 0 2px 10px rgba(0,0,0,0.5);
            z-index: 20;
        }

        .stats-group { display: flex; gap: 12px; }
        .stat-box { font-weight: bold; font-size: 0.8em; line-height: 1.2; }
        .stat-box span { color: var(--accent-color); font-size: 1.1em; display: block; }

        #reset-btn {
            background-color: transparent;
            color: var(--danger-color);
            border: 1px solid var(--danger-color);
            border-radius: 6px;
            padding: 4px 8px;
            cursor: pointer;
            font-weight: bold;
            font-size: 0.75em;
        }

        #main-content {
            flex: 1 1 auto;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: flex-start;
            padding: 10px;
            min-height: 0;
            box-sizing: border-box;
        }

        #game-container {
            flex: 1 1 auto;
            display: flex;
            align-items: center;
            justify-content: center;
            width: 100%;
            min-height: 0;
            position: relative;
            margin-bottom: 10px;
        }

        canvas {
            max-height: 100%;
            max-width: 100%;
            aspect-ratio: 240 / 400;
            background-color: #000;
            border-radius: var(--border-radius);
            border: 2px solid #333;
            box-shadow: 0 4px 15px rgba(0,0,0,0.6);
            object-fit: contain;
        }

        #pause-overlay {
            position: absolute;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.7);
            display: none;
            align-items: center;
            justify-content: center;
            font-size: 2em;
            font-weight: bold;
            color: var(--accent-color);
            border-radius: var(--border-radius);
            pointer-events: none;
        }

        #controls {
            flex: 0 0 auto;
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            gap: 8px;
            width: 100%;
            max-width: 300px;
            padding: 5px 0 15px 0;
            z-index: 20;
        }

        .btn {
            background-color: var(--card-bg);
            color: var(--text-color);
            border: 1px solid #444;
            border-radius: var(--border-radius);
            font-size: 1.3em;
            padding: 14px;
            text-align: center;
            cursor: pointer;
            user-select: none;
            box-shadow: 0 3px 5px rgba(0,0,0,0.3);
        }

        .btn:active { background-color: var(--accent-color); transform: scale(0.95); }
        .btn-rotate { grid-column: 2; grid-row: 1; border-color: var(--accent-color); color: var(--accent-color); }
        .btn-left { grid-column: 1; grid-row: 2; }
        .btn-down { grid-column: 2; grid-row: 2; }
        .btn-right { grid-column: 3; grid-row: 2; }

    </style>
</head>
<body>

    <div id="header">
        <div class="stats-group">
            <div class="stat-box">Score <span id="score">0</span></div>
            <div class="stat-box">Lvl <span id="level">1</span></div>
        </div>
        <button id="reset-btn" onclick="confirmReset()">NEU</button>
    </div>

    <div id="main-content">
        <div id="game-container" onclick="togglePause()">
            <canvas id="tetris" width="240" height="400"></canvas>
            <div id="pause-overlay">PAUSE</div>
        </div>

        <div id="controls">
            <div class="btn btn-rotate" onclick="playerRotate(1)">↻</div>
            <div class="btn btn-left" onclick="playerMove(-1)">â—€</div>
            <div class="btn btn-down" onclick="playerDrop()">â–Ľ</div>
            <div class="btn btn-right" onclick="playerMove(1)">â–¶</div>
        </div>
    </div>

    <script>
        const canvas = document.getElementById('tetris');
        const context = canvas.getContext('2d');
        const pauseOverlay = document.getElementById('pause-overlay');
        context.scale(20, 20);

        let paused = false;
        let dropCounter = 0;
        let dropInterval = 1000;
        let lastTime = 0;

        const colors = [null, '#FF4081', '#00E5FF', '#00E676', '#D500F9', '#FF9100', '#FFEA00', '#2979FF'];
        const arena = createMatrix(12, 20);
        const player = { pos: {x: 0, y: 0}, matrix: null, score: 0, lines: 0, level: 1 };

        // Tastatur-Steuerung hinzufĂĽgen
        document.addEventListener('keydown', event => {
            if (event.keyCode === 37) { // Pfeil links
                playerMove(-1);
            } else if (event.keyCode === 39) { // Pfeil rechts
                playerMove(1);
            } else if (event.keyCode === 40) { // Pfeil unten
                playerDrop();
            } else if (event.keyCode === 38 || event.keyCode === 32) { // Pfeil oben oder Leertaste
                playerRotate(1);
            } else if (event.keyCode === 80) { // 'P' fĂĽr Pause
                togglePause();
            }
        });

        function togglePause() {
            paused = !paused;
            pauseOverlay.style.display = paused ? 'flex' : 'none';
        }

        function confirmReset() {
            if (player.score === 0 || confirm("Neues Spiel starten?")) {
                arena.forEach(row => row.fill(0));
                player.score = 0; player.lines = 0; player.level = 1;
                dropInterval = 1000;
                paused = false;
                pauseOverlay.style.display = 'none';
                playerReset();
                updateScore();
            }
        }

        function arenaSweep() {
            let rowCount = 0;
            outer: for (let y = arena.length - 1; y > 0; --y) {
                for (let x = 0; x < arena[y].length; ++x) {
                    if (arena[y][x] === 0) continue outer;
                }
                const row = arena.splice(y, 1)[0].fill(0);
                arena.unshift(row);
                ++y; rowCount++;
            }
            if(rowCount > 0) {
                player.score += (rowCount * 10) * player.level;
                player.lines += rowCount;
                player.level = Math.floor(player.lines / 10) + 1;
                dropInterval = Math.max(100, 1000 - (player.level - 1) * 100);
                updateScore();
            }
        }

        function collide(arena, player) {
            const [m, o] = [player.matrix, player.pos];
            for (let y = 0; y < m.length; ++y) {
                for (let x = 0; x < m[y].length; ++x) {
                    if (m[y][x] !== 0 && (arena[y + o.y] && arena[y + o.y][x + o.x]) !== 0) return true;
                }
            }
            return false;
        }

        function createMatrix(w, h) {
            const matrix = [];
            while (h--) matrix.push(new Array(w).fill(0));
            return matrix;
        }

        function createPiece(type) {
            if (type === 'T') return [[0, 1, 0], [1, 1, 1], [0, 0, 0]];
            if (type === 'O') return [[2, 2], [2, 2]];
            if (type === 'L') return [[0, 3, 0], [0, 3, 0], [0, 3, 3]];
            if (type === 'J') return [[0, 4, 0], [0, 4, 0], [4, 4, 0]];
            if (type === 'I') return [[0, 5, 0, 0], [0, 5, 0, 0], [0, 5, 0, 0], [0, 5, 0, 0]];
            if (type === 'S') return [[0, 6, 6], [6, 6, 0], [0, 0, 0]];
            if (type === 'Z') return [[7, 7, 0], [0, 7, 7], [0, 0, 0]];
        }

        function draw() {
            context.fillStyle = '#000';
            context.fillRect(0, 0, canvas.width, canvas.height);
            context.lineWidth = 0.05;
            context.strokeStyle = 'rgba(255,255,255,0.1)';
            for (let x = 0; x <= 12; x++) { context.beginPath(); context.moveTo(x, 0); context.lineTo(x, 20); context.stroke(); }
            for (let y = 0; y <= 20; y++) { context.beginPath(); context.moveTo(0, y); context.lineTo(12, y); context.stroke(); }
            drawMatrix(arena, {x: 0, y: 0});
            drawMatrix(player.matrix, player.pos);
        }

        function drawMatrix(matrix, offset) {
            matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        context.fillStyle = colors[value];
                        context.beginPath();
                        context.roundRect(x + offset.x, y + offset.y, 0.95, 0.95, 0.1);
                        context.fill();
                    }
                });
            });
        }

        function merge(arena, player) {
            player.matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) arena[y + player.pos.y][x + player.pos.x] = value;
                });
            });
        }

        function playerDrop() {
            if (paused) return;
            player.pos.y++;
            if (collide(arena, player)) {
                player.pos.y--; merge(arena, player);
                playerReset(); arenaSweep();
            }
            dropCounter = 0;
        }

        function playerMove(dir) {
            if (paused) return;
            player.pos.x += dir;
            if (collide(arena, player)) player.pos.x -= dir;
        }

        function playerReset() {
            const pieces = 'ILJOTSZ';
            player.matrix = createPiece(pieces[pieces.length * Math.random() | 0]);
            player.pos.y = 0;
            player.pos.x = (arena[0].length / 2 | 0) - (player.matrix[0].length / 2 | 0);
            if (collide(arena, player)) {
                arena.forEach(row => row.fill(0));
                player.score = 0; player.lines = 0; player.level = 1;
                dropInterval = 1000; updateScore();
            }
        }

        function playerRotate(dir) {
            if (paused) return;
            const pos = player.pos.x;
            let offset = 1;
            rotate(player.matrix, dir);
            while (collide(arena, player)) {
                player.pos.x += offset;
                offset = -(offset + (offset > 0 ? 1 : -1));
                if (offset > player.matrix[0].length) {
                    rotate(player.matrix, -dir);
                    player.pos.x = pos; return;
                }
            }
        }

        function rotate(matrix, dir) {
            for (let y = 0; y < matrix.length; ++y) {
                for (let x = 0; x < y; ++x) {
                    [matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
                }
            }
            if (dir > 0) matrix.forEach(row => row.reverse());
            else matrix.reverse();
        }

        function update(time = 0) {
            const deltaTime = time - lastTime;
            lastTime = time;
            if (!paused) {
                dropCounter += deltaTime;
                if (dropCounter > dropInterval) playerDrop();
                draw();
            }
            requestAnimationFrame(update);
        }

        function updateScore() {
            document.getElementById('score').innerText = player.score;
            document.getElementById('level').innerText = player.level;
        }

        playerReset(); updateScore(); update();
    </script>
</body>
</html>

Ich hätte auch noch was für die grauen Zellen. @jayjojayson , bei Bedarf bitte melden.
Mastermind _______________________Minesweeper______________2048

1 „Gefällt mir“

Das schaue ich mir nochmal an mit deiner Version fĂĽr Tetris, dann baue ich die ver. mit ein. :slight_smile:

Wenn die Enkelkinder dann mal wieder vor Ort sind, könnt ihr sie jetzt auch vor HA setzen und beschäftigen.. :rofl:

1 „Gefällt mir“

Cool! Das wäre doch auch mal was für eine custom HACS card :wink:

1 „Gefällt mir“