gehacktes /// noniq.at

0002Die 90er haben angerufen: Sie wollen Ihre Pixel zurück!

Pixelgrafik wie damals – in GW-Basic mit Zeilennummern.

BASIC CoffeeScript Pixelart Retrocomputing Software Projects

Schon am C128 hab ich gern kleine Basic-Programme geschrieben, die Linien und Kreise auf den Bildschirm malten – allerdings war das Thema bei 320×200 Pixeln und 2 Farben bald ausgereizt. Ganz anders dann auf meinem ersten PC1: 640×350 Pixel in 16 Farben2 – so viele Auflösung, und noch dazu in Farbe und bunt!

Programmiert hab ich am PC damals in GW-Basic: Keine Spur von Methoden, lokalen Variablen oder ähnlichem neumodischen Zeugs – man hatte Zeilennummern, das musste reichen.

Ein (minimaler) GW-Basic-Emulator

Um dieses Feeling nochmal zu erleben hab ich irgendwann 2013 einen Mini-Emulator für Pixelbilder in GW-Basic geschrieben, der folgende Befehle unterstützt:

Außerdem sind Zuweisungen zu Variablen und mathematische Ausdrücke, in denen Variablen vorkommen können, möglich. Folgendes ist zu beachten:

Vier kurze, bearbeitbare Beispielprogramme zum Ausprobieren und Rumspielen:

250 Zeilen CoffeeScript

Der Emulator besteht aus ca. 250 Zeilen CoffeeScript. Damit der Output möglichst originalgetreu „pixelig“ ausschaut4, verwende ich zum Zeichnen von Linien und Kreisen keine Canvas-Funktionen, sondern erzeuge diese Figuren Pixel für Pixel mit dem Bresenham-Algorithmus (siehe die Funktionen line und circle gegen Schluss des Codes).

Disclaimer: Einen Parser / Interpreter auf Basis von RegExps zu schreiben funktioniert ungefähr so gut wie HTML mit RegExps zu parsen, nämlich eigentlich gar nicht. Für Spaßprojekte reicht’s aber. Wie man sowas richtig™ macht, lässt sich z.B. im – überhaupt sehr lesenswerten – Buch Understanding Computation nachlesen (Affiliatelink).

# Sample usage:
#   canvas = document.getElementById("gw-canvas")
#   interpreter = new GWBasicGraphics(new Screen(canvas))
#   interpreter.run("… [program code here] …")

class GWBasicGraphics
  constructor: (@screen, @tickDelay = 20) ->

  run: (source, callbacks) ->
    @onError = callbacks.onError
    @onStop  = callbacks.onStop
    try
      @program  = new Program(source)
      @env      = new Environment(@program.lines.first(), @screen)
      @ticker   = setInterval(@tick, @tickDelay)
    catch err
      @stop()
      @onError(err)

  stop: ->
    clearInterval(@ticker)
    @onStop()

  tick: =>
    try
      if @env.currentLine then @step() else @stop()
    catch err
      @stop()
      @onError(err, @env.currentLine)

  step: ->
    line = @env.currentLine
    line.execute(@env)
    @env.currentLine = if @env.jumpToLineNr
      @program.lineWithNr(@env.jumpToLineNr)
    else if @env.redoCurrentLoop
      @program.lineAfter(@env.currentLoop().startLine)
    else
      @program.lineAfter(line)
    @env.jumpToLineNr = null
    @env.redoCurrentLoop = false


class Program
  constructor: (source) ->
    @lines = @parseLines(source)

  lineAfter: (givenLine) ->
    @lines.find (line) -> line.nr > givenLine.nr

  lineWithNr: (nr) ->
    @lines.find (line) -> line.nr == nr

  parseLines: (source) ->
    lines = source.split("\n").map (line) => @parseLine(line)
    lines.compact().sortBy (line) => line.nr

  parseLine: (string) ->
    return if string.match(/^ *$/)
    if matches = string.match(/^ *(\d+) +(.*)/)
      [nr, rest] = matches.splice(1)
      new Line(parseInt(nr, 10), rest)
    else
      throw "SYNTAX ERROR: #{string}"


