Skip to content

Instantly share code, notes, and snippets.

@washort
Last active December 13, 2019 04:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save washort/4f020d64f72520ba1ca85bf543c2b680 to your computer and use it in GitHub Desktop.
Save washort/4f020d64f72520ba1ca85bf543c2b680 to your computer and use it in GitHub Desktop.
import "lib/codec/utf8" =~ [=> UTF8]
import "buda" =~ [=> makeBuda, => rulesFromMap, => which]
exports (main)
def main(_argv, => makeProcess, => currentProcess, => makeFileResource, => stdio) as DeepFrozen:
#find `monte` and `emacs` if they weren't specified as env vars
def [
(b`MONTE`) => var MONTE := null,
(b`EMACS`) => var EMACS := null,
(b`PATH`) => PATH := b``
] | _ := currentProcess.getEnvironment()
if (MONTE == null):
MONTE := which(makeFileResource, PATH, b`monte`)
if (EMACS == null):
EMACS := which(makeFileResource, PATH, b`emacs`)
object bake:
to getDependencies():
return [b`*.mt`]
to run(buda, prefix, destPath):
return buda.do(MONTE, [b`monte`, b`bake`, b`${prefix}.mt`, destPath])
object orgExport:
to getDependencies():
return [b`game.org`]
to run(buda, _prefix, destPath):
return buda.do(EMACS, [b`emacs`, b`game.org`, b`-q`, b`--batch`, b`--eval`, b`(progn (require 'ob-tangle) (setq org-src-preserve-indentation t) (org-babel-goto-named-src-block "root") (org-babel-tangle '(4) "${destPath}"))`])
object makeWrapper:
to getDependencies():
return [b`game.mast`, b`mcurses.mast`]
to run(buda, _prefix, destPath):
return when (makeFileResource(UTF8.decode(destPath, null)).setContents(b`#!/bin/sh$\n${MONTE} eval game.mast`)) ->
buda.do(b`chmod`, [b`chmod`, b`+x`, destPath])
def buda := makeBuda(
rulesFromMap(
[
b`game` => makeWrapper,
b`game.mt` => orgExport,
],
[
b`.mast` => bake,
]),
=> makeProcess, => makeFileResource, => stdio,
)
return when (MONTE, EMACS) ->
buda.main(b`game`)

The concept

This is a reimplementation of the classic game ‘hunt’ from the BSD Games collection. Players maneuver through a top-down view of a destructible maze, trying to stay alive and kill each other.

Design

I/O

We’re going to start with a design fairly close to the original. Our server will be turn-based – when no inputs arrive, the action stops. Our clients (for now) will be dumb – they send input events to the server and receive draw events.

Output

def [[_width, _height], [tIn, tOut]] := activateTerminal(stdio)
object outputHandler:
    to draw(row, col, txt :Bytes):
        tOut.move(row, col)
        tOut.write(txt)
    to drawMultiple(drawList):
        var lastCol := 0
        var lastRow := 0
        for [col :Int, row :Int, txt :Bytes] in (drawList):
            if (!(col == (lastCol + 1) && row == lastRow)):
                tOut.move(row + 1, col + 1)
            tOut.write(txt)
            lastCol := col
            lastRow := row
    to message(msg :Str):
        tOut.move(HEIGHT, 0)
        tOut.write(UTF8.encode(msg, throw))
        tOut.eraseRight()
    to quit():
        tIn.quit()

        endR.resolve(0)

This client-side code drives the terminal. Hunt’s needs are pretty simple; this object provides methods for writing to locations on the display. A `drawMultiple` method allows coalescing adjacent writes efficiently. The `endR` resolver signals completion to the top level when done.

Input

def player := gameController.createPlayer(outputHandler, "Player", 1, null)

object stdinResponder as Sink:
    to run([kind, content]):
        return if (kind == "KEY" && keyMapping.contains(content)) {
            M.sendOnly(player, keyMapping[content], [], [].asMap())
        } else if (kind == "DATA") {
            var ps := []
            for b in (content) {
                def s := _makeStr.fromChars(['\x00' + b])
                if (keyMapping.contains(s)) {
                    M.sendOnly(player, keyMapping[s], [], [].asMap())
                }
            }
        }

    to complete():
        endR.resolve(0)
    to abort(problem):
        endR.smash(problem)

The client’s input listener decodes keys and maps them to event names such as “faceLeft”, “fire”, etc. Note that it’s using `sendOnly` here, and so will work the same whether `player` is a reference to a local object or one hosted remotely. Again, `endR` is used to signal normal or erroneous disconnection from stdin.

