Navigation


Kategorien


🎙️ Podcast


Kundenbereich


🏷️ Tags


Weitere Seiten

Logo

Katschmarz Software

Professionelle Softwareentwicklung

C64 Sprite Editor – Pixel-Art im Retro-Look direkt im Browser

Kategorie: Code-Snippets

Tags: C64

Erstellt am: 2026-03-01 16:04:17

Der Commodore 64 war in den 80ern die Heimcomputer-Plattform schlechthin – und ein großer Teil seines Charmes steckte in den charakteristischen Hardware-Sprites: knackige 24×21-Pixel-Figuren, die direkt auf dem Bildschirm animiert werden konnten, ohne dass die CPU die Grafik manuell neuzeichnen musste.

Dieser browserbasierte Sprite Editor bringt genau dieses Erlebnis zurück – ohne Emulator, ohne Installation, direkt im Tab.

Was du damit machen kannst:

Du zeichnest Sprites pixelgenau auf einem 24×21-Raster und hast dabei die originalen 16 C64-Farben zur Auswahl. Der Editor unterstützt beide Grafikmodi des C64: im Hi-Res-Modus hast du volle Pixelauflösung mit einer Farbe pro Sprite, im Multicolor-Modus teilst du die Breite auf 12 Doppelpixel auf und gewinnst dafür drei zusätzliche Farben – genau wie auf echter Hardware. Zeichenwerkzeuge wie Flood Fill, Linienzug, Flip und Shift machen das Pixeln komfortabel.

Für Animationen legst du mehrere Frames an und schaust dir das Ergebnis direkt in der Vorschau an. Wenn du fertig bist, exportiert der Editor deinen Sprite als BASIC DATA-Zeilen, Hex-Bytes oder Assembler .BYTE-Direktiven – bereit zum Eintippen in deinen C64 oder einen Emulator wie VICE.