class Line
  constructor: (@nr, @source) ->
    @statement = new Statement(@source)

  execute: (env) ->
    @statement.execute(env)


class Statement
  @EXECUTORS: [
    { 
      regex: /^GOTO (\d+) *$/,
      impl: (m, env) ->
        lineNr = parseInt(m[1], 10)
        env.jumpToLineNr = lineNr
    }, { 
      regex: /^FOR (\w+) *= *(.+) TO (.+?)(?: +STEP +(.+))? *$/, 
      impl: (m, env) -> 
        [variable, from, to, step] = m.slice(1)
        env.variables[variable] = env.evaluate(from)
        env.addLoop(variable, env.evaluate(to), env.evaluate(step)) 
    }, { 
      regex: /^NEXT *$/, 
      impl: (m, env) -> 
        loopData = env.currentLoop()
        env.variables[loopData.variable] += loopData.step
        if env.variables[loopData.variable] <= loopData.bound
          env.redoCurrentLoop = true
        else
          env.removeLoop()
    }, { 
      regex: /^IF +(.+) +THEN +(.+?)(?: +ELSE +(.*))? *$/,
      impl: (m, env) ->
        [condition, ifTrue, ifFalse] = m.slice(1)
        stmtSource = if env.evaluate(condition) then ifTrue else ifFalse
        new Statement(stmtSource).execute(env) if stmtSource
    }, { 
      regex: /^CLS *$/,
      impl: (m, env) ->
        env.screen.clear()
    }, { 
      regex: /^LINE (?:\((.+?), *(.+?)\))? *- *\((.+?), *(.+?)\)(?:,(?: *([^,]+))?(?:, *(BF?))?)? *$/,
      impl: (m, env) -> 
        [x1, y1, x2, y2] = m.slice(1, 5).map (expr) -> env.evaluate(expr) if expr
        x1 ?= env.lastReferencedPoint.x
        y1 ?= env.lastReferencedPoint.y
        env.lastReferencedPoint = {x: x2, y: y2}
        color = if m[5] then env.evaluate(m[5]) else 15
        switch m[6]
          when "BF" then env.screen.filledRect(x1, y1, x2, y2, color)
          when "B"  then env.screen.rect(x1, y1, x2, y2, color)
          else           env.screen.line(x1, y1, x2, y2, color)
    }, { 
      regex: /^CIRCLE \((.+?), *(.+?)\), *([^,]+?)(, *(.+))? *$/,
      impl: (m, env) -> 
        [x, y, radius] = m.slice(1, 4).map (expr) -> env.evaluate(expr)
        color = if m[5] then env.evaluate(m[5]) else 15
        env.screen.circle(x, y, radius, color)
    }, { 
      regex: /^(\w+) *= *(.+) *$/,
      impl: (m, env) -> 
        [variable, expr] = m.slice(1)
        env.variables[variable] = env.evaluate(expr)
    }, { 
      regex: /.+/,
      impl: (m, env) -> throw "SYNTAX ERROR" 
    }
  ]

  constructor: (@source) ->
    executor = Statement.EXECUTORS.find (e) => e.regex.test(@source)
    matches = @source.match(executor.regex)
    @implementation = executor.impl.bind(this, matches)

  execute: (env) ->
    @implementation(env)


class Environment
  constructor: (@currentLine, @screen, @variables = {}) ->
    @jumpToLineNr = null
    @redoCurrentLoop = false
    @loops = []
    @lastReferencedPoint = {x: 0, y: 0}

  addLoop: (variable, bound, step = 1) ->
    @loops.push(variable: variable, bound: bound, step: step, startLine: @currentLine)

  currentLoop: ->
    @loops.last()

  removeLoop: ->
    @loops.pop()

  evaluate: (expr) ->
    return unless expr
    for variable, value of @variables
      expr = expr.replace(new RegExp("\\b#{variable}\\b", "g"), value)
    expr = expr.replace(/RND/g, "Math.random()")
    eval(expr)


