Multiplatform Engine voor Nokia Jam 3310

Het doel van de Jam was om een spel te maken alsof het specifiek voor de Nokia 3310 was ontwikkeld. De algemene beperkingen waren:

  • Het tekengebied van het spel is monochroom en 84x48 pixels.
  • Audio is beperkt tot wat mogelijk is met een Nokia-buzzer.
  • Er kan slechts één audiospoor tegelijk worden afgespeeld (een Nokia 3310 heeft slechts één buzzer).
  • De spelbesturing is beperkt tot 12 knoppen; muisinput is alleen geldig bij interactie met een virtueel toetsenbord.

Voor dit project wilde ik een spel maken met Ebitengine, een 2D-game-engine voor Go. Tegelijkertijd had ik onlangs kennisgemaakt met TinyGo, een Go-compiler voor populaire embedded apparaten. Voorafgaand aan de Jam bouwde ik een eenvoudige game-engine die Ebitengine gebruikt voor desktop/web-targets en TinyGo voor embedded targets. De broncode van deze engine is beschikbaar op https://github.com/hoani/3310_engine.

Aangepaste Nokia PCB

Toen dit project begon, had ik geen 3310-telefoon. In plaats daarvan koppelde ik een hobbyist PCD8544 LCD, een buzzer en een toetsenbord. Deze configuratie gebruikt dezelfde pinout als de aangepaste hardware die ik uiteindelijk voor de 3310-telefoon heb ontworpen.

Zodra ik een 3310-telefoon in bezit had, heb ik deze gedemonteerd om te bepalen hoe hij werkt. Alle mechanische aspecten van de PCB — de omtrek, press-fit pads, LED-plaatsing en toetsenbordplaatsing — zijn bepaald door de mm-markeringen op een liniaal van 20 cm te schatten.

De aangepaste PCB arriveerde drie dagen voordat de Jam begon. Ik heb de eerste versie met de hand gesoldeerd, maar ik maakte een paar fouten:

  • De randen aan de onderkant van de PCB waren onjuist gemeten; ik heb een Dremel (met persoonlijke beschermingsmiddelen) gebruikt om deze af te slijpen.
  • Ik bestelde een 1,6 mm PCB in plaats van een 1 mm PCB. De telefoonhoes klikt niet goed vast tenzij de PCB 1 mm is. Voor mijn behoeften was dit acceptabel, maar wel vervelend.

latere revisies van de hardware hebben deze problemen opgelost. Het huidige PCB-ontwerp is te vinden in de GitHub-repository github.com/hoani/3310_pico.

Aangepaste 3310 Engine

Om Trick Shot II zowel in de browser als op hardware af te kunnen spelen, heb ik de 3310_engine gebouwd. De engine bestaat uit twee belangrijke bibliotheken:

  1. Engine API: De bibliotheken die worden gebruikt om een spel te maken.
  2. Platformabstractie: Deze handelt alle platformspecifieke logica af, zodat de engine consistent werkt op zowel hardware als desktop.

Engine API

De basis van de Engine API is vergelijkbaar met Ebitengine. Het vereist dat de gebruiker een struct biedt die de volgende interface implementeert:

type Game interface {
    Setup(Platform)
    Update() error
    Draw(canvas Canvas) error
}
  • Setup biedt de mogelijkheid om toegang te krijgen tot drivers voor het toetsenbord, de SoundPlayer, de displaycontroller, etc.
  • Update bevat de logica per stap van het spel.
  • Draw stelt het spel in staat om te tekenen op een canvas van 48x84.

Drawing API

De Drawing API handelt veelvoorkomende tekenstappen af:

  • Tekst
  • Vormen
  • Sprites
  • Dithering-hulpmiddelen

Tekst Tekst wordt afgehandeld met tinyfont, een bibliotheek die deel uitmaakt van TinyGo. Dit bevat een generator-tool die TTF/OTF-bestanden converteert naar binaire data, zodat deze in het ROM van het programma worden opgeslagen. De methoden van Tinyfont zijn aangepast om aan te sluiten bij de overige API's van de 3310-engine.

Vormen Er zijn diverse vorm-primitieven beschikbaar in de API. De Triangle Fan- en Triangle Strip-API's zijn gebaseerd op primitieve drawers in GameMaker, wat zeer nuttig kan zijn bij het tekenen van willekeurige vormen.

Sprites De engine is ontworpen om sprites direct in het ROM in te bedden in plaats van ze in het RAM te laden. Dit sluit bestandsformaten als GIF, PNG of JPEG uit, omdat deze compressie gebruiken en verwerkt zouden moeten worden in het RAM om te kunnen renderen.

In plaats daarvan gebruikt de engine PBM-beeldformaten om sprites efficiënt te representeren in een van de twee volgende formats:

  1. Grijswaarden (met een alfa-waarde): gerenderd via dithering.
  2. Monochroom: een enkel monochroom beeld schrijft direct naar het canvas; twee monochrome beelden kunnen worden gebruikt om kleur + transparantie weer te geven.

Het titelbeeld van Trick Shot is een voorbeeld van een grijswaarden-sprite die dithering implementeert voor verschillende grijstinten. De levels in Trick Shot bestaan volledig uit monochrome sprites. Een conversietool (png2pbm) converteert PNG's naar grijswaarden- of monochrome beelden. De API van de engine ondersteunt strip-afbeeldingen, waardoor een sprite meerdere frames kan hebben.

Dithering Dithering is een techniek waarbij tussenkleuren op een beperkt palet worden weergegeven door kleuren af te wisselen. De engine gebruikt een threshold-map over een raster van 8x8 om te bepalen of een waarde tussen 0-64 gekleurd moet worden (0 is volledig transparant, 64 is solide). De gebruikte map is de $M_8$ map uit Wikipedia: Ordered dithering.

Audio API

De basis van de Audio API biedt een reeks Noten die samen een Geluid vormen. De interfaces zien er als volgt uit:

type Note interface {
    Frequency() float32
    Index() note.Index
    Amplitude() uint8
    Duration() time.Duration
}

type Sound interface {
    Reset()
    Next() Note
    Done() bool
}

De engine biedt een reeks vooraf ingestelde Noten van C0 tot B8. Omdat er een buzzer wordt gebruikt, klinken noten onder C3 enkel als een reeks tikken, omdat ze niet resoneren in de buzzer. Een conversietool (midi2go) converteert MIDI-bestanden naar de Sound interface, uitgaande van één enkel spoor, aangezien een PWM-driver Buzzer slechts één kanaal heeft.

Input Commands

Gebruikersinput wordt beheerd via een Command interface:

type Command[T ~int] interface {
    Pressed(cmd T) bool
    Released(cmd T) bool
    Check(cmd T) bool
    PressedAny() bool
    ReleasedAny() bool
    CheckAny() bool
}

Bij het construeren van de commando's wordt een check-methode geregistreerd: func (c *CommandImpl[T]) Register(cmd T, check func() bool)

Een commando kan één of meerdere check-methoden hebben. De spellogica kan vervolgens opvragen of een toets is ingedrukt, losgelaten of momenteel actief is.

Platformabstractie

Het doel van de platformabstractie is om een spel te maken dat consistent wordt uitgevoerd op alle platforms. Dit maakt gebruik van de conditionele compilatie van Go, waardoor alternatieve implementaties van dezelfde code kunnen worden gecompileerd, afhankelijk van het doelplatform.

De belangrijkste verantwoordelijkheden van de platformabstractie zijn:

  • Orchestratie: updaten op de gewenste framerate.
  • Tekenen van een scherm van 48x84.
  • Afhandelen van audio.
  • Gebruikersinput.

Firmware Platform

Firmware-targets worden gebouwd met TinyGo. Broncode die alleen voor firmware is, wordt conditioneel gecompileerd door de volgende richtlijn aan het begin van een bestand toe te voegen: //go build tinygo

Een spel draait doorgaans op een vaste framerate. De orchestratie wordt afgehandeld in een eenvoudige for-loop waarbij de volgende stappen worden doorlopen:

  1. De keypad-driver updaten om gebruikersinput te controleren.
  2. game.Update aanroepen.
  3. De audio-driver updaten.
  4. game.Draw aanroepen.
  5. Het canvas tekenen naar het PCD8544-display.
  6. Slapen tot het volgende frame begint.

Firmware Keypad Driver De keypad-driver bestaat uit een matrix van rijen en kolommen om alle 16 toetsen van een Nokia 3310 te ondersteunen. Er zijn 4 kolom-inputs en 4 rij-outputs. Het algoritme werkt als volgt: voor elke rij-output wordt de rij "hoog" gezet terwijl alle andere rijen "laag" zijn. Vervolgens wordt voor elke kolom-input gecontroleerd of deze hoog (toets ingedrukt) of laag (toets losgelaten) is.

Firmware Audio Driver De audio-driver gebruikt een PWM-output om een buzzer aan te sturen. De audio API levert een amplitude en frequentie. De PWM-periode wordt aangepast aan de gevraagde frequentie, en de amplitude varieert de duty cycle van 0 tot 50%. In de praktijk heeft de amplitude een zeer niet-lineair effect op het volume van de buzzer.

Firmware Display Driver Het canvas van het spel is een wrapper rond de PCD8544-driver. Dit betekent dat de meeste calls van de draw API in feite de PCD8544-driver voorbereiden voor het tekenen.

Board Abstractie

Er zijn twee niveaus van abstractie: de firmware/desktop-abstractie via de TinyGo-richtlijn, en de targetspecifieke abstractie. Dit laatste is bedoeld om andere boards te kunnen gebruiken, zoals een Teensy-gebaseerd board in plaats van de Raspberry Pi Pico.

Een aangepaste board-definitie kan worden opgegeven door de board.Definition interface te implementeren:

type Definition interface {
    Initialize()
    Pcd() *Pcd
    Buzzer() *Buzzer
    Keypad() *Keypad
}

Vervolgens kan board.Set(input board.Definition) worden aangeroepen om het eigen custom board te gebruiken. Deze abstractie wordt momenteel gebruikt voor latere versies van het 3310_pico board, inclusief RGB-achtergrondverlichting en batterijmeting.

Desktop Platform

Het desktop-platform maakt uitgebreid gebruik van Ebitengine en wordt conditioneel gecompileerd met: //go build !tinygo

Ebitengine heeft twee methoden, Update en Draw, die de 3310-engine gebruikt voor orchestratie:

  • In Update: De keypad-driver updaten, game.Update aanroepen en game.Draw aanroepen.
  • In Draw: Shaders toepassen om het LCD-scherm te emuleren en tekenen naar het Ebiten-canvas.

Desktop Keypad Driver Een applicatie kan een eigen remapping van de keypad-driver schrijven, wat logisch is aangezien computertoetsenborden sterk verschillen van een telefoon-toetsenbord.

Desktop Audio Emulatie In plaats van audiobestanden te importeren, synthetiseert de engine audiogolven om de buzzer na te bootsen. De audio-API specificeert sequenties van noten. De gesynthetiseerde audio is in feite een zaagtandgolf die door een banddoorlaatfilter wordt geleid om een geluid te produceren dat lijkt op een buzzer.

Desktop Screen Emulatie Het scherm wordt geëmuleerd met een paar shaders:

  1. Response time (shadowing) emulatie (84x48) → (84x48).
  2. Pixel appearance emulatie (84x48) → (schermresolutie).

Deze shaders zijn ontwikkeld naast een echt PCD8544-display om ervoor te zorgen dat de afmetingen en effecten correct aanvoelen.

Desktop Extensions Extensions werken buiten de hoofdexecutie van het spel. Hiermee kunnen zaken worden gedaan zoals het tekenen van overlays, het berekenen en weergeven van debug-informatie, of het tekenen van een telefoonframe rond het tekengebied.