Das Ganze läuft als einzelne HTML-Datei, ganz ohne Framework oder Backend.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>C64 Sprite Editor</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=VT323&family=Share+Tech+Mono&display=swap');

  :root {
    --c64-bg: #3540A0;
    --c64-border: #4040B0;
    --c64-text: #7878F8;
    --c64-bright: #A0A8FF;
    --c64-white: #FFFFFF;
    --c64-black: #000000;
    --c64-cyan: #68D8E8;
    --c64-yellow: #F8D838;
    --c64-red: #B84040;
    --grid-bg: #000010;
    --pixel-size: 20px;
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    background: #1a1a2e;
    background-image:
      radial-gradient(ellipse at 20% 50%, rgba(53,64,160,0.4) 0%, transparent 60%),
      radial-gradient(ellipse at 80% 20%, rgba(53,64,160,0.3) 0%, transparent 50%);
    min-height: 100vh;
    font-family: 'VT323', monospace;
    color: var(--c64-text);
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 20px;
  }

  /* CRT scanline effect */
  body::after {
    content: '';
    position: fixed;
    inset: 0;
    background: repeating-linear-gradient(
      0deg,
      transparent,
      transparent 2px,
      rgba(0,0,0,0.08) 2px,
      rgba(0,0,0,0.08) 4px
    );
    pointer-events: none;
    z-index: 9999;
  }

  .screen {
    background: var(--c64-bg);
    border: 4px solid var(--c64-border);
    box-shadow:
      0 0 0 8px #1a1a4a,
      0 0 0 12px #0a0a2a,
      0 0 60px rgba(100,120,255,0.4),
      0 0 120px rgba(100,120,255,0.2),
      inset 0 0 80px rgba(0,0,40,0.3);
    border-radius: 4px;
    padding: 24px;
    max-width: 1100px;
    width: 100%;
  }

  .title-bar {
    text-align: center;
    margin-bottom: 20px;
    border-bottom: 2px solid var(--c64-text);
    padding-bottom: 12px;
  }

  .title-bar h1 {
    font-size: 2.8rem;
    color: var(--c64-white);
    letter-spacing: 4px;
    text-shadow: 0 0 20px rgba(168,168,255,0.8);
  }

  .title-bar p {
    color: var(--c64-cyan);
    font-size: 1.2rem;
    letter-spacing: 2px;
  }

  .main-layout {
    display: grid;
    grid-template-columns: auto 1fr auto;
    gap: 16px;
    align-items: start;
  }

  /* ---- LEFT PANEL ---- */
  .left-panel {
    display: flex;
    flex-direction: column;
    gap: 12px;
    min-width: 160px;
  }

  .panel-box {
    background: rgba(0,0,20,0.5);
    border: 2px solid var(--c64-text);
    padding: 10px;
  }

  .panel-box h3 {
    color: var(--c64-cyan);
    font-size: 1.1rem;
    letter-spacing: 2px;
    margin-bottom: 8px;
    border-bottom: 1px solid var(--c64-text);
    padding-bottom: 4px;
  }

  /* Color palette */
  .palette-grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 3px;
  }

  .palette-swatch {
    width: 28px;
    height: 28px;
    border: 2px solid transparent;
    cursor: pointer;
    transition: transform 0.1s, border-color 0.1s;
    image-rendering: pixelated;
  }

  .palette-swatch:hover { transform: scale(1.15); }
  .palette-swatch.active { border-color: var(--c64-white); box-shadow: 0 0 8px rgba(255,255,255,0.8); }

  /* Tools */
  .tool-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 4px;
  }

  .tool-btn {
    background: rgba(0,0,20,0.7);
    border: 2px solid var(--c64-text);
    color: var(--c64-text);
    font-family: 'VT323', monospace;
    font-size: 1rem;
    padding: 6px 4px;
    cursor: pointer;
    letter-spacing: 1px;
    transition: background 0.1s, color 0.1s;
    text-align: center;
  }

  .tool-btn:hover, .tool-btn.active {
    background: var(--c64-text);
    color: var(--c64-bg);
  }

  .tool-btn.danger:hover {
    background: var(--c64-red);
    border-color: var(--c64-red);
    color: white;
  }

  .tool-btn.full-width { grid-column: 1 / -1; }

  /* Mode toggle */
  .mode-toggle {
    display: flex;
    gap: 4px;
  }

  .mode-btn {
    flex: 1;
    background: rgba(0,0,20,0.7);
    border: 2px solid var(--c64-text);
    color: var(--c64-text);
    font-family: 'VT323', monospace;
    font-size: 0.95rem;
    padding: 5px 4px;
    cursor: pointer;
    letter-spacing: 1px;
    text-align: center;
    transition: all 0.1s;
  }

  .mode-btn.active {
    background: var(--c64-yellow);
    border-color: var(--c64-yellow);
    color: #000;
  }

  /* Multicolor indicators */
  .mc-colors {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-top: 6px;
  }

  .mc-color-row {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 0.9rem;
    color: var(--c64-bright);
  }

  .mc-swatch {
    width: 20px;
    height: 20px;
    border: 1px solid var(--c64-text);
    cursor: pointer;
    flex-shrink: 0;
  }

  .mc-swatch.selected-mc { border-color: white; box-shadow: 0 0 6px white; }

  /* ---- CENTER: CANVAS ---- */
  .canvas-area {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
  }

  .canvas-wrapper {
    position: relative;
    cursor: crosshair;
    user-select: none;
  }

  #sprite-canvas {
    display: block;
    image-rendering: pixelated;
    border: 3px solid var(--c64-cyan);
    box-shadow: 0 0 20px rgba(104,216,232,0.4), 0 0 40px rgba(104,216,232,0.1);
  }

  /* Coordinates display */
  .coords {
    font-size: 1rem;
    color: var(--c64-cyan);
    letter-spacing: 2px;
    height: 20px;
  }

  /* ---- RIGHT PANEL ---- */
  .right-panel {
    display: flex;
    flex-direction: column;
    gap: 12px;
    min-width: 160px;
  }

  /* Frames */
  .frames-list {
    display: flex;
    flex-direction: column;
    gap: 4px;
    max-height: 280px;
    overflow-y: auto;
  }

  .frame-item {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 4px;
    border: 2px solid transparent;
    cursor: pointer;
    transition: border-color 0.1s;
  }

  .frame-item:hover { border-color: var(--c64-text); }
  .frame-item.active { border-color: var(--c64-cyan); }

  .frame-thumb {
    image-rendering: pixelated;
    border: 1px solid var(--c64-text);
    flex-shrink: 0;
  }

  .frame-label {
    font-size: 1rem;
    color: var(--c64-bright);
  }

  /* Preview */
  .preview-area {
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: center;
  }

  .preview-row {
    display: flex;
    gap: 12px;
    align-items: flex-end;
  }

  .preview-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
  }

  .preview-label {
    font-size: 0.85rem;
    color: var(--c64-text);
    letter-spacing: 1px;
  }

  #preview-1x, #preview-2x, #anim-preview {
    image-rendering: pixelated;
    border: 1px solid var(--c64-text);
    background: #000;
  }

  /* Anim controls */
  .anim-controls {
    display: flex;
    gap: 4px;
    align-items: center;
  }

  .speed-label {
    font-size: 0.9rem;
    color: var(--c64-text);
  }

  /* Export */
  .export-output {
    background: #000010;
    border: 1px solid var(--c64-text);
    color: var(--c64-yellow);
    font-family: 'Share Tech Mono', monospace;
    font-size: 0.7rem;
    padding: 8px;
    width: 100%;
    min-height: 80px;
    resize: vertical;
    white-space: pre;
    overflow: auto;
  }

  /* Scrollbar */
  ::-webkit-scrollbar { width: 6px; }
  ::-webkit-scrollbar-track { background: rgba(0,0,20,0.5); }
  ::-webkit-scrollbar-thumb { background: var(--c64-text); }

  .status-bar {
    margin-top: 16px;
    border-top: 2px solid var(--c64-text);
    padding-top: 8px;
    display: flex;
    justify-content: space-between;
    font-size: 1rem;
    color: var(--c64-text);
  }

  /* Flicker animation for CRT effect */
  @keyframes flicker {
    0%, 100% { opacity: 1; }
    92% { opacity: 1; }
    93% { opacity: 0.97; }
    94% { opacity: 1; }
  }

  .screen { animation: flicker 8s infinite; }

  /* Responsive */
  @media (max-width: 900px) {
    .main-layout { grid-template-columns: 1fr; }
    .left-panel, .right-panel { flex-direction: row; flex-wrap: wrap; }
    .panel-box { flex: 1; min-width: 140px; }
  }
</style>
</head>
<body>

<div class="screen">
  <div class="title-bar">
    <h1>★ C64 SPRITE EDITOR ★</h1>
    <p>COMMODORE 64 SPRITE DESIGNER V1.0</p>
  </div>

  <div class="main-layout">

    <!-- LEFT PANEL -->
    <div class="left-panel">

      <div class="panel-box">
        <h3>MODE</h3>
        <div class="mode-toggle">
          <div class="mode-btn active" id="mode-hires" onclick="setMode('hires')">HIRES</div>
          <div class="mode-btn" id="mode-multi" onclick="setMode('multi')">MULTI</div>
        </div>
        <div class="mc-colors" id="mc-colors" style="display:none">
          <div style="font-size:0.8rem;color:var(--c64-text);letter-spacing:1px;">MULTICOLOR:</div>
          <div class="mc-color-row">
            <div class="mc-swatch selected-mc" id="mc-swatch-bg" title="Background (00)"></div>
            <span>BG (00)</span>
          </div>
          <div class="mc-color-row">
            <div class="mc-swatch" id="mc-swatch-01" title="Multi 1 (01)"></div>
            <span>MC1 (01)</span>
          </div>
          <div class="mc-color-row">
            <div class="mc-swatch" id="mc-swatch-11" title="Sprite Color (11)"></div>
            <span>SPR (11)</span>
          </div>
          <div class="mc-color-row">
            <div class="mc-swatch" id="mc-swatch-10" title="Multi 2 (10)"></div>
            <span>MC2 (10)</span>
          </div>
        </div>
      </div>

      <div class="panel-box">
        <h3>PALETTE</h3>
        <div class="palette-grid" id="palette-grid"></div>
      </div>

      <div class="panel-box">
        <h3>DRAW COLOR</h3>
        <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
          <div id="current-color-swatch" style="width:40px;height:40px;border:2px solid white;"></div>
          <div>
            <div id="current-color-name" style="font-size:1rem;color:var(--c64-white);"></div>
            <div id="current-color-idx" style="font-size:0.9rem;color:var(--c64-text);"></div>
          </div>
        </div>
        <div style="display:flex;gap:6px;align-items:center;">
          <span style="font-size:0.9rem;color:var(--c64-text);">BG:</span>
          <div id="bg-color-swatch" style="width:24px;height:24px;border:2px solid var(--c64-text);cursor:pointer;" onclick="selectBgColor()"></div>
          <span id="bg-color-name" style="font-size:0.9rem;color:var(--c64-text);"></span>
        </div>
      </div>

      <div class="panel-box">
        <h3>TOOLS</h3>
        <div class="tool-grid">
          <div class="tool-btn active" id="tool-draw" onclick="setTool('draw')">✏ DRAW</div>
          <div class="tool-btn" id="tool-erase" onclick="setTool('erase')">◻ ERASE</div>
          <div class="tool-btn" id="tool-fill" onclick="setTool('fill')">▓ FILL</div>
          <div class="tool-btn" id="tool-line" onclick="setTool('line')">╱ LINE</div>
          <div class="tool-btn full-width" id="tool-select" onclick="setTool('select')">⬜ SELECT</div>
        </div>
      </div>

      <div class="panel-box">
        <h3>EDIT</h3>
        <div class="tool-grid">
          <div class="tool-btn" onclick="shiftSprite('up')">▲ UP</div>
          <div class="tool-btn" onclick="shiftSprite('down')">▼ DOWN</div>
          <div class="tool-btn" onclick="shiftSprite('left')">◄ LEFT</div>
          <div class="tool-btn" onclick="shiftSprite('right')">► RIGHT</div>
          <div class="tool-btn" onclick="flipH()">↔ FLIP H</div>
          <div class="tool-btn" onclick="flipV()">↕ FLIP V</div>
          <div class="tool-btn" onclick="invertSprite()">◈ INVERT</div>
          <div class="tool-btn danger" onclick="clearSprite()">✗ CLEAR</div>
        </div>
      </div>

    </div>

    <!-- CENTER: CANVAS -->
    <div class="canvas-area">
      <div class="coords" id="coords">X:-- Y:--</div>
      <div class="canvas-wrapper" id="canvas-wrapper">
        <canvas id="sprite-canvas"></canvas>
      </div>
      <div style="display:flex;gap:8px;">
        <div class="tool-btn" onclick="undo()" style="padding:6px 16px;">↩ UNDO</div>
        <div class="tool-btn" onclick="redo()" style="padding:6px 16px;">↪ REDO</div>
      </div>

      <div class="panel-box" style="width:100%;">
        <h3>EXPORT</h3>
        <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;">
          <div class="tool-btn" onclick="exportBASIC()" style="flex:1;padding:5px;">BASIC DATA</div>
          <div class="tool-btn" onclick="exportHex()" style="flex:1;padding:5px;">HEX BYTES</div>
          <div class="tool-btn" onclick="exportASM()" style="flex:1;padding:5px;">ASM .BYTE</div>
          <div class="tool-btn" onclick="copyExport()" style="flex:1;padding:5px;">📋 COPY</div>
        </div>
        <textarea class="export-output" id="export-output" readonly></textarea>
      </div>
    </div>

    <!-- RIGHT PANEL -->
    <div class="right-panel">

      <div class="panel-box">
        <h3>FRAMES</h3>
        <div class="frames-list" id="frames-list"></div>
        <div style="display:flex;gap:4px;margin-top:6px;">
          <div class="tool-btn" onclick="addFrame()" style="flex:1;padding:5px;">+ ADD</div>
          <div class="tool-btn danger" onclick="deleteFrame()" style="flex:1;padding:5px;">- DEL</div>
          <div class="tool-btn" onclick="dupFrame()" style="flex:1;padding:5px;">⧉ DUP</div>
        </div>
      </div>

      <div class="panel-box">
        <h3>PREVIEW</h3>
        <div class="preview-area">
          <div class="preview-row">
            <div class="preview-item">
              <canvas id="preview-1x" width="24" height="21"></canvas>
              <span class="preview-label">1×</span>
            </div>
            <div class="preview-item">
              <canvas id="preview-2x" width="48" height="42"></canvas>
              <span class="preview-label">2×</span>
            </div>
            <div class="preview-item">
              <canvas id="preview-4x" width="96" height="84"></canvas>
              <span class="preview-label">4×</span>
            </div>
          </div>
        </div>
      </div>

      <div class="panel-box">
        <h3>ANIMATION</h3>
        <div class="preview-area">
          <canvas id="anim-preview" width="96" height="84"></canvas>
          <div class="anim-controls">
            <div class="tool-btn" id="anim-play-btn" onclick="toggleAnim()" style="padding:4px 12px;">▶ PLAY</div>
          </div>
          <div style="display:flex;align-items:center;gap:8px;">
            <span class="speed-label">FPS:</span>
            <input type="range" id="fps-slider" min="1" max="30" value="8"
              style="accent-color:var(--c64-cyan);width:80px;"
              oninput="updateFps(this.value)">
            <span class="speed-label" id="fps-display">8</span>
          </div>
        </div>
      </div>

      <div class="panel-box">
        <h3>GRID</h3>
        <div style="display:flex;flex-direction:column;gap:4px;">
          <label style="font-size:0.95rem;color:var(--c64-bright);display:flex;align-items:center;gap:8px;cursor:pointer;">
            <input type="checkbox" id="grid-toggle" checked onchange="renderCanvas()" style="accent-color:var(--c64-cyan);">
            SHOW GRID
          </label>
          <label style="font-size:0.95rem;color:var(--c64-bright);display:flex;align-items:center;gap:8px;cursor:pointer;">
            <input type="checkbox" id="byte-grid-toggle" onchange="renderCanvas()" style="accent-color:var(--c64-yellow);">
            BYTE GRID
          </label>
          <label style="font-size:0.95rem;color:var(--c64-bright);display:flex;align-items:center;gap:8px;cursor:pointer;">
            <input type="checkbox" id="center-cross" checked onchange="renderCanvas()" style="accent-color:var(--c64-red);">
            CENTER LINE
          </label>
        </div>
        <div style="margin-top:8px;display:flex;align-items:center;gap:8px;">
          <span style="font-size:0.9rem;color:var(--c64-text);">ZOOM:</span>
          <input type="range" id="zoom-slider" min="10" max="28" value="20"
            style="accent-color:var(--c64-cyan);width:80px;"
            oninput="setZoom(this.value)">
          <span id="zoom-display" style="font-size:0.9rem;color:var(--c64-text);">20</span>
        </div>
      </div>

    </div>
  </div>

  <div class="status-bar">
    <span id="status-left">READY. DRAW YOUR SPRITE!</span>
    <span>C64 SPRITE EDITOR © 2026</span>
    <span id="status-right">SPRITE: 1/1 | 63 BYTES</span>
  </div>
</div>

<script>
// =========================================
// C64 COLOR PALETTE
// =========================================
const C64_PALETTE = [
  { name: 'BLACK',       hex: '#000000' },
  { name: 'WHITE',       hex: '#FFFFFF' },
  { name: 'RED',         hex: '#883333' },
  { name: 'CYAN',        hex: '#67CCCC' },
  { name: 'PURPLE',      hex: '#8B3F96' },
  { name: 'GREEN',       hex: '#55A049' },
  { name: 'BLUE',        hex: '#40318D' },
  { name: 'YELLOW',      hex: '#BFCE72' },
  { name: 'ORANGE',      hex: '#8B5429' },
  { name: 'BROWN',       hex: '#574200' },
  { name: 'LT RED',      hex: '#B86962' },
  { name: 'DARK GREY',   hex: '#505050' },
  { name: 'MED GREY',    hex: '#787878' },
  { name: 'LT GREEN',    hex: '#94E089' },
  { name: 'LT BLUE',     hex: '#7869C4' },
  { name: 'LT GREY',     hex: '#9F9F9F' },
];

const SPRITE_W = 24;
const SPRITE_H = 21;

// =========================================
// STATE
// =========================================
let mode = 'hires'; // 'hires' | 'multi'
let currentColorIdx = 1; // white
let bgColorIdx = 6;      // blue
let tool = 'draw';
let pixelSize = 20;
let animPlaying = false;
let animTimer = null;
let animFps = 8;
let animFrame = 0;

// Multicolor color slots
let mcColors = {
  bg:  0,  // 00 - background (shared)
  mc1: 4,  // 01 - multicolor 1 (shared)
  spr: 1,  // 11 - sprite color (per sprite)
  mc2: 3,  // 10 - multicolor 2 (shared)
};
let activeMcSlot = 'spr'; // which mc slot we're painting

// Frames: each frame is a 2D array [row][col] = colorIndex (0-15)
// For hires: 0 = transparent, 1-15 = color (but only 1 is "set" — uses spriteColor)
// For multi: we store 0,1,2,3 as the four mc-indices directly
let frames = [];
let currentFrame = 0;

// Undo/redo
let undoStack = [];
let redoStack = [];

// Line tool state
let lineStart = null;
let lineOverlay = null;

// Drawing state
let isDrawing = false;
let lastPx = -1, lastPy = -1;

// Selection
let selection = null;
let selStart = null;

// =========================================
// INIT
// =========================================
function createEmptyFrame() {
  const d = [];
  for (let y = 0; y < SPRITE_H; y++) {
    d.push(new Array(SPRITE_W).fill(0));
  }
  return d;
}

function init() {
  frames.push(createEmptyFrame());
  buildPalette();
  updateMcSwatches();
  renderFramesList();
  renderCanvas();
  updatePreviews();
  updateExport();
  updateCurrentColorDisplay();
  updateBgColorDisplay();
}

// =========================================
// PALETTE UI
// =========================================
function buildPalette() {
  const grid = document.getElementById('palette-grid');
  grid.innerHTML = '';
  C64_PALETTE.forEach((col, idx) => {
    const sw = document.createElement('div');
    sw.className = 'palette-swatch' + (idx === currentColorIdx ? ' active' : '');
    sw.style.background = col.hex;
    sw.title = col.name + ' (' + idx + ')';
    sw.onclick = () => selectColor(idx);
    grid.appendChild(sw);
  });
}

function selectColor(idx) {
  if (mode === 'multi') {
    // Set the active mc slot's color
    mcColors[activeMcSlot] = idx;
    updateMcSwatches();
    updateMcPaintColor();
  } else {
    currentColorIdx = idx;
  }
  document.querySelectorAll('.palette-swatch').forEach((sw, i) => {
    sw.classList.toggle('active', i === idx);
  });
  updateCurrentColorDisplay();
  renderCanvas();
}

function updateCurrentColorDisplay() {
  document.getElementById('current-color-swatch').style.background = C64_PALETTE[currentColorIdx].hex;
  document.getElementById('current-color-name').textContent = C64_PALETTE[currentColorIdx].name;
  document.getElementById('current-color-idx').textContent = 'IDX: ' + currentColorIdx;
}

function updateBgColorDisplay() {
  document.getElementById('bg-color-swatch').style.background = C64_PALETTE[bgColorIdx].hex;
  document.getElementById('bg-color-name').textContent = C64_PALETTE[bgColorIdx].name;
  if (mode === 'multi') {
    // bg is mc slot bg
    mcColors.bg = bgColorIdx;
    updateMcSwatches();
  }
}

