0002Die 90er haben angerufen: Sie wollen Ihre Pixel zurück!
18.06.2015
Pixelgrafik wie damals – in GW-Basic mit Zeilennummern.
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:
FOR … NEXT
undGOTO
für Schleifen und SprüngeIF … THEN … ELSE
CLS
,LINE
undCIRCLE
zum ZeichnenRND
für Zufallszahlen
Außerdem sind Zuweisungen zu Variablen und mathematische Ausdrücke, in denen Variablen vorkommen können, möglich. Folgendes ist zu beachten:
- Expressions werden direkt in JavaScript ausgewertet (man muss also zum Testen auf Gleichheit
==
statt=
verwenden,!=
statt<>
,||
stattor
usw.).3 - Es werden nur Variablen ohne Typ-Zeichen unterstützt (d.h.
A$
oderI%
funktionieren nicht), Grund: siehe oben. - In jeder Programmzeile darf nur ein Befehl vorkommen (Doppelpunkt als Befehlstrenner wird nicht unterstützt).
- Nur die wichtigsten Syntaxvarianten der Befehle sind implementiert; Spezialitäten wie z.B. der relative Koordinatensyntax bei
LINE (10,10)-STEP(5,5)
, ein zweiter Radius oder Anfangs-/Endwinkel beiCIRCLE
etc. fehlen.
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 }
-
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. ↩
-
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). ↩ -
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 … ↩
-
Anti-Aliasing war noch eher unbekannt. ↩
Kommentieren