class Screen
  COLORS = [
    "#000000", "#0000AA", "#00AA00", "#00AAAA", 
    "#AA0000", "#AA00AA", "#AA5500", "#AAAAAA", 
    "#555555", "#5555FF", "#55FF55", "#55FFFF", 
    "#FF5555", "#FF55FF", "#FFFF55", "#FFFFFF" ]

  constructor: (@canvas) ->
    @ctx       = @canvas.getContext("2d")
    @width     = @canvas.width
    @height    = @canvas.height
    @imageData = @ctx.createImageData(@width, @height)
    @clear()

  clear: ->
    @ctx.fillStyle = COLORS[0]
    @ctx.fillRect(0, 0, @width, @height)

  line: (x1, y1, x2, y2, color = 15) ->
    [x1, y1, x2, y2, color] = [x1, y1, x2, y2, color].map (v) -> Math.floor(v)
    dx = Math.abs(x2 - x1)
    dy = Math.abs(y2 - y1) 
    sx = if x1 < x2 then 1 else -1
    sy = if y1 < y2 then 1 else -1
    err = dx - dy
    loop
      @setPixel(x1, y1, color)
      break if x1 == x2 and y1 == y2
      e2 = 2 * err
      if e2 > -dy 
        err -= dy
        x1 += sx 
      if e2 < dx
        err += dx
        y1 += sy 

  rect: (x1, y1, x2, y2, color = 15) ->
    [x1, y1, x2, y2, color] = [x1, y1, x2, y2, color].map (v) -> Math.floor(v)
    @line(x1, y1, x2, y1, color)
    @line(x1, y2, x2, y2, color)
    @line(x1, y1, x1, y2, color)
    @line(x2, y1, x2, y2, color)

  filledRect: (x1, y1, x2, y2, color = 15) ->
    [x1, y1, x2, y2, color] = [x1, y1, x2, y2, color].map (v) -> Math.floor(v)
    @ctx.fillStyle = COLORS[color]
    [x2, x1] = [x1, x2] if x1 > x2
    [y2, y1] = [y1, y2] if y1 > y2
    @ctx.fillRect(x1, y1, x2 - x1, y2 - y1)

  circle: (x0, y0, radius, color = 15) ->
    [x0, y0, radius, color] = [x0, y0, radius, color].map (v) -> Math.floor(v)
    x = radius
    y = 0
    radiusError = 1 - x
    while x >= y
      @setPixel(x + x0, y + y0, color)
      @setPixel(y + x0, x + y0, color)
      @setPixel(-x + x0, y + y0, color)
      @setPixel(-y + x0, x + y0, color)
      @setPixel(-x + x0, -y + y0, color)
      @setPixel(-y + x0, -x + y0, color)
      @setPixel(x + x0, -y + y0, color)
      @setPixel(y + x0, -x + y0, color)
      y += 1
      if radiusError < 0
        radiusError += 2 * y + 1
      else
        x -= 1
        radiusError += 2 * (y - x + 1)


  setPixel: (x, y, color) ->
    @ctx.fillStyle = COLORS[color]
    @ctx.fillRect(x, y, 1, 1)

export { GWBasicGraphics, Screen }


  1. Ein 386SX mit 2 MB RAM und VGA-Grafikkarte – gerade genug, um Windows 3.1 im „Extended Modus“ (echtes präemptives Multitasking!) betreiben zu können. 

  2. Eigentlich konnte die Grafikkarte sogar 640×480 in 16 Farben (VGA) – aber bei GW-Basic war mit EGA, also 640×350, Schluss (SCREEN 9, falls sich noch wer erinnert). 

  3. Auf diese Weise kann man dem Emulator natürlich auch diverse Sachen unterjubeln, die in GW-Basic nicht möglich waren – z.B. JavaScript-Objekte etc. Aber das wäre natürlich stillos … 

  4. Anti-Aliasing war noch eher unbekannt. 


Kommentieren

Bitte sei höflich und konstruktiv. Kommentare werden manuell überprüft und freigeschaltet.

Kommentar konnte nicht abgeschickt werden. Bitte fülle alle Felder aus.
Ein technisches Problem ist aufgetreten. Bitte versuche es später nochmal.
Vielen Dank! Wir informieren dich, sobald dein Kommentar freigeschaltet wurde.

0001Eine sd2iec für den Commodore 128WLAN für den Tischtenniskeller0003