function selectBgColor() {
  // cycle through palette for bg
  bgColorIdx = (bgColorIdx + 1) % 16;
  if (mode === 'multi') mcColors.bg = bgColorIdx;
  updateBgColorDisplay();
  renderCanvas();
  updatePreviews();
}

// =========================================
// MODE
// =========================================
function setMode(m) {
  mode = m;
  document.getElementById('mode-hires').classList.toggle('active', m === 'hires');
  document.getElementById('mode-multi').classList.toggle('active', m === 'multi');
  document.getElementById('mc-colors').style.display = m === 'multi' ? 'flex' : 'none';
  updateMcSwatches();
  renderCanvas();
  updatePreviews();
  updateExport();
}

function updateMcSwatches() {
  const slots = ['bg','mc1','spr','mc2'];
  const ids   = ['mc-swatch-bg','mc-swatch-01','mc-swatch-11','mc-swatch-10'];
  slots.forEach((slot, i) => {
    const el = document.getElementById(ids[i]);
    if (el) {
      el.style.background = C64_PALETTE[mcColors[slot]].hex;
      el.classList.toggle('selected-mc', slot === activeMcSlot);
      el.onclick = () => { activeMcSlot = slot; updateMcSwatches(); updateMcPaintColor(); };
    }
  });
}

function updateMcPaintColor() {
  currentColorIdx = mcColors[activeMcSlot];
  document.querySelectorAll('.palette-swatch').forEach((sw, i) => {
    sw.classList.toggle('active', i === currentColorIdx);
  });
  updateCurrentColorDisplay();
}

// =========================================
// TOOL
// =========================================
function setTool(t) {
  tool = t;
  document.querySelectorAll('[id^=tool-]').forEach(el => el.classList.remove('active'));
  const el = document.getElementById('tool-' + t);
  if (el) el.classList.add('active');
  lineStart = null;
  lineOverlay = null;
}

// =========================================
// CANVAS RENDERING
// =========================================
function getCanvas() { return document.getElementById('sprite-canvas'); }

function getPixelColor(frame, x, y) {
  const val = frames[frame][y][x];
  if (mode === 'hires') {
    return val === 0 ? null : currentColorIdx; // hires: val is 0/1 but stores colorIdx
  }
  // multi: val is 0-3
  return val;
}

// Get actual RGB color for a cell
function getCellRGB(frame, x, y) {
  const data = frames[frame];
  const val = data[y][x];
  if (mode === 'hires') {
    if (val === 0) return C64_PALETTE[bgColorIdx].hex;
    return C64_PALETTE[val].hex;
  } else {
    // multi: x must be even-aligned (each mc pixel is 2 wide)
    const effectiveX = Math.floor(x / 2) * 2;
    const v = data[y][effectiveX];
    const colorMap = [mcColors.bg, mcColors.mc1, mcColors.spr, mcColors.mc2];
    return C64_PALETTE[colorMap[v]].hex;
  }
}

function renderCanvas() {
  const canvas = getCanvas();
  const ps = pixelSize;
  const totalW = SPRITE_W * ps;
  const totalH = SPRITE_H * ps;
  canvas.width = totalW;
  canvas.height = totalH;
  const ctx = canvas.getContext('2d');

  // Background
  ctx.fillStyle = C64_PALETTE[bgColorIdx].hex;
  ctx.fillRect(0, 0, totalW, totalH);

  const data = frames[currentFrame];

  // Draw pixels
  for (let y = 0; y < SPRITE_H; y++) {
    for (let x = 0; x < SPRITE_W; x++) {
      const val = data[y][x];
      if (mode === 'hires') {
        if (val !== 0) {
          ctx.fillStyle = C64_PALETTE[val].hex;
          ctx.fillRect(x * ps, y * ps, ps, ps);
        }
      } else {
        // multicolor: each pixel pair shares same value
        const effectiveX = Math.floor(x / 2) * 2;
        const v = data[y][effectiveX];
        const colorMap = [mcColors.bg, mcColors.mc1, mcColors.spr, mcColors.mc2];
        ctx.fillStyle = C64_PALETTE[colorMap[v]].hex;
        ctx.fillRect(x * ps, y * ps, ps, ps);
      }
    }
  }

  // Line overlay
  if (lineOverlay && tool === 'line') {
    lineOverlay.forEach(([lx, ly]) => {
      ctx.fillStyle = mode === 'hires'
        ? C64_PALETTE[currentColorIdx].hex + 'CC'
        : C64_PALETTE[mcColors[activeMcSlot]].hex + 'CC';
      ctx.fillRect(lx * ps, ly * ps, ps, ps);
    });
  }

  // Selection overlay
  if (selection && tool === 'select') {
    ctx.strokeStyle = '#FFD700';
    ctx.lineWidth = 2;
    ctx.setLineDash([4, 4]);
    ctx.strokeRect(selection.x1 * ps, selection.y1 * ps,
      (selection.x2 - selection.x1 + 1) * ps, (selection.y2 - selection.y1 + 1) * ps);
    ctx.setLineDash([]);
  }

  // Grid
  if (document.getElementById('grid-toggle')?.checked) {
    ctx.strokeStyle = 'rgba(100,120,255,0.25)';
    ctx.lineWidth = 0.5;
    for (let x = 0; x <= SPRITE_W; x++) {
      ctx.beginPath(); ctx.moveTo(x * ps, 0); ctx.lineTo(x * ps, totalH); ctx.stroke();
    }
    for (let y = 0; y <= SPRITE_H; y++) {
      ctx.beginPath(); ctx.moveTo(0, y * ps); ctx.lineTo(totalW, y * ps); ctx.stroke();
    }
  }

  // Byte grid (every 8 pixels wide, every 7 pixels tall = byte boundaries)
  if (document.getElementById('byte-grid-toggle')?.checked) {
    ctx.strokeStyle = 'rgba(255,215,0,0.4)';
    ctx.lineWidth = 1;
    for (let x = 0; x <= SPRITE_W; x += 8) {
      ctx.beginPath(); ctx.moveTo(x * ps, 0); ctx.lineTo(x * ps, totalH); ctx.stroke();
    }
    for (let y = 0; y <= SPRITE_H; y += 7) {
      ctx.beginPath(); ctx.moveTo(0, y * ps); ctx.lineTo(totalW, y * ps); ctx.stroke();
    }
  }

  // Center cross
  if (document.getElementById('center-cross')?.checked) {
    ctx.strokeStyle = 'rgba(255,80,80,0.5)';
    ctx.lineWidth = 1;
    ctx.setLineDash([3, 3]);
    const cx = Math.floor(SPRITE_W / 2) * ps;
    const cy = Math.floor(SPRITE_H / 2) * ps;
    ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, totalH); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(totalW, cy); ctx.stroke();
    ctx.setLineDash([]);
  }
}

// =========================================
// CANVAS INTERACTION
// =========================================
function getPixelCoords(e) {
  const canvas = getCanvas();
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  const cx = (e.clientX - rect.left) * scaleX;
  const cy = (e.clientY - rect.top) * scaleY;
  const px = Math.floor(cx / pixelSize);
  const py = Math.floor(cy / pixelSize);
  return [Math.max(0, Math.min(SPRITE_W - 1, px)), Math.max(0, Math.min(SPRITE_H - 1, py))];
}

function paintPixel(x, y) {
  if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return;
  if (mode === 'multi') {
    // snap to even column
    const ex = Math.floor(x / 2) * 2;
    const slotMap = { bg: 0, mc1: 1, spr: 2, mc2: 3 };
    const v = slotMap[activeMcSlot] ?? 2;
    frames[currentFrame][y][ex] = v;
    frames[currentFrame][y][ex + 1] = v;
  } else {
    if (tool === 'erase') {
      frames[currentFrame][y][x] = 0;
    } else {
      frames[currentFrame][y][x] = currentColorIdx;
    }
  }
}

function erasePixel(x, y) {
  if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return;
  if (mode === 'multi') {
    const ex = Math.floor(x / 2) * 2;
    frames[currentFrame][y][ex] = 0;
    frames[currentFrame][y][ex + 1] = 0;
  } else {
    frames[currentFrame][y][x] = 0;
  }
}

function floodFill(x, y, targetVal, fillVal) {
  if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return;
  if (JSON.stringify(targetVal) === JSON.stringify(fillVal)) return;

  const data = frames[currentFrame];
  const stack = [[x, y]];
  const get = (px, py) => mode === 'multi'
    ? data[py][Math.floor(px / 2) * 2]
    : data[py][px];

  while (stack.length > 0) {
    const [cx, cy] = stack.pop();
    if (cx < 0 || cx >= SPRITE_W || cy < 0 || cy >= SPRITE_H) continue;
    if (get(cx, cy) !== targetVal) continue;
    paintPixel(cx, cy);
    stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
  }
}

function getLinePixels(x0, y0, x1, y1) {
  const pixels = [];
  let dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
  let dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
  let err = dx + dy;
  let cx = x0, cy = y0;
  while (true) {
    pixels.push([cx, cy]);
    if (cx === x1 && cy === y1) break;
    const e2 = 2 * err;
    if (e2 >= dy) { err += dy; cx += sx; }
    if (e2 <= dx) { err += dx; cy += sy; }
  }
  return pixels;
}

function saveUndo() {
  undoStack.push(JSON.parse(JSON.stringify(frames[currentFrame])));
  if (undoStack.length > 50) undoStack.shift();
  redoStack = [];
}

function undo() {
  if (undoStack.length === 0) return;
  redoStack.push(JSON.parse(JSON.stringify(frames[currentFrame])));
  frames[currentFrame] = undoStack.pop();
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
}

function redo() {
  if (redoStack.length === 0) return;
  undoStack.push(JSON.parse(JSON.stringify(frames[currentFrame])));
  frames[currentFrame] = redoStack.pop();
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
}

// =========================================
// CANVAS EVENTS
// =========================================
function setupCanvasEvents() {
  const canvas = getCanvas();

  canvas.addEventListener('mousedown', (e) => {
    e.preventDefault();
    const [x, y] = getPixelCoords(e);
    isDrawing = true;
    lastPx = x; lastPy = y;

    if (tool === 'fill') {
      saveUndo();
      const targetVal = mode === 'multi'
        ? frames[currentFrame][y][Math.floor(x / 2) * 2]
        : frames[currentFrame][y][x];
      const slotMap = { bg: 0, mc1: 1, spr: 2, mc2: 3 };
      const fillVal = mode === 'multi' ? (slotMap[activeMcSlot] ?? 2) : currentColorIdx;
      floodFill(x, y, targetVal, fillVal);
      renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
      return;
    }

    if (tool === 'line') {
      if (!lineStart) {
        lineStart = [x, y];
        setStatus('Click end point for line...');
      } else {
        saveUndo();
        const pixels = getLinePixels(lineStart[0], lineStart[1], x, y);
        pixels.forEach(([px, py]) => { if (e.buttons === 2) erasePixel(px, py); else paintPixel(px, py); });
        lineStart = null; lineOverlay = null;
        renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
        setStatus('Line drawn!');
      }
      return;
    }

    if (tool === 'select') {
      selStart = [x, y];
      selection = { x1: x, y1: y, x2: x, y2: y };
      renderCanvas();
      return;
    }

    saveUndo();
    if (e.buttons === 2) erasePixel(x, y); else paintPixel(x, y);
    renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
  });

  canvas.addEventListener('mousemove', (e) => {
    const [x, y] = getPixelCoords(e);
    document.getElementById('coords').textContent = `X:${String(x).padStart(2,'0')} Y:${String(y).padStart(2,'0')}`;

    if (tool === 'line' && lineStart) {
      lineOverlay = getLinePixels(lineStart[0], lineStart[1], x, y);
      renderCanvas();
      return;
    }

    if (tool === 'select' && isDrawing && selStart) {
      selection = {
        x1: Math.min(selStart[0], x), y1: Math.min(selStart[1], y),
        x2: Math.max(selStart[0], x), y2: Math.max(selStart[1], y),
      };
      renderCanvas();
      return;
    }

    if (!isDrawing || tool === 'fill' || tool === 'line') return;
    if (x === lastPx && y === lastPy) return;
    lastPx = x; lastPy = y;
    if (e.buttons === 2) erasePixel(x, y); else paintPixel(x, y);
    renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
  });

  canvas.addEventListener('mouseup', () => { isDrawing = false; selStart = null; });
  canvas.addEventListener('mouseleave', () => {
    isDrawing = false;
    document.getElementById('coords').textContent = 'X:-- Y:--';
  });
  canvas.addEventListener('contextmenu', e => e.preventDefault());
}

// =========================================
// EDIT OPERATIONS
// =========================================
function clearSprite() {
  if (!confirm('Clear current frame?')) return;
  saveUndo();
  frames[currentFrame] = createEmptyFrame();
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
}

function shiftSprite(dir) {
  saveUndo();
  const d = frames[currentFrame];
  const nd = createEmptyFrame();
  for (let y = 0; y < SPRITE_H; y++) {
    for (let x = 0; x < SPRITE_W; x++) {
      let nx = x, ny = y;
      if (dir === 'up') ny = y - 1;
      if (dir === 'down') ny = y + 1;
      if (dir === 'left') nx = x - 1;
      if (dir === 'right') nx = x + 1;
      if (nx >= 0 && nx < SPRITE_W && ny >= 0 && ny < SPRITE_H) {
        nd[ny][nx] = d[y][x];
      }
    }
  }
  frames[currentFrame] = nd;
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
}

function flipH() {
  saveUndo();
  const d = frames[currentFrame];
  for (let y = 0; y < SPRITE_H; y++) {
    d[y].reverse();
  }
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
}

function flipV() {
  saveUndo();
  frames[currentFrame].reverse();
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
}

function invertSprite() {
  saveUndo();
  const d = frames[currentFrame];
  for (let y = 0; y < SPRITE_H; y++) {
    for (let x = 0; x < SPRITE_W; x++) {
      if (mode === 'hires') {
        d[y][x] = d[y][x] === 0 ? currentColorIdx : 0;
      } else {
        d[y][x] = (d[y][x] + 1) % 4; // cycle mc values
      }
    }
  }
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
}

// =========================================
// FRAMES
// =========================================
function renderFramesList() {
  const list = document.getElementById('frames-list');
  list.innerHTML = '';
  frames.forEach((frame, i) => {
    const item = document.createElement('div');
    item.className = 'frame-item' + (i === currentFrame ? ' active' : '');
    item.onclick = () => selectFrameItem(i);

    const thumb = document.createElement('canvas');
    thumb.className = 'frame-thumb';
    thumb.width = 24; thumb.height = 21;
    thumb.style.width = '48px'; thumb.style.height = '42px';
    renderFrameToCanvas(frame, thumb, 1);

    const label = document.createElement('div');
    label.className = 'frame-label';
    label.textContent = 'FRAME ' + (i + 1);

    item.appendChild(thumb);
    item.appendChild(label);
    list.appendChild(item);
  });
  document.getElementById('status-right').textContent = `SPRITE: ${currentFrame+1}/${frames.length} | 63 BYTES`;
}

function renderFrameToCanvas(frame, canvas, scale) {
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = C64_PALETTE[bgColorIdx].hex;
  ctx.fillRect(0, 0, canvas.width * scale, canvas.height * scale);
  for (let y = 0; y < SPRITE_H; y++) {
    for (let x = 0; x < SPRITE_W; x++) {
      const val = frame[y][x];
      let color;
      if (mode === 'hires') {
        color = val === 0 ? null : C64_PALETTE[val].hex;
      } else {
        const ex = Math.floor(x / 2) * 2;
        const v = frame[y][ex];
        const colorMap = [mcColors.bg, mcColors.mc1, mcColors.spr, mcColors.mc2];
        color = C64_PALETTE[colorMap[v]].hex;
      }
      if (color) {
        ctx.fillStyle = color;
        ctx.fillRect(x * scale, y * scale, scale, scale);
      }
    }
  }
}

function selectFrameItem(i) {
  currentFrame = i;
  renderCanvas(); renderFramesList(); updatePreviews(); updateExport();
}

function addFrame() {
  frames.push(createEmptyFrame());
  currentFrame = frames.length - 1;
  renderCanvas(); renderFramesList(); updatePreviews(); updateExport();
}

function deleteFrame() {
  if (frames.length <= 1) { setStatus('NEED AT LEAST 1 FRAME!'); return; }
  frames.splice(currentFrame, 1);
  currentFrame = Math.min(currentFrame, frames.length - 1);
  renderCanvas(); renderFramesList(); updatePreviews(); updateExport();
}

function dupFrame() {
  const copy = JSON.parse(JSON.stringify(frames[currentFrame]));
  frames.splice(currentFrame + 1, 0, copy);
  currentFrame = currentFrame + 1;
  renderCanvas(); renderFramesList(); updatePreviews(); updateExport();
}

// =========================================
// PREVIEWS
// =========================================
function updatePreviews() {
  const preview1 = document.getElementById('preview-1x');
  const preview2 = document.getElementById('preview-2x');
  const preview4 = document.getElementById('preview-4x');
  renderFrameToCanvas(frames[currentFrame], preview1, 1);
  const ctx2 = preview2.getContext('2d');
  ctx2.imageSmoothingEnabled = false;
  ctx2.drawImage(preview1, 0, 0, 48, 42);
  const ctx4 = preview4.getContext('2d');
  ctx4.imageSmoothingEnabled = false;
  ctx4.drawImage(preview1, 0, 0, 96, 84);
}

// =========================================
// ANIMATION
// =========================================
function toggleAnim() {
  animPlaying = !animPlaying;
  document.getElementById('anim-play-btn').textContent = animPlaying ? '⏹ STOP' : '▶ PLAY';
  if (animPlaying) {
    runAnim();
  } else {
    clearTimeout(animTimer);
  }
}

function runAnim() {
  if (!animPlaying) return;
  const canvas = document.getElementById('anim-preview');
  animFrame = animFrame % frames.length;
  renderFrameToCanvas(frames[animFrame], document.getElementById('preview-1x'), 1);
  const ctx = canvas.getContext('2d');
  ctx.imageSmoothingEnabled = false;
  const tmp = document.getElementById('preview-1x');
  ctx.clearRect(0, 0, 96, 84);
  ctx.drawImage(tmp, 0, 0, 96, 84);
  animFrame = (animFrame + 1) % frames.length;
  animTimer = setTimeout(runAnim, 1000 / animFps);
}

function updateFps(v) {
  animFps = parseInt(v);
  document.getElementById('fps-display').textContent = v;
}

// =========================================
// EXPORT
// =========================================
function frameToBytes(frameIdx) {
  const frame = frames[frameIdx];
  const bytes = [];
  for (let y = 0; y < SPRITE_H; y++) {
    for (let byteX = 0; byteX < 3; byteX++) {
      let b = 0;
      for (let bit = 0; bit < 8; bit++) {
        const x = byteX * 8 + bit;
        const val = frame[y][x];
        if (mode === 'hires') {
          if (val !== 0) b |= (1 << (7 - bit));
        } else {
          // multi: pairs, 2 bits per pair
          if (bit % 2 === 0) {
            const ex = byteX * 8 + bit;
            const v = frame[y][ex]; // 0-3
            b |= (v << (6 - bit));
          }
        }
      }
      bytes.push(b);
    }
  }
  bytes.push(mcColors.spr); // byte 64: sprite color
  return bytes;
}

function exportBASIC() {
  let out = '';
  frames.forEach((_, fi) => {
    const bytes = frameToBytes(fi);
    out += `; FRAME ${fi + 1}\n`;
    for (let row = 0; row < 9; row++) {
      const line = bytes.slice(row * 7, row * 7 + 7);
      out += `DATA ${line.join(',')}\n`;
    }
    out += `DATA ${bytes[63]}\n\n`;
  });
  document.getElementById('export-output').value = out;
}

function exportHex() {
  let out = '';
  frames.forEach((_, fi) => {
    const bytes = frameToBytes(fi);
    out += `; FRAME ${fi + 1}\n`;
    for (let i = 0; i < bytes.length; i++) {
      if (i % 8 === 0 && i > 0) out += '\n';
      out += '$' + bytes[i].toString(16).toUpperCase().padStart(2, '0') + ' ';
    }
    out += '\n\n';
  });
  document.getElementById('export-output').value = out;
}

function exportASM() {
  let out = '';
  frames.forEach((_, fi) => {
    const bytes = frameToBytes(fi);
    out += `; FRAME ${fi + 1}\n`;
    for (let row = 0; row < 9; row++) {
      const line = bytes.slice(row * 7, row * 7 + 7);
      out += `.BYTE ${line.map(b => '$' + b.toString(16).toUpperCase().padStart(2,'0')).join(',')}\n`;
    }
    out += `.BYTE $${bytes[63].toString(16).toUpperCase().padStart(2,'0')} ; COLOR\n\n`;
  });
  document.getElementById('export-output').value = out;
}

function updateExport() {
  exportBASIC();
}

function copyExport() {
  const out = document.getElementById('export-output');
  navigator.clipboard.writeText(out.value).then(() => setStatus('COPIED TO CLIPBOARD!')).catch(() => {
    out.select(); document.execCommand('copy'); setStatus('COPIED!');
  });
}

// =========================================
// ZOOM
// =========================================
function setZoom(v) {
  pixelSize = parseInt(v);
  document.getElementById('zoom-display').textContent = v;
  renderCanvas();
}

// =========================================
// STATUS
// =========================================
function setStatus(msg) {
  document.getElementById('status-left').textContent = msg;
  setTimeout(() => document.getElementById('status-left').textContent = 'READY.', 2500);
}

// =========================================
// KEYBOARD SHORTCUTS
// =========================================
document.addEventListener('keydown', (e) => {
  if (e.target.tagName === 'TEXTAREA') return;
  const key = e.key.toLowerCase();
  if (key === 'd') setTool('draw');
  if (key === 'e') setTool('erase');
  if (key === 'f') setTool('fill');
  if (key === 'l') setTool('line');
  if (key === 'z' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); undo(); }
  if (key === 'y' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); redo(); }
  if (key === 'arrowup')    { e.preventDefault(); shiftSprite('up'); }
  if (key === 'arrowdown')  { e.preventDefault(); shiftSprite('down'); }
  if (key === 'arrowleft')  { e.preventDefault(); shiftSprite('left'); }
  if (key === 'arrowright') { e.preventDefault(); shiftSprite('right'); }
});

// =========================================
// STARTUP
// =========================================
window.addEventListener('load', () => {
  init();
  setupCanvasEvents();
  // Draw a little demo sprite
  saveUndo();
  const d = frames[0];
  // Simple smiley
  const pts = [
    [10,5],[11,5],[12,5],[13,5],
    [8,7],[9,7],[14,7],[15,7],
    [7,9],[16,9],
    [7,11],[9,11],[14,11],[16,11],
    [7,13],[8,13],[15,13],[16,13],
    [8,15],[9,15],[10,15],[13,15],[14,15],[15,15],
    [10,16],[11,16],[12,16],[13,16],
  ];
  pts.forEach(([x, y]) => { if (y < SPRITE_H && x < SPRITE_W) d[y][x] = 1; });
  renderCanvas(); updatePreviews(); renderFramesList(); updateExport();
  setStatus('WELCOME TO C64 SPRITE EDITOR!');
});
</script>
</body>
</html>

💬 Kommentare (0)

Kommentar hinterlassen

Noch keine Kommentare. Sei der/die Erste!

KATSCHMARZ SOFTWARE SYSTEM READY.
Loading…
00:00:00