Terminal interface

def p0 := tIn.flowTo(stdinResponder)
Ref.whenBroken(p0, traceln.exception)
tOut.enterAltScreen()
tOut.clear()
tOut.hideCursor()
player <- start()

return when (end) -> {
        stdio.stdout().complete()
        tOut.leaveAltScreen()
        tOut.showCursor()
        traceln(`Press any key to exit. Sorry.`)
        end
    } catch p {
        tOut.leaveAltScreen()
        tOut.showCursor()
        traceln.exception(Ref.optProblem(end))
        traceln.exception(Ref.optProblem(p0))
        traceln.exception(Ref.optProblem(p))
        1
    }

Finally we plumb all our IO pieces together. `tIn` is an object wrapping stdin that decodes terminal escape sequences into key names such as `F1`, `RIGHT_ARROW`, etc. `tOut` provides methods emitting terminal escape codes such as `move`, `insertLines`, `eraseRight`, and so forth. We set up the display and signal the player object representing the user that we are ready to begin.

The `end` value is a promise that resolved by `endR` above, and here we perform either a clean exit or displaying error info as needed.

Maze

<<digMaze>>
<<smoothMaze>>
<<makeMaze>>

Generation

The maze is generated by carving tunnels through the gameboard. A starting point is picked and tunnels are dug in random directions. After the structure is determined, the walls are adjusted to look right. Our current implementation builds a map of [x, y] coordinates to cell contents for convenience.

def mazeArray := [for _ in (0..!HEIGHT) ([b`#`] * WIDTH).diverge()]
digMaze(entropy, mazeArray,
        entropy.nextInt(WIDTH),
        entropy.nextInt(HEIGHT))
remap(entropy, mazeArray)
def mazeMap := {
    def pairs := [].diverge()
    for y => row in (mazeArray) {
        for x => b in (row) {
            pairs.push([[x, y], b])
        }
    }
    _makeMap.fromPairs(pairs)
}

Tunnel digging

The starting space is dug, directions are selected randomly, then two steps are taken in the direction chosen.
def digMaze(entropy, maze, x, y) as DeepFrozen:
    def [NORTH, SOUTH, EAST, WEST] := [1, 2, 4, 8]
    def order := [NORTH, 0, 0, 0].diverge()
    maze[y][x] := SPACE
    for i in (1..!4):
        def j := entropy.nextInt(i + 1)
        order[i] := order[j]
        order[j] := 1 << i
    for i in (0..!4):
        def [tx, ty] := switch (order[i]) {
            match ==NORTH {[x, y - 2]}
            match ==SOUTH {[x, y + 2]}
            match ==EAST  {[x + 2, y]}
            match ==WEST  {[x - 2, y]}
        }

If an existing tunnel or the edge of the board is hit, end this one.

if (tx < 0 || ty < 0 || tx >= WIDTH || ty >= HEIGHT):
    continue
if (maze[ty][tx] == SPACE):
    continue

Otherwise dig out the space between the previous location and the current one. Continue the tunnel at the current space.

maze[(y + ty) // 2][(x + tx) // 2] := SPACE
digMaze(entropy, maze, tx, ty)

Wall smoothing

Look at all non-wall tiles and categorize them by what wall tiles are adjacent.
def remap(entropy, maze) as DeepFrozen:
    def [NORTH, SOUTH, EAST, WEST] := [1, 2, 8, 16]
    for y in (0..!HEIGHT):
        for x in (0..!WIDTH):
            if (maze[y][x] == SPACE):
                continue
            var stat := 0
            if (y - 1 >= 0 && maze[y - 1][x] != SPACE):
                stat |= NORTH
            if (y + 1 < HEIGHT && maze[y + 1][x] != SPACE):
                stat |= SOUTH
            if (x + 1 < WIDTH && maze[y][x + 1] != SPACE):
                stat |= EAST
            if (x - 1 >= 0  && maze[y][x - 1] != SPACE):
                stat |= WEST

Tiles with only east and west neighbors get `-`, tiles with only north and south neighbors get `-`, and all others get `+`.

if ([WEST | EAST, EAST, WEST].contains(stat)):
    maze[y][x] := WALL1
else if ([NORTH | SOUTH, NORTH, SOUTH].contains(stat)):
    maze[y][x] := WALL2
else if (stat == 0):
    #ifdef RANDOM
    # maze[y][x] := DOOR
    #ifdef REFLECT
    maze[y][x] := if (entropy.nextBool()) {WALL4} else {WALL5}
else:
    maze[y][x] := WALL3

Gameplay behavior

def makeMaze(entropy) as DeepFrozen:
    <<generateMaze>>
    def damageTiles := [].asMap().diverge()
    return object maze:
        to get(x, y):
            if (damageTiles.contains([x, y])):
                return SPACE
            return mazeArray[y][x]
        <<mazeCollide>>
        <<mazeRender>>

The maze object consists of the generated “permanent” contents and a list of damaged tiles representing walls shot down. It provides a `get` method so `maze[someX, someY]` will return the current contents of that cell, taking damage into account.

Game objects

Items

def makeItemStocker(maze, entropy) as DeepFrozen:
    def items := [].asMap().diverge()
    return object itemStocker:
        to restockMines():
            while (true):
                def x := entropy.nextInt(WIDTH)
                def y := entropy.nextInt(HEIGHT)
                if (maze[x, y] == SPACE):
                    items[[x, y]] := GMINE
                    break
            while (true):
                def mx := entropy.nextInt(WIDTH)
                def my := entropy.nextInt(HEIGHT)
                if (maze[mx, my] == SPACE):
                    items[[mx, my]] := MINE
                    break
        <<itemsCollide>>
        <<itemsRender>>

The `itemStocker` manages pickup items, like mines and boots.

Shots

def makeShotTracker(gameboard, theEventArena, _entropy) as DeepFrozen:
    def shots := [].asSet().diverge()
    return object shotTracker:
        to remove(shot):
            shots.remove(shot)
        to add(var x, var y, type, direction, charge, owner):
            var live := true
            object shot:
                to getKind():
                    return "shot"
                to getCoords():
                    return [x, y]
                to getCharge():
                    return charge
                <<singleShotRender>>
                to hitMaze():
                    shots.remove(shot)
                    live := false
                to move():
                    <<shotMove>>
                    theEventArena.add(shotMove)
            shots.include(shot)
            shot.move()

        <<shotCollide>>
        <<shotsRender>>

The `shotTracker` manages `shot` objects, which represent every type of moving projectile.

break up and document player stuff

def makePlayer(eventArena, outputHandler, name, team, enterStatus):
    # stplayer, answer.c
    var x := entropy.nextInt(WIDTH)
    var y := entropy.nextInt(HEIGHT)
    var flying := 0
    var cloak := 0
    var scan := 0

    var score := 0.0
    var numBoots := 0
    var damage := 0
    var damageCap := MAXDAM
    var ammo := ISHOTS
    var ncshot := 0
    # if (enterStatus == "FLY"):
    #     flying := entropy.nextInt(20)
    #     flyX := 2 * entropy.nextInt(6) - 5
    #     flyY := 2 * entropy.nextInt(6) - 5
    # else if (enterStatus == "SCAN"):
    #     scan := gameController.numPlayers() * SCANLEN
    # else if (enterStatus == "CLOAK"):
    #     cloak := CLOAKLEN
    while (maze[x, y] != SPACE):
        x := entropy.nextInt(WIDTH)
        y := entropy.nextInt(HEIGHT)

    var face := MOVEGRID.getKeys()[entropy.nextInt(3)]


    def player

    def move(tx, ty):
        <<playerMove>>
        eventArena.add(moveEvent)

    def faceTo(o ? (MOVEGRID.getKeys().contains(o))):
        <<faceEvent>>
        eventArena.add(faceEvent)

    def statChar():
        return if (flying > 0) {
            "&"
        } else if (scan > 0) {
            "*"
        } else if (cloak > 0) {
            "+"
        } else {
            " "
        }

    def showExpl(ey, ex, type):
        null

    def fire
    object inputHandler:
        to start():
            null
        to moveUp():
            move(x, 0.max(y - 1))
        to moveDown():
            move(x, (HEIGHT - 1).min(y + 1))
        to moveLeft():
            move(0.max(x - 1), y)
        to moveRight():
            move((WIDTH - 1).min(x + 1), y)
        to faceUp():
            faceTo(b`^`)
        to faceDown():
            faceTo(b`v`)
        to faceLeft():
            faceTo(b`<`)
        to faceRight():
            faceTo(b`>`)
        to shot():
            fire(0)
        to grenade():
            fire(1)
        to quit():
            return outputHandler <- quit()

    def playerViewArray := [for _ in (0..!HEIGHT) ([b` `] * WIDTH).diverge()]
    var viewUpdates := [].asMap().diverge()
    var viewFilter := [].asSet()
    def rotate([x, y]):
        return [[x.abs() ^ 1, y.abs() ^ 1],
                [-(x.abs() ^ 1), -(y.abs() ^ 1)]]
    def seeNearby():
        def localCoords := [
            [x,     y],
            [x + 1, y],
            [x - 1, y],
            [x, y + 1],
            [x, y - 1],
            [x + 1, y + 1],
            [x + 1, y - 1],
            [x - 1, y + 1],
            [x - 1, y - 1]
        ]
        return [for [sx, sy] in (localCoords)
                ? (inMazeBounds(sx, sy)) [sx, sy]].asSet()

    def canSeeOver(tile):
        return ![DOOR, WALL1, WALL2, WALL3, WALL4, WALL5].contains(tile)

    def see([dx, dy]):
        var sx := x + dx
        var sy := y + dy
        def cells := [].diverge()
        while (inMazeBounds(sx, sy)):
            cells.push([sx, sy])
            def [[lx, ly], [rx, ry]] := rotate([dx, dy])
            def leftX := sx + lx
            def leftY := sy + ly
            def rightX := sx + rx
            def rightY := sy + ry
            if (inMazeBounds(leftX, leftY)):
                cells.push([leftX, leftY])
            if (inMazeBounds(rightX, rightY)):
                cells.push([rightX, rightY])
            if (!canSeeOver(maze[sx, sy])):
                break
            sx += dx
            sy += dy
        return cells.asSet()

    bind player:
        to getKind():
            return "player"
        to getInputHandler():
            return inputHandler
        to damage(amount):
            damage += amount
        to look():
            def [left, right] := rotate(MOVEGRID[face])
            for dir in ([MOVEGRID[face], left, right]):
               viewFilter |= see(dir)
            viewFilter |= seeNearby()

        <<playerRender>>

        to updateViewmap(tilemap):
            def points := viewFilter# | tilemap.getKeys().asSet()
            for point in (points):
                def [x, y] := point
                def tile := tilemap.fetch(point, fn {
                    maze[x, y]})
                if (playerViewArray[y][x] != tile):
                    viewUpdates[point] := tile
                    playerViewArray[y][x] := tile

        to pushViewChanges():
            outputHandler <- drawMultiple([for [x, y] => tile in (viewUpdates) [x, y, tile]])
            viewUpdates := [].asMap().diverge()
        to getScore():
            return score
        to statusLine():
            def scoreS := M.toString(score)
            return `${scoreS.slice(0, scoreS.size() - 4)}${statChar()}$name $team`

    bind fire(var weaponN):
        while (weaponN >= 0 && ammo < shotCost[weaponN]):
            weaponN -= 1
        if (weaponN < 0):
            outputHandler <- message("Not enough charges.")
            return
        if (ncshot > MAXNCSHOT):
            return
        shotTracker.add(x, y, shotCost[weaponN], face, null, player)
        # ncshot += 1
        # if (ncshot == MAXNCSHOT):
        #     outputHandler <- draw(STAT_GUN_ROW, STAT_VALUE_COL, b`   `)
        # ammo -= shotCost[weaponN]
        # outputHandler <- draw(STAT_AMMO_ROW, STAT_VALUE_COL,
        #                       UTF8.encode(`$ammo`, throw))
        # addShot(shotType[weaponN], y, x, face, shotCost[weaponN], player,
        #         false, face, liveShots)
        # underShot := true
        # showExpl(y, x, shotType[weaponN])
        # gameController.refreshPlayers()
    move(x, y)
    return [inputHandler, player]

Collisions and events

Event definitions

Shots

def shotMove.execute():
    def [dx, dy] := MOVEGRID[direction]
    for _ in (0..!BULSPD):
        if (live):
            x += dx
            y += dy
            if (!gameboard.collide(theEventArena, shot, x, y)):
                theEventArena.damageView(x, y)
    if (live):
        theEventArena <- add(shotMove)

The shot moves `BULSPD` spaces in the direction it’s travelling, and collides with each cell along its route, marking them for view update. If the collision process didn’t deactivate the shot, it queues another move event for next turn.

Players

def moveEvent.execute():
    if (!gameboard.collide(eventArena, player, tx, ty)):
        eventArena.damageView(x, y)
        eventArena.damageView(tx, ty)
        x := tx
        y := ty
        player.look()

Move the player and update view for its new and old locations, then update the area visible to the player once in the new location.

def faceEvent.execute():
    if (face != o):
        face := o
        eventArena.damageView(x, y)
        player.look()

Change the player’s orientation, which changes how it’s displayed and what’s visible to it.

Implement stepping on mines.

def hitMineEvent.execute() { }
def hitGMineEvent.execute() { }

Collision behavior

refactor collisions

It wouldn’t surprise me if these all need to be handled in a single function instead of done as methods on each kind of thing. There’s no single clear allocation of who does what when two objects collide.

Wall damage

Maze edges count as walls but are indestructible.
to collide(eventArena, item, x, y):
    if (!inMazeBounds(x, y)):
        if (item.getKind() == "shot"):
            item.hitMaze()
        return false

The `damageTiles` overlay directs us to treat damaged spots in the maze as clear.

if (damageTiles.contains([x, y]) || mazeArray[y][x] == SPACE):
    return false

But if this is an in-bounds undamaged spot, we add it to the damage overlay (making the oldest damaged tile heal if we’re past `MAXMAZEDAMAGE` tiles destroyed), tell the item it hit the maze, and get ready to inform the client that a tile needs redrawing as gone.

if (item.getKind() == "shot"):
    damageTiles[[x, y]] := SPACE
    eventArena.damageView(x, y)
    if (damageTiles.size() > MAXMAZEDAMAGE):
        def pair := damageTiles.getKeys()[0]
        damageTiles.removeKey(pair)
    item.hitMaze()
return true

Item pickup

to collide(ev, item, x, y):
    if (!items.contains([x, y]) || item.getKind() != "player"):
        return false
    switch (items[[x, y]]):
        match ==MINE:
            <<hitMineEvent>>
            ev.add(hitMineEvent)
        match ==GMINE:
            <<hitGMineEvent>>
            ev.add(hitGMineEvent)
    return true

Currently, only when players collide with an item does anything happen. The “hit mine” events are scheduled when a player steps on one.

Shot collisions

to collide(theEventArena, item, x, y):
    if (item.getKind() == "player"):
        for s in (shots):
            if (s.getCoords() == [x, y]):
                #s.explode()
                item.damage(s.getCharge())
                s.remove()
                return true
    return false

Hitting a player causes damage according to the shot’s power level then removes the shot from the board.

Event plumbing

object gameboard:
    to collide(eventArena, item, x, y):
        "An item has moved, give all other pieces of the game an opportunity
        to generate an event"
        return (maze.collide(eventArena, item, x, y) ||
                itemStocker.collide(eventArena, item, x, y) ||
                shotTracker.collide(eventArena, item, x, y)
                )#|| playerTracker.collide(eventArena, item, x, y))

def allPlayers := [].asMap().diverge()
def advanceGame():
    # Process events
    when (theEventArena <- execute()) ->
        # Collect coordinates updated by events
        def viewdamageTiles := theEventArena.collectViewDamage()
        var output := [].asMap()
        <<render>>
        # Update player views
        for p in (allPlayers.getKeys()):
            p.updateViewmap(output)
            # Send player-view changes to clients
            p.pushViewChanges()
    catch p:
        traceln.exception(p)

object gameController:
    to createPlayer(outputHandler, name, team, enterStatus):

        def [player, playerView] := makePlayer(theEventArena, outputHandler, name, team, enterStatus)
        itemStocker.restockMines()
        allPlayers[playerView] := outputHandler
        gameController.drawPlayerStats(outputHandler)
        return object playerWrapper:
            match msg:
                M.callWithMessage(player, msg)
                advanceGame()
    to removePlayer(p):
        allPlayers.remove(p)

    to drawPlayerStats(out):
        for i => p in (allPlayers.getKeys()):
            out <- draw(STAT_PLAY_ROW + 1 + i, STAT_NAME_COL,
                        UTF8.encode(p.statusLine(), throw))


def makeEventArena() as DeepFrozen:
    var events := [].diverge()
    var pendingViewDamage := [].asSet().diverge()
    return object theEventArena:
        to add(event):
            events.push(event)
        to execute():
            for e in (events):
                e.execute()
            events := [].diverge()
        to damageView(x, y):
            pendingViewDamage.include([x, y])
        to collectViewDamage():
            def vd := pendingViewDamage
            pendingViewDamage := [].asSet().diverge()
            return vd

Rendering

Recall that the events above all reported which tiles needed to be redrawn as a result of their actions. After all of these events have run, we can collect the definitive version of the updated tiles, in order to send only the changed contents to our clients.

for unit in ([maze, itemStocker, shotTracker] + allPlayers.getKeys()):
    output := unit.render(viewdamageTiles) | output

We’re going to ask each game component to contribute to the output; each one will look at `viewDamageTiles` to decide what parts of its state to report. Note that `viewDamageTiles` will be `null` when a client first connects; this is used to request a full draw of everything.

Maze

to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
    def view := damageTiles | mazeMap
    if (viewdamageTiles == null):
        return view
    return [for k in (viewdamageTiles) ? (view.contains(k)) k => view[k]]

The maze itself contributes walls or spaces, taking damage into account.

Items

to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
    if (viewdamageTiles == null):
        return items
    return [for k in (viewdamageTiles) ? (items.contains(k)) k => items[k]]

Show any pickup items that might have spawned in this turn.

Shots

to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
    var output := [].asMap()
    for s in (shots):
        output := s.render(viewdamageTiles) | output
    return output

Multiple shots can be in flight at once, so the shot tracker queries all of them.

to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
    if (viewdamageTiles == null || viewdamageTiles.contains([x, y])):
        return [[x, y] => b`*`]
    return [].asMap()

Currently all shots look the same. We’ll probably display different types differently, later.

Players

to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
    if (viewdamageTiles == null || viewdamageTiles.contains([x, y])):
        return [[x, y] => face]
    else:
        return [].asMap()

Show the player’s location and facing direction.

Boring parts

import "lib/codec/utf8" =~ [=> UTF8 :DeepFrozen]
import "lib/streams" =~ [=> Sink :DeepFrozen]
import "lib/entropy/entropy" =~ [=> makeEntropy :DeepFrozen]
import "lib/entropy/pcg" =~ [=> makePCG :DeepFrozen]

import "mcurses" =~ [=> activateTerminal :DeepFrozen]

exports (main)

def DOOR      :Bytes := b`#`
def WALL1     :Bytes := b`-`
def WALL2     :Bytes := b`|`
def WALL3     :Bytes := b`+`
def WALL4     :Bytes := b`/`
def WALL5     :Bytes := b`\\`
def KNIFE     :Bytes := b`K`
def SHOT      :Bytes := b`:`
def GRENADE   :Bytes := b`o`
def SATCHEL   :Bytes := b`O`
def BOMB      :Bytes := b`@@`
def MINE      :Bytes := b`;`
def GMINE     :Bytes := b`g`
def SLIME     :Bytes := b`$$`
def LAVA      :Bytes := b`~`
def DSHOT     :Bytes := b`?`
def FALL      :Bytes := b`F`
def BOOT      :Bytes := b`b`
def BOOT_PAIR :Bytes := b`B`
def SPACE     :Bytes := b` `
def ABOVE     :Bytes := b`i`
def BELOW     :Bytes := b`!`
def RIGHT     :Bytes := b`}`
def LEFTS     :Bytes := b`{`

def defaultKeyMapping :Map[Str, Str] := [
    "h" => "moveLeft",
    "H" => "faceLeft",
    "j" => "moveDown",
    "J" => "faceDown",
    "k" => "moveUp",
    "K" => "faceUp",
    "l" => "moveRight",
    "L" => "faceRight",
    "f" => "shot",
    "1" => "shot",
    " " => "shot",
    "g" => "grenade",
    "2" => "grenade",
    "F" => "satchel",
    "3" => "satchel",
    "G" => "bomb7",
    "4" => "bomb7",
    "5" => "bomb9",
    "6" => "bomb11",
    "7" => "bomb13",
    "8" => "bomb15",
    "9" => "bomb19",
    "@" => "bomb21",
    "o" => "slime",
    "O" => "sslime",
    "p" => "slime2",
    "P" => "slime3",
    "s" => "scan",
    "c" => "cloak",
    "q" => "quit"
]

def [WIDTH :Int, HEIGHT :Int] := [51, 23]
def STAT_LABEL_COL :Int := 60
def STAT_VALUE_COL :Int := 74
def STAT_NAME_COL :Int := 61
def STAT_SCAN_COL :Int := (STAT_NAME_COL + 5)
def STAT_AMMO_ROW :Int := 1
def STAT_GUN_ROW :Int := 2
def STAT_DAM_ROW :Int := 3
def STAT_KILL_ROW :Int := 4
def STAT_PLAY_ROW :Int := 6
def BULSPD :Int := 2 # `hunt` has 5 but this is more impressive looking atm
def ISHOTS :Int := 15
def NSHOTS :Int := 5
def MAXNCSHOT :Int := 2
def MAXDAM :Int := 10
def CLOAKLEN :Int := 20
def SCANLEN :Int := 20

def MAXMAZEDAMAGE :Int := 40
# define	BULREQ		1
# define	GRENREQ		9
# define	SATREQ		25
# define	BOMB7REQ	49
# define	BOMB9REQ	81
# define	BOMB11REQ	121
# define	BOMB13REQ	169
# define	BOMB15REQ	225
# define	BOMB17REQ	289
# define	BOMB19REQ	361
# define	BOMB21REQ	441

def shotCost :List[Int] := [1, 9, 25, 49, 81, 121, 169, 225, 289, 361, 441]
def shotType :List[Bytes] := [SHOT, GRENADE, SATCHEL, BOMB, BOMB, BOMB,
                              BOMB, BOMB, BOMB, BOMB, BOMB]
def MAXBOMB :Int := shotType.size()

def dec(x :Int) :Bytes as DeepFrozen:
    def s := M.toString(x)
    return UTF8.encode(s, null)

def MOVEGRID :Map[Bytes, List[Int]] := [
    b`^` => [0, -1],
    b`v` => [0,  1],
    b`>` => [1,  0],
    b`<` => [-1, 0]
]

def inMazeBounds(x, y) as DeepFrozen:
    return (x < WIDTH && x >= 0 &&
            y < HEIGHT && y >= 0)
def main(_args, => stdio) as DeepFrozen:
    def entropy := makeEntropy(makePCG(42, 5))
    def keyMapping := defaultKeyMapping
    def [end, endR] := Ref.promise()

    def theEventArena := makeEventArena()

    def maze := makeMaze(entropy)
    def itemStocker := makeItemStocker(maze, entropy)
    def shotTracker := makeShotTracker(maze, theEventArena, entropy)
    <<gameboard>>
    <<makePlayer>>
    <<advanceGame>>
    <<gameController>>
    <<ioBlock>>
<<preface>>
<<definitions>>
<<maze>>
<<items>>
<<shots>>
<<events>>
<<main>>
import "lib/codec/utf8" =~ [=> UTF8 :DeepFrozen]
import "lib/streams" =~ [=> Sink :DeepFrozen]
exports (activateTerminal)
def CSI :Bytes := b`$\x1b[`
def KEY_NAMES :List[Str] := [
"UP_ARROW", "DOWN_ARROW", "RIGHT_ARROW", "LEFT_ARROW",
"HOME", "INSERT", "DELETE", "END", "PGUP", "PGDN", "NUMPAD_MIDDLE",
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9",
"F10", "F11", "F12"]
def dec(x :Int) :Bytes as DeepFrozen:
def s := M.toString(x)
return UTF8.encode(s, null)
def undec(bs :Bytes, ej) :Int as DeepFrozen:
return _makeInt(UTF8.decode(bs, ej), ej)
def makeTerminalPair(stdin, stdout) as DeepFrozen:
var cursorReports := [].diverge()
object termout:
to write(bytes):
stdout(bytes)
to clear():
stdout(b`${CSI}2J`)
to enterAltScreen():
stdout(b`${CSI}?1049h`)
to leaveAltScreen():
stdout(b`${CSI}?1049l`)
to move(y :Int, x :Int):
stdout(b`${CSI}${dec(y)};${dec(x)}H`)
to moveLeft(n):
stdout(b`${CSI}${dec(n)}D`)
to eraseLeft():
stdout(b`${CSI}1K`)
to eraseRight():
stdout(b`${CSI}0K`)
to setMargins(top :Int, bottom :Int):
stdout(b`${CSI}${dec(top)};${dec(bottom)}r`)
to insertLines(n :Int):
stdout(b`${CSI}${dec(n)}L`)
to deleteLines(n :Int):
stdout(b`${CSI}${dec(n)}M`)
to eraseLine():
stdout(b`${CSI}K`)
to getCursorReport():
def [response, r] := Ref.promise()
cursorReports.insert(0, r)
stdout(b`${CSI}6n`)
return response
to hideCursor():
stdout(b`${CSI}?25l`)
to showCursor():
stdout(b`${CSI}?25h`)
def lowFKeys :Map[Int, Str] := [
80 => "F1", 81 => "F2",
82 => "F3", 83 => "F4"]
def tildeSeqs :Map[Int, Str] := [
1 => "HOME", 2 => "INSERT", 3 => "DELETE", 4 => "END",
5 => "PGUP", 6 => "PGDN",
11 => "F1", 12 => "F2", 13 => "F3", 14 => "F4",
15 => "F5", 17 => "F6", 18 => "F7", 19 => "F8",
20 => "F9", 21 => "F10", 23 => "F11", 24 => "F12"]
def simpleControlSeqs :Map[Str, Str] := [
"A" => "UP_ARROW", "B" => "DOWN_ARROW",
"C" => "RIGHT_ARROW", "D" => "LEFT_ARROW",
"E" => "NUMPAD_MIDDLE", "F" => "END",
"H" => "HOME"]
object controlSequenceParser:
to "~"(seq, fail):
return tildeSeqs.fetch(
try {
_makeInt(UTF8.decode(seq, fail))
} catch _ {
throw.eject(fail, `Not an integer: $seq`)
}, fail)
to R(seq, fail):
if (cursorReports.size() == 0):
throw.eject(fail, `Unsolicited cursor report`)
def [via (undec) row,
via (undec) col] exit fail := seq.split(b`;`)
cursorReports.pop().resolve([row, col])
match [verb, [_, fail], _]:
simpleControlSeqs.fetch(verb, fail)
var quit := false
object termin:
to quit():
quit := true
to flowTo(sink):
def [terminVow, terminR] := Ref.promise()
var state := "data"
var escBuffer := []
var writeStart := 0
var writeEnd := null
object terminalSink as Sink:
to complete():
terminR.resolve(null)
return sink.complete()
to abort(p):
terminR.smash(p)
return sink.abort(p)
to run(packet):
if (quit):
return
def deliver(msg):
if (msg[1] == null):
# Oh well, try again
stdin <- (terminalSink)
return
return when (sink <- (msg)) ->
def p0 := stdin <- (terminalSink)
null
catch p:
traceln.exception(p)
terminR.smash(p)
sink.abort(p)
Ref.broken(p)
def handleControlSequence(content, terminator):
escape e:
def keyname := M.call(controlSequenceParser,
terminator,
[content, throw],
[].asMap())
return deliver(["KEY", keyname])
catch p:
traceln(p)
def handleLowFunction(ch):
if (!lowFKeys.contains(ch)):
traceln(`Unrecognized ^[O sequence`)
return
def k := lowFKeys[ch]
return deliver(["KEY", k])
def handleText(data):
return deliver(["DATA", data])
for i => byte in (packet):
switch (state):
match =="data":
if (byte == 0x1b):
state := "escaped"
if (writeEnd != null):
handleText(packet.slice(writeStart,
writeEnd))
writeStart := null
writeEnd := null
else:
writeEnd := i
match =="escaped":
if (byte == '['.asInteger()):
state := "bracket-escaped"
else if (byte == 'O'.asInteger()):
state := "low-function-escaped"
else:
state := "data"
writeStart := i + 1
writeEnd := null
deliver(["DATA", b`$\x1b`])
match =="bracket-escaped":
if (byte == 'O'.asInteger()):
state := "low-function-escaped"
else if (('A'.asInteger()..'Z'.asInteger()
).contains(byte) ||
byte == '~'.asInteger()):
handleControlSequence(
_makeBytes.fromInts(escBuffer),
_makeStr.fromChars(['\x00' + byte]))
escBuffer := []
writeStart := i + 1
state := "data"
else:
escBuffer with= (byte)
match =="low-function-escaped":
handleLowFunction(byte)
writeStart := i + 1
state := "data"
match s:
throw(`Illegal state $s`)
if (state == "data"):
if (writeStart != packet.size()):
handleText(packet.slice(writeStart))
writeStart := 0
writeEnd := null
return when (stdin <- (terminalSink)) ->
terminVow
catch problem:
traceln.exception(problem)
terminR.smash(problem)
Ref.broken(problem)
return [termin, termout]
def activateTerminal(stdio) as DeepFrozen:
def stdin := stdio.stdin()
def stdout := stdio.stdout()
# if (!(stdin.isATTY() && stdout.isATTY())):
# stdout(b`A terminal is required$\n`)
# return 1
def [width, height] := stdout.getWindowSize()
if (!(width >= 80 && height >= 24)):
throw("Terminal must be at least 80x24.")
stdin.setRawMode(true)
return [[width, height], makeTerminalPair(stdin, stdout)]
# -*- mode: org; -*-
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="https://www.pirilampo.org/styles/readtheorg/css/htmlize.css"/>
#+HTML_HEAD: <link rel="stylesheet" type="text/css" href="https://www.pirilampo.org/styles/readtheorg/css/readtheorg.css"/>
#+HTML_HEAD: <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
#+HTML_HEAD: <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
#+HTML_HEAD: <script type="text/javascript" src="https://www.pirilampo.org/styles/lib/js/jquery.stickytableheaders.min.js"></script>
#+HTML_HEAD: <script type="text/javascript" src="https://www.pirilampo.org/styles/readtheorg/js/readtheorg.js"></script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment