Farbpaletten-Interpolation
Wie eine ganze Spielwelt stufenlos die Jahreszeit wechselt — ohne eine einzige Zeile Animationscode.
Wer in Easter Hop gut spielt, sieht die Welt sich verwandeln: Der pastellige Frühlingshimmel vertieft sich zu sattem Sommerblau, das junge Grün wird kräftig, aus rosa Blüten werden Sonnenblumen, und selbst die Partikel wechseln von Blütenrosa zu goldenem Pollen. Der Übergang hat keine Stufen, keine Sprünge, keinen erkennbaren Moment des Umschaltens — er passiert einfach, verteilt über die ersten 30 Punkte.
Dieses Feature ist mein Lieblingsteil des Spiels, und gleichzeitig einer der kleinsten: Der Kern sind etwa 25 Zeilen Code. In diesem Artikel zerlege ich ihn vollständig — mit den echten Werten aus dem Spiel, den Entscheidungen dahinter und den Grenzen des Ansatzes, über die man Bescheid wissen sollte, bevor man ihn kopiert. Er ist Teil einer Serie; der Überblick über das ganze Projekt steht in „Wie ich Easter Hop gebaut habe“.
Die Grundidee: Farben sind Daten, keine Deko
Der entscheidende Schritt passiert lange vor jeder Interpolation: Keine Zeichenfunktion im Spiel kennt eine konkrete Farbe. Es gibt keinen Aufruf wie ctx.fillStyle = '#87ceeb' irgendwo im Render-Code. Stattdessen sind alle Farben in zwei Objekten gesammelt, die dieselben benannten Rollen definieren:
const SPRING = {
bg1: [135, 206, 235], // Himmel oben
bg2: [180, 230, 180], // Himmel unten
ground: [ 80, 160, 60], // Boden
groundDetail: [110, 190, 80], // Grashalme
obstacle: [238, 233, 218], // Zaun (gebrochen weiß)
obstacleLED: [255, 200, 60], // Zaun-Blüten (gelb)
particle: [255, 200, 210], // Partikel (blütenrosa)
bgDetail: [255, 160, 190], // Blütenblätter
// …
};
const SUMMER = {
bg1: [ 30, 120, 200], // sattes Nachmittagsblau
bg2: [ 60, 180, 100],
ground: [ 40, 130, 40],
groundDetail: [200, 210, 60], // ausgeblichenes Sommergras
obstacle: [228, 222, 200], // wärmeres Weiß
obstacleLED: [255, 120, 40], // orangefarbener Mohn
particle: [255, 240, 80], // goldener Pollen
bgDetail: [255, 220, 60], // Sonnenblumengelb
// …
};
Jede Farbe ist ein rohes RGB-Tripel — bewusst ein Array aus drei Zahlen, kein Hex-String. Das ist die Form, in der man rechnen kann. Ein Hex-String wie '#87ceeb' müsste vor jeder Interpolation zerlegt und danach wieder zusammengebaut werden; drei nackte Zahlen kann man direkt mischen.
Der Kern: 8 Zeilen Lerp
„Lerp“ steht für lineare Interpolation: Nimm zwei Werte und einen Fortschritt t zwischen 0 und 1, und gib den Punkt dazwischen zurück. Für Farben macht man das einfach dreimal — je Kanal einmal:
function lerpColor(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
Bei t = 0 kommt exakt die erste Farbe heraus, bei t = 1 exakt die zweite, bei t = 0.5 die Mitte. Für den Himmel sieht der Weg von Frühling zu Sommer so aus:
Der echte Verlauf von bg1: [135, 206, 235] → [30, 120, 200], hier als CSS-Gradient nachgestellt.
[135, 206, 235]
[83, 163, 218]
[30, 120, 200]
Ein t für alles: der Fortschrittswert
Jetzt fehlt nur noch die Verbindung zum Spielgeschehen. Jeden Frame baut sich das Spiel aus dem aktuellen Punktestand die komplette Momentan-Palette:
function getPalette(score) {
const t = Math.min(score / 30, 1.0);
const p = {};
for (const key in SPRING) {
p[key] = lerpColor(SPRING[key], SUMMER[key], t);
}
return p;
}
Drei Details, die jeweils eine bewusste Entscheidung waren:
- Warum 30? Der Wert ist reines Spieldesign. Bei 30 Punkten ist der Wechsel abgeschlossen — das ist erreichbar genug, dass gute Spieler die Verwandlung wirklich zu Ende sehen, und weit genug weg, dass sie sich verdient anfühlt. Praktischerweise erreichen auch die Zaunlücken bei etwa Punkt 33 ihr Minimum: Voller Sommer und volle Schwierigkeit fallen fast zusammen.
- Warum
Math.min(…, 1.0)? Ohne den Deckel würde die Interpolation bei Punkt 31 einfach weiterrechnen — über das Sommerblau hinaus ins Undefinierte. Extrapolation ist bei Farben fast nie das, was man will. - Warum jeden Frame neu? Weil es billig ist. 14 Rollen × 3 Kanäle sind 42 Multiplikationen pro Frame — dagegen ist ein einziger
fillRect-Aufruf teuer. Ich hatte kurz überlegt, die Palette nur bei Punkteänderung neu zu berechnen, aber das wäre Optimierung ohne messbaren Gewinn gewesen: Die Profiler-Zeit lag unter der Messgrenze.
Der Rest des Spiels fragt nur noch Rollen ab: palette.ground für den Boden, palette.particle für die Hüpf-Wölkchen, palette.obstacleLED für die pulsierenden Blüten am Zaun. Kein Renderer weiß, welche Jahreszeit gerade ist — und genau deshalb wandert alles synchron durch den Farbraum, vom Himmelsverlauf bis zum Schlagschatten der Punkteanzeige, ohne dass irgendwo Animationscode existiert.
Die ehrliche Fußnote: linear in RGB ist nicht perfekt
Wer sich mit Farbräumen auskennt, hat beim Lesen vermutlich schon die Stirn gerunzelt: Lineare Interpolation im RGB-Raum ist theoretisch die falsche Methode. RGB ist kein wahrnehmungsgleichförmiger Farbraum — der Weg zwischen zwei Farben führt dort manchmal durch gräuliche, entsättigte Zwischentöne. Das klassische Beispiel ist Blau nach Gelb: In RGB läuft die Mitte durch ein trübes Grau, während ein Lerp im HSL- oder gar OKLCH-Raum sauber über Grün oder ein neutrales Helligkeitsband ginge.
Warum funktioniert es hier trotzdem? Aus zwei Gründen. Erstens sind meine Palettenpaare bewusst so gewählt, dass Start- und Zielfarbe im Farbkreis nah beieinander liegen: Himmelblau nach dunklerem Blau, Hellgrün nach Sattgrün, Rosa nach Gold über warmes Orange. Auf kurzen Wegen ist der RGB-Umweg durchs Graue vernachlässigbar. Zweitens verteilt sich der Übergang über 30 Punkte — mehrere Minuten Spielzeit. Selbst wenn ein Zwischenton minimal trüber ist, als er im Ideal-Farbraum wäre, sieht ihn niemand als Fehler, weil nie zwei Zustände direkt nebeneinander stehen.
Die Regel, die ich daraus für mich abgeleitet habe: RGB-Lerp ist gut genug, wenn die Farbpaare verwandt sind und der Übergang langsam ist. Wer dagegen Komplementärfarben blenden oder harte Übergänge in Sekundenbruchteilen zeigen will, sollte in einen besseren Farbraum wechseln — und sich den Konvertierungscode leisten, den ich mir hier gespart habe. Bei einem Spiel mit der Eine-Datei-Philosophie gewinnt die einfache Lösung, solange sie unsichtbar bleibt.
Ein unerwarteter Nebeneffekt: das Rebranding-Experiment
Der schönste Beweis, dass die Rollen-Architektur trägt, kam Monate nach ihrer Entstehung. Easter Hop war ursprünglich „Molt the Mole“ — ein Roboter-Maulwurf in einer unterirdischen Serverwelt, Kupfer und kaltes Neon statt Frühlingsgrün. Als ich das Spiel zu Ostern komplett umgestaltet habe, bestand der visuelle Teil der Verwandlung im Wesentlichen darin, Zahlen in zwei Objekten zu ändern: neue SPRING-, neue SUMMER-Werte, dazu ein neues Sprite. Der gesamte Render-Code blieb unangetastet, weil er nie etwas anderes gekannt hatte als seine Rollen.
Die Rollennamen tragen diese Geschichte übrigens noch in sich: Die Zaun-Blüten heißen im Code bis heute obstacleLED — im Maulwurf-Original waren es blinkende LEDs an den Server-Racks. Ich habe den Namen absichtlich nie geändert. Er erinnert mich daran, dass gute Architektur Umbauten überlebt, und schlechte Namensgebung auch.
Zum Nachbauen: die Checkliste
Wenn du denselben Effekt in deinem eigenen Canvas-Projekt willst, ist es wirklich nur das hier:
- Sammle alle Farben deines Spiels in einem Paletten-Objekt mit sprechenden Rollennamen. Das ist 90 % der Arbeit — und der Teil, den man nicht nachträglich mal eben macht.
- Definiere eine zweite Palette mit identischen Schlüsseln und deinen Zielfarben. Halte die Farbpaare verwandt (gleiche Farbfamilie oder Nachbarn im Farbkreis).
- Interpoliere pro Frame mit
lerpColorüber einen Fortschrittswert deiner Wahl — Score, Zeit, Level-Fortschritt, Tageszeit. - Deckle
tmitMath.min(t, 1), und lass keine Zeichenfunktion je wieder eine Farbe hart codieren.
Mehr davon gibt es in den kommenden Wochen: Als Nächstes nehme ich die Touch-Steuerung und ihre iOS-Eigenheiten auseinander. Bis dahin — spiel eine Runde und schau dir den Übergang bewusst an. Ab Punkt 15 lohnt sich der Blick auf die Blumen.