main.go
// Command roguelike is a tile-based dungeon crawler on gophics, and the driver
// example for paint.DrawSprite: the whole map, monsters, and items are blitted
// from one procedurally-generated atlas texture (no binary assets). Turn-based,
// with a minimal d20 combat core. Arrow keys or tap to move; bump to attack;
// reach the stairs to descend.
//
// go run ./examples/roguelike
package main
import (
"log"
"time"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/sound"
"github.com/doug/gophics/sound/device"
)
func main() {
// Audio is best-effort: if the device won't open, the game runs silent.
mixer := sound.NewMixer()
if closer, err := device.Open(mixer); err != nil {
log.Printf("audio disabled: %v", err)
} else {
defer closer.Close()
}
err := app.Run(Roguelike{Seed: time.Now().UnixNano(), Sound: mixer}, app.Config{
Title: "Roguelike",
Size: geom.Size{W: 900, H: 680},
Background: colBG,
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
})
if err != nil {
log.Fatal(err)
}
}
dungeon.go
package main
import (
"math/rand"
"slices"
)
// Cell is a map tile's terrain.
type Cell uint8
const (
CellWall Cell = iota
CellFloor
CellDoor
CellStairs
)
// Room is an axis-aligned rectangle of floor.
type Room struct{ X, Y, W, H int }
func (r Room) center() (int, int) { return r.X + r.W/2, r.Y + r.H/2 }
func (r Room) overlaps(o Room) bool {
return r.X <= o.X+o.W && r.X+r.W >= o.X && r.Y <= o.Y+o.H && r.Y+r.H >= o.Y
}
// Dungeon is a grid of cells with the rooms that were carved.
type Dungeon struct {
W, H int
cells []Cell
rooms []Room
}
func (d *Dungeon) at(x, y int) Cell {
if x < 0 || y < 0 || x >= d.W || y >= d.H {
return CellWall
}
return d.cells[y*d.W+x]
}
func (d *Dungeon) set(x, y int, c Cell) {
if x >= 0 && y >= 0 && x < d.W && y < d.H {
d.cells[y*d.W+x] = c
}
}
// walkable reports whether an entity can stand on (x, y).
func (d *Dungeon) walkable(x, y int) bool {
c := d.at(x, y)
return c == CellFloor || c == CellDoor || c == CellStairs
}
// opaque reports whether (x, y) blocks line of sight.
func (d *Dungeon) opaque(x, y int) bool { return d.at(x, y) == CellWall }
// genDungeon carves up to maxRooms non-overlapping rooms and connects each to
// the previous with an L-shaped corridor. The last room gets the stairs down.
func genDungeon(w, h, maxRooms int, rng *rand.Rand) *Dungeon {
d := &Dungeon{W: w, H: h, cells: make([]Cell, w*h)} // all walls
for range maxRooms {
rw, rh := 4+rng.Intn(7), 3+rng.Intn(5)
rx, ry := 1+rng.Intn(w-rw-2), 1+rng.Intn(h-rh-2)
room := Room{rx, ry, rw, rh}
clash := slices.ContainsFunc(d.rooms, room.overlaps)
if clash {
continue
}
for y := ry; y < ry+rh; y++ {
for x := rx; x < rx+rw; x++ {
d.set(x, y, CellFloor)
}
}
if len(d.rooms) > 0 {
px, py := d.rooms[len(d.rooms)-1].center()
cx, cy := room.center()
d.carveCorridor(px, py, cx, cy, rng)
}
d.rooms = append(d.rooms, room)
}
if n := len(d.rooms); n > 0 {
sx, sy := d.rooms[n-1].center()
d.set(sx, sy, CellStairs)
}
return d
}
func (d *Dungeon) carveCorridor(x0, y0, x1, y1 int, rng *rand.Rand) {
if rng.Intn(2) == 0 {
d.hLine(x0, x1, y0)
d.vLine(y0, y1, x1)
} else {
d.vLine(y0, y1, x0)
d.hLine(x0, x1, y1)
}
}
func (d *Dungeon) hLine(x0, x1, y int) {
if x0 > x1 {
x0, x1 = x1, x0
}
for x := x0; x <= x1; x++ {
if d.at(x, y) == CellWall {
d.set(x, y, CellFloor)
}
}
}
func (d *Dungeon) vLine(y0, y1, x int) {
if y0 > y1 {
y0, y1 = y1, y0
}
for y := y0; y <= y1; y++ {
if d.at(x, y) == CellWall {
d.set(x, y, CellFloor)
}
}
}
game.go
package main
import (
"fmt"
"math/rand"
"strings"
)
// Entity is the player or a monster.
type Entity struct {
X, Y int
Tile TileID
Name string
HP, MaxHP int
Atk, AC, Damage int // d20 attack bonus, armor class, damage die
Alive bool
FlipX bool
// Speed is how many turns this entity takes per player turn, as a
// numerator over 2: 1 is half speed, 2 is normal, 3 gives an extra turn
// every other round. Differing speeds are what stop every monster from
// being the same fight with different numbers — a rat you can outrun is a
// different problem from a brute you cannot.
Speed int
energy int
// Asleep monsters ignore the player until one comes close, so a room is
// something you enter carefully rather than a queue of things already
// walking at you.
Asleep bool
XP int // awarded to the player on kill
}
// Item is a pickup lying on the floor.
type Item struct {
X, Y int
Tile TileID
Gold int // >0 → gold; else a potion
Amulet bool // the win goal
}
// maxDepth is where the Amulet of Yendor waits.
const maxDepth = 5
// SoundID names a sound effect; the widget maps these to samples. The engine
// only emits ids, staying decoupled from the audio package (and silent in tests).
type SoundID int
const (
SndHit SoundID = iota
SndCoin
SndPotion
SndDescend
SndDie
SndWin
)
// Game is the full, rendering-free game state.
type Game struct {
d *Dungeon
player *Entity
monsters []*Entity
items []*Item
seen []bool // ever revealed (fog memory)
visible []bool // in the current field of view
rng *rand.Rand
log []string
depth int
gold int
// potions are carried, not drunk where they lie: the decision of when to
// spend one is the most interesting choice this game has, and picking them
// up automatically threw it away.
potions int
level int
xp int
kills int
turns int
dead bool
won bool
sfx func(id SoundID, pan float64) // optional; set by the widget
// onHit reports a landed blow to the presentation layer. The engine stays
// rendering-free: it says what happened, not what it should look like.
onHit func(attacker, target *Entity, dmg int)
}
func (g *Game) play(id SoundID, pan float64) {
if g.sfx != nil {
g.sfx(id, pan)
}
}
// panAt maps a world x to a stereo pan (-1..1) relative to the player.
func (g *Game) panAt(x int) float64 {
p := float64(x-g.player.X) / fovRadius
if p < -1 {
p = -1
} else if p > 1 {
p = 1
}
return p
}
const fovRadius = 7
// newGame builds level 1 with a seeded RNG.
func newGame(seed int64) *Game {
g := &Game{rng: rand.New(rand.NewSource(seed)), depth: 1}
g.build()
return g
}
// build lays out the current depth: dungeon, player, monsters, items, FOV.
func (g *Game) build() {
d := genDungeon(48, 32, 14, g.rng)
g.d = d
g.seen = make([]bool, d.W*d.H)
g.visible = make([]bool, d.W*d.H)
g.monsters = nil
g.items = nil
px, py := d.rooms[0].center()
if g.player == nil {
g.player = &Entity{Tile: TPlayer, Name: "you", HP: 20, MaxHP: 20, Atk: 4, AC: 13, Damage: 6, Alive: true, Speed: 2}
g.level, g.potions = 1, 1
}
g.player.X, g.player.Y = px, py
// Populate the other rooms with monsters and loot, scaling with depth.
for _, r := range d.rooms[1:] {
// Deeper levels are denser as well as tougher, so descending feels
// like a decision rather than a formality.
for n := 0; n < 1+g.rng.Intn(1+g.depth/2); n++ {
mx := r.X + 1 + g.rng.Intn(max(1, r.W-1))
my := r.Y + 1 + g.rng.Intn(max(1, r.H-1))
if !g.d.walkable(mx, my) || g.monsterAt(mx, my) != nil {
continue
}
g.monsters = append(g.monsters, g.spawn(mx, my))
}
if g.rng.Intn(2) == 0 {
ix, iy := r.X+1+g.rng.Intn(r.W-1), r.Y+1+g.rng.Intn(r.H-1)
if g.rng.Intn(2) == 0 {
g.items = append(g.items, &Item{X: ix, Y: iy, Tile: TGold, Gold: 3 + g.rng.Intn(12)})
} else {
g.items = append(g.items, &Item{X: ix, Y: iy, Tile: TPotion})
}
}
}
// The Amulet of Yendor waits on the deepest level (no stairs down there).
if g.depth >= maxDepth {
sx, sy := d.rooms[len(d.rooms)-1].center()
d.set(sx, sy, CellFloor)
g.items = append(g.items, &Item{X: sx, Y: sy, Tile: TAmulet, Amulet: true})
}
g.computeFOV()
if g.depth >= maxDepth {
g.logf("Level %d — the Amulet of Yendor is near!", g.depth)
} else {
g.logf("You enter dungeon level %d.", g.depth)
}
}
// spawn picks a monster for the current depth. The mix shifts down: rats
// thin out, brutes appear from level three, so the same tactics stop working.
func (g *Game) spawn(x, y int) *Entity {
d := g.depth
roll := g.rng.Intn(100)
switch {
case roll < max(10, 45-d*8):
// Rat: fragile and fast. It reaches you first and softens you up.
return &Entity{X: x, Y: y, Tile: TRat, Name: "rat", Alive: true, Asleep: true,
HP: 3 + d/2, MaxHP: 3 + d/2, Atk: 2, AC: 10, Damage: 3, Speed: 3, XP: 3}
case roll < 80 || d < 3:
// Goblin: the baseline fight.
return &Entity{X: x, Y: y, Tile: TGoblin, Name: "goblin", Alive: true, Asleep: true,
HP: 7 + d, MaxHP: 7 + d, Atk: 3 + d/2, AC: 12, Damage: 5, Speed: 2, XP: 8}
default:
// Brute: slow, armoured, hits hard. You can outwalk it — the question
// is whether the room lets you.
return &Entity{X: x, Y: y, Tile: TGoblin, Name: "brute", Alive: true, Asleep: true,
HP: 14 + d*3, MaxHP: 14 + d*3, Atk: 4 + d/2, AC: 14, Damage: 8, Speed: 1, XP: 20}
}
}
// xpToLevel is the total experience needed for the next level.
func xpToLevel(level int) int { return 12 * level * level }
// gainXP awards experience and levels the player up, which is what makes
// fighting worth the risk instead of something to be walked around.
func (g *Game) gainXP(n int) {
g.xp += n
for g.xp >= xpToLevel(g.level) {
g.xp -= xpToLevel(g.level)
g.level++
g.player.MaxHP += 4
g.player.HP = g.player.MaxHP
g.player.Atk++
if g.level%2 == 0 {
g.player.Damage++
}
g.logf("You reach level %d — you feel stronger.", g.level)
g.play(SndWin, 0)
}
}
func (g *Game) logf(format string, a ...any) {
g.log = append(g.log, fmt.Sprintf(format, a...))
if len(g.log) > 6 {
g.log = g.log[len(g.log)-6:]
}
}
// monsterAt returns the living monster on (x, y), if any.
func (g *Game) monsterAt(x, y int) *Entity {
for _, m := range g.monsters {
if m.Alive && m.X == x && m.Y == y {
return m
}
}
return nil
}
// Move attempts to move the player by (dx, dy): attack a monster there, step
// onto walkable floor (picking up items, descending stairs), else do nothing.
// A successful action passes the turn to the monsters.
func (g *Game) Move(dx, dy int) {
if g.dead || g.won || (dx == 0 && dy == 0) {
return
}
if dx < 0 {
g.player.FlipX = true
} else if dx > 0 {
g.player.FlipX = false
}
nx, ny := g.player.X+dx, g.player.Y+dy
if m := g.monsterAt(nx, ny); m != nil {
g.attack(g.player, m)
g.endTurn()
return
}
if !g.d.walkable(nx, ny) {
return
}
g.player.X, g.player.Y = nx, ny
g.pickup()
if g.d.at(nx, ny) == CellStairs {
g.descend()
return
}
g.endTurn()
}
func (g *Game) pickup() {
kept := g.items[:0]
for _, it := range g.items {
if it.X == g.player.X && it.Y == g.player.Y {
if it.Amulet {
g.won = true
g.play(SndWin, 0)
g.logf("You claim the Amulet of Yendor — you win!")
continue
}
if it.Gold > 0 {
g.gold += it.Gold
g.play(SndCoin, 0)
g.logf("You pick up %d gold.", it.Gold)
} else {
g.potions++
g.play(SndCoin, 0)
g.logf("You pocket a potion (%d held).", g.potions)
}
continue
}
kept = append(kept, it)
}
g.items = kept
}
// Quaff drinks a held potion. It is a turn like any other, so healing in
// front of something that is still swinging costs you a hit — which is the
// point: the interesting question is when, not whether.
func (g *Game) Quaff() {
if g.dead || g.won {
return
}
if g.potions == 0 {
g.logf("You have no potions.")
return
}
if g.player.HP >= g.player.MaxHP {
g.logf("You are unhurt.")
return
}
g.potions--
heal := 8 + g.level*2
before := g.player.HP
g.player.HP = min(g.player.MaxHP, g.player.HP+heal)
g.play(SndPotion, 0)
g.logf("You quaff a potion (+%d HP).", g.player.HP-before)
g.endTurn()
}
// Wait passes a turn. Standing in a doorway so only one thing can reach you
// is a real tactic, and it needs a way to spend a turn without moving.
func (g *Game) Wait() {
if g.dead || g.won {
return
}
g.endTurn()
}
// endTurn runs the monsters and recomputes sight after any player action.
func (g *Game) endTurn() {
g.turns++
g.monstersAct()
g.computeFOV()
}
func (g *Game) descend() {
g.play(SndDescend, 0)
g.depth++
g.gold += 5
g.build()
}
// attack resolves one d20 attack: d20 + Atk vs AC, then Damage die on a hit.
func (g *Game) attack(a, b *Entity) {
roll := g.rng.Intn(20) + 1
// Pan toward the non-player combatant.
loc := b
if b == g.player {
loc = a
}
pan := g.panAt(loc.X)
if roll+a.Atk >= b.AC {
dmg := g.rng.Intn(b.hitDie(a)) + 1
b.HP -= dmg
if b.HP < 0 {
b.HP = 0 // a corpse is at zero, not in debt
}
g.play(SndHit, pan)
if g.onHit != nil {
g.onHit(a, b, dmg)
}
g.logf("%s hit %s for %d.", cap1(a.Name), b.Name, dmg)
if b.HP <= 0 {
b.Alive = false
if b == g.player {
g.logf("You die.")
} else {
g.logf("%s dies.", cap1(b.Name))
}
if b == g.player {
g.dead = true
g.play(SndDie, 0)
g.logf("You have died on level %d.", g.depth)
} else {
g.kills++
g.gainXP(b.XP)
}
}
} else {
g.logf("%s missed %s.", cap1(a.Name), b.Name)
}
}
func (e *Entity) hitDie(a *Entity) int {
if a.Damage > 0 {
return a.Damage
}
return 4
}
// monstersAct runs each living monster: attack if adjacent to the player, else
// step toward the player when it can see them.
func (g *Game) monstersAct() {
for _, m := range g.monsters {
if !m.Alive || g.dead {
continue
}
// Wake on proximity rather than on sight, so creeping around the edge
// of a room is a real option and a corridor is not a conga line.
if m.Asleep {
if g.visibleAt(m.X, m.Y) && dist(m.X, m.Y, g.player.X, g.player.Y) <= 4 {
m.Asleep = false
g.logf("The %s notices you.", m.Name)
} else {
continue
}
}
// Energy accrues at the monster's speed against a cost of 2 per turn,
// so a rat acts three times per two player turns and a brute once.
sp := m.Speed
if sp <= 0 {
sp = 2
}
m.energy += sp
for m.energy >= 2 && m.Alive && !g.dead {
m.energy -= 2
g.monsterStep(m)
}
}
}
// monsterStep is one monster action: swing if adjacent, else close in.
func (g *Game) monsterStep(m *Entity) {
{
dx, dy := g.player.X-m.X, g.player.Y-m.Y
if abs(dx) <= 1 && abs(dy) <= 1 {
g.attack(m, g.player)
return
}
sx, sy := sign(dx), sign(dy)
if g.step(m, sx, sy) || g.step(m, sx, 0) || g.step(m, 0, sy) {
m.FlipX = sx < 0
}
}
}
// all returns the player and every living monster — the set the renderer has
// to track positions for.
func (g *Game) all() []*Entity {
out := make([]*Entity, 0, len(g.monsters)+1)
out = append(out, g.player)
for _, m := range g.monsters {
if m.Alive {
out = append(out, m)
}
}
return out
}
// dist is Chebyshev distance — the grid's own notion of "how many steps".
func dist(x0, y0, x1, y1 int) int { return max(abs(x1-x0), abs(y1-y0)) }
func (g *Game) step(m *Entity, dx, dy int) bool {
if dx == 0 && dy == 0 {
return false
}
nx, ny := m.X+dx, m.Y+dy
if !g.d.walkable(nx, ny) || g.monsterAt(nx, ny) != nil || (nx == g.player.X && ny == g.player.Y) {
return false
}
m.X, m.Y = nx, ny
return true
}
func (g *Game) visibleAt(x, y int) bool {
if x < 0 || y < 0 || x >= g.d.W || y >= g.d.H {
return false
}
return g.visible[y*g.d.W+x]
}
func (g *Game) seenAt(x, y int) bool {
if x < 0 || y < 0 || x >= g.d.W || y >= g.d.H {
return false
}
return g.seen[y*g.d.W+x]
}
// computeFOV recomputes visibility: every cell within fovRadius with an
// unobstructed line from the player is visible (and remembered as seen).
func (g *Game) computeFOV() {
for i := range g.visible {
g.visible[i] = false
}
px, py := g.player.X, g.player.Y
for y := py - fovRadius; y <= py+fovRadius; y++ {
for x := px - fovRadius; x <= px+fovRadius; x++ {
if x < 0 || y < 0 || x >= g.d.W || y >= g.d.H {
continue
}
if (x-px)*(x-px)+(y-py)*(y-py) > fovRadius*fovRadius {
continue
}
if g.los(px, py, x, y) {
g.visible[y*g.d.W+x] = true
g.seen[y*g.d.W+x] = true
}
}
}
}
// los is a Bresenham line-of-sight test: true when no wall lies strictly between
// (x0,y0) and (x1,y1).
func (g *Game) los(x0, y0, x1, y1 int) bool {
dx, dy := abs(x1-x0), abs(y1-y0)
sx, sy := sign(x1-x0), sign(y1-y0)
err := dx - dy
x, y := x0, y0
for {
if x == x1 && y == y1 {
return true
}
if !(x == x0 && y == y0) && g.d.opaque(x, y) {
return false
}
e2 := 2 * err
if e2 > -dy {
err -= dy
x += sx
}
if e2 < dx {
err += dx
y += sy
}
}
}
func abs(v int) int {
if v < 0 {
return -v
}
return v
}
func sign(v int) int {
switch {
case v > 0:
return 1
case v < 0:
return -1
}
return 0
}
func cap1(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
roguelike.go
package main
import (
"fmt"
"image"
"math"
"math/rand"
"time"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/shell"
"github.com/doug/gophics/sound"
"github.com/doug/gophics/sound/procedural"
"github.com/doug/gophics/widget"
)
// Roguelike is the root widget: a tile dungeon crawler rendered entirely with
// paint.DrawSprite from one procedurally-generated atlas. Sound is optional
// (nil → silent, e.g. in tests).
type Roguelike struct {
Seed int64
Sound *sound.Mixer
}
func (Roguelike) CreateState() widget.State { return &gameState{} }
// stateHook lets tests observe the mounted state.
var stateHook func(*gameState)
type gameState struct {
widget.StateBase[Roguelike]
ctx widget.Ctx
g *Game
atlas *image.RGBA
restarts int64
snd *sound.Mixer
rng *rand.Rand
samples map[SoundID]*sound.Sample
music *sound.Voice
origin geom.Pt // last camera origin (world px), for tap→cell mapping
ts float32 // last tile size on screen
fx effects
tkr fxTicker
// hpGhost trails the real HP fraction so a hit leaves a visible wound on
// the bar for a moment instead of just being a shorter bar.
hpGhost float32
}
// effects is everything that is purely presentational: where entities are
// drawn as opposed to where they are, and the short-lived flourishes that make
// a turn feel like it happened. The game logic never reads any of it.
type effects struct {
clock float64 // seconds since mount, for the torch flicker
pos map[*Entity]geom.Pt // render position, easing toward the real cell
flash map[*Entity]float32 // white hit flash, 1 → 0
lunge map[*Entity]geom.Pt // attack shove, decaying to zero
floats []floater
shake float32
}
// floater is a damage number rising off a hit.
type floater struct {
x, y float32 // world pixels at spawn
text string
col paint.Color
age float32
}
// fxTicker advances the effects. It reports "still running" only while there
// is something to animate, so a game sitting still costs no frames.
type fxTicker struct{ s *gameState }
func (t *fxTicker) Tick(dt float64) bool {
s := t.s
s.fx.clock += dt
busy := s.fx.advance(float32(dt), s.g, s.ts)
if want := clamp01(float32(s.g.player.HP) / float32(s.g.player.MaxHP)); s.hpGhost > want {
s.hpGhost -= float32(dt) * 0.6
if s.hpGhost < want {
s.hpGhost = want
}
busy = true
} else {
s.hpGhost = want
}
// The torch flickers forever, but only repaint for it while something is
// on screen to see; a still frame does not need 60 fps of flicker.
if busy {
s.SetState(nil)
}
return busy
}
var (
colBG = paint.RGB(0.04, 0.045, 0.06)
colPanel = paint.Color{R: 0.08, G: 0.09, B: 0.12, A: 0.93}
colInk = paint.RGB(0.86, 0.88, 0.92)
colDim = paint.RGB(0.55, 0.58, 0.64)
colHP = paint.RGB(0.80, 0.27, 0.30)
colHPbg = paint.Color{R: 1, G: 1, B: 1, A: 0.12}
colCoin = paint.RGB(0.90, 0.74, 0.30)
colBanner = paint.Color{R: 0, G: 0, B: 0, A: 0.62}
colDamage = paint.RGB(1.00, 0.86, 0.55)
colXP = paint.RGB(0.44, 0.72, 0.92)
)
func (s *gameState) Init(ctx widget.Ctx) {
s.ctx = ctx
s.atlas = buildAtlas()
s.fx.pos = map[*Entity]geom.Pt{}
s.fx.flash = map[*Entity]float32{}
s.fx.lunge = map[*Entity]geom.Pt{}
s.tkr.s = s
ctx.AddTicker(&s.tkr)
s.rng = rand.New(rand.NewSource(1))
s.snd = s.W().Sound
if s.snd != nil {
s.samples = map[SoundID]*sound.Sample{
SndHit: procedural.Hit(),
SndCoin: procedural.Coin(),
SndPotion: procedural.Blip(720, 0.14),
SndDescend: procedural.Thud(),
SndDie: procedural.Blip(140, 0.4),
SndWin: procedural.Coin(),
}
s.music = s.snd.PlaySource(procedural.DungeonMusic(1),
sound.PlayOptions{Volume: 0.30, FadeIn: 2 * time.Second}) // ambient loop, fades in
}
s.g = newGame(s.W().Seed)
s.g.onHit = s.onHit
s.attachSound()
if stateHook != nil {
stateHook(s)
}
}
// Dispose stops the effects ticker so it cannot outlive the widget.
func (s *gameState) Dispose() { s.ctx.RemoveTicker(&s.tkr) }
// attachSound wires the current game's sound hook to the mixer (a no-op without
// audio). Called on mount and after each restart. Hits get a small random pitch
// for variety and a pan from the game (positional combat).
func (s *gameState) attachSound() {
if s.snd == nil {
return
}
s.g.sfx = func(id SoundID, pan float64) {
smp := s.samples[id]
if smp == nil {
return
}
opts := sound.PlayOptions{Volume: 0.55, Pan: pan}
if id == SndHit {
opts.Pitch = 0.9 + s.rng.Float64()*0.35
}
s.snd.Play(smp, opts)
}
}
func (s *gameState) Build(_ widget.Ctx) widget.Widget {
return widget.Interactive{
Gestures: widget.Gestures{
OnKey: func(k shell.Key) {
if k.Kind == shell.KeyPress {
s.key(k.Code)
}
},
OnPress: func(p geom.Pt) { s.tap(p) },
},
Child: widget.Canvas{Clip: true, Draw: s.draw},
}
}
func (s *gameState) key(c shell.KeyCode) {
// Restart is on any key once the run is over, so the switch below only has
// to describe a live game.
if s.g.dead || s.g.won {
s.act(0, 0)
return
}
switch c {
case shell.KeyLeft, shell.KeyA:
s.step(-1, 0)
case shell.KeyRight, shell.KeyD:
s.step(1, 0)
case shell.KeyUp, shell.KeyW:
s.step(0, -1)
case shell.KeyDown, shell.KeyS:
s.step(0, 1)
case shell.KeyQ:
s.g.Quaff()
s.after()
case shell.KeySpace:
s.g.Wait()
s.after()
}
}
// step is a movement action.
func (s *gameState) step(dx, dy int) {
s.g.Move(dx, dy)
s.after()
}
// tap moves one step toward the tapped cell (touch/mouse control).
func (s *gameState) tap(p geom.Pt) {
if s.ts == 0 {
return
}
cx := int((p.X + s.origin.X) / s.ts)
cy := int((p.Y + s.origin.Y) / s.ts)
s.act(sign(cx-s.g.player.X), sign(cy-s.g.player.Y))
}
func (s *gameState) act(dx, dy int) {
if s.g.dead || s.g.won {
s.restarts++
s.g = newGame(s.W().Seed + s.restarts) // any input after death/win starts anew
s.g.onHit = s.onHit
s.fx.pos = map[*Entity]geom.Pt{}
s.fx.flash = map[*Entity]float32{}
s.fx.lunge = map[*Entity]geom.Pt{}
s.fx.floats = nil
s.attachSound()
} else {
s.g.Move(dx, dy)
}
s.after()
}
// after runs the housekeeping every action shares.
func (s *gameState) after() {
if s.music != nil {
s.music.SetVolume(0.28 + 0.05*float64(s.g.depth-1)) // tenser as you descend
}
s.SetState(nil)
}
func (s *gameState) draw(c paint.Canvas, size geom.Size) {
c.Clear(colBG)
g := s.g
ts := tileSize(size)
s.ts = ts
// Follow the player's eased position, not the cell, so the camera glides
// with them instead of jumping a whole tile ahead of the sprite.
pp := s.renderPos(g.player)
ox := pp.X - size.W/2 + ts/2
oy := pp.Y - (size.H-hudHeight)/2 + ts/2 // leave room for the HUD
if s.fx.shake > 0 {
k := s.fx.shake * s.fx.shake * ts * 0.16
ox += k * float32(math.Sin(s.fx.clock*61))
oy += k * float32(math.Sin(s.fx.clock*47))
}
s.origin = geom.Pt{X: ox, Y: oy}
x0, y0 := int(ox/ts)-1, int(oy/ts)-1
x1 := x0 + int(size.W/ts) + 3
y1 := y0 + int(size.H/ts) + 3
for y := y0; y <= y1; y++ {
for x := x0; x <= x1; x++ {
if !g.seenAt(x, y) {
continue
}
s.blit(c, s.terrainTile(x, y), x, y, false, s.light(x, y))
}
}
// Torch halo: a translucent warm glow centered on the player, breathing.
fl := s.torch()
gs := ts * 7 * fl
c.DrawSprite(s.atlas, paint.Sprite{Src: src(TGlow),
Dst: geom.RectXYWH(pp.X-ox+ts/2-gs/2, pp.Y-oy+ts/2-gs/2, gs, gs),
Alpha: 0.42 * fl})
for _, it := range g.items {
if g.visibleAt(it.X, it.Y) {
s.blit(c, it.Tile, it.X, it.Y, false, s.light(it.X, it.Y))
}
}
for _, m := range g.monsters {
if m.Alive && g.visibleAt(m.X, m.Y) {
s.entity(c, m, s.light(m.X, m.Y))
}
}
s.entity(c, g.player, paint.Color{R: 1, G: 1, B: 1, A: 1})
s.drawFloats(c)
s.vignette(c, size)
s.drawHUD(c, size)
switch {
case g.dead:
s.summary(c, size, "You died", false)
case g.won:
s.summary(c, size, "You claimed the Amulet", true)
}
}
// entity draws one creature at its eased position: shadow, sprite, and the
// white flash of a fresh wound.
func (s *gameState) entity(c paint.Canvas, e *Entity, tint paint.Color) {
p := s.renderPos(e)
dst := geom.RectXYWH(p.X-s.origin.X, p.Y-s.origin.Y, s.ts, s.ts)
c.DrawSprite(s.atlas, paint.Sprite{Src: src(TShadow), Dst: dst, Nearest: true})
c.DrawSprite(s.atlas, paint.Sprite{Src: src(e.Tile), Dst: dst, Nearest: true, FlipX: e.FlipX, Tint: tint})
if f := s.fx.flash[e]; f > 0 {
c.DrawSprite(s.atlas, paint.Sprite{Src: src(e.Tile), Dst: dst, Nearest: true, FlipX: e.FlipX,
Tint: paint.Color{R: 1, G: 1, B: 1, A: 1}, Alpha: f})
}
}
func (s *gameState) cell(x, y int) geom.Rect {
return geom.RectXYWH(float32(x)*s.ts-s.origin.X, float32(y)*s.ts-s.origin.Y, s.ts, s.ts)
}
func (s *gameState) blit(c paint.Canvas, id TileID, x, y int, flip bool, tint paint.Color) {
c.DrawSprite(s.atlas, paint.Sprite{Src: src(id), Dst: s.cell(x, y), Nearest: true, FlipX: flip, Tint: tint})
}
// Torchlight runs from a warm core to a cold edge, and remembered cells are
// colder still. The hue shift is doing most of the work: a falloff that only
// darkens reads as haze over the room, where warm-to-cool reads as a flame in
// the dark, and it also tells you at a glance which parts of the map you are
// looking at versus only recalling.
var (
lightCore = paint.RGB(1.00, 0.93, 0.76) // at the torch
lightEdge = paint.RGB(0.26, 0.25, 0.36) // at the limit of sight
lightMem = paint.RGB(0.20, 0.22, 0.34) // explored, out of sight
)
// light returns the tint for a cell.
func (s *gameState) light(x, y int) paint.Color {
if !s.g.visibleAt(x, y) {
return lightMem
}
d := math.Hypot(float64(x-s.g.player.X), float64(y-s.g.player.Y)) / float64(fovRadius+1)
if d > 1 {
d = 1
}
// Squared falloff, so the bright core is generous and the shoulder is
// short — a linear ramp washes the whole room out evenly.
t := float32(d * d)
return paint.Color{
R: lightCore.R + (lightEdge.R-lightCore.R)*t,
G: lightCore.G + (lightEdge.G-lightCore.G)*t,
B: lightCore.B + (lightEdge.B-lightCore.B)*t,
A: 1,
}
}
// terrainTile picks the tile for a cell. A wall with a walkable cell below it
// is a face the torch can light; every other wall is the top of the rock, and
// painting the two differently is what gives the grid depth.
func (s *gameState) terrainTile(x, y int) TileID {
switch s.g.d.at(x, y) {
case CellWall:
if s.g.d.walkable(x, y+1) {
return TWall
}
return TWallTop
case CellStairs:
return TStairs
case CellDoor:
return TDoor
default:
// Vary the floor by position so a large room is not one flat texture.
switch (x*7 ^ y*13) % 3 {
case 0:
return TFloor2
case 1:
return TFloor3
}
return TFloor
}
}
// tileSize scales tiles to the viewport so the lit radius roughly fills the
// frame. It was fixed at 32px, which on a large window left the visible island
// marooned in black — the field of view is only a few tiles wide, so the tile
// has to grow with the window rather than the count of tiles on screen. The
// scale is a whole number because these are nearest-neighbour pixel blits.
func tileSize(size geom.Size) float32 {
const targetAcross = 19
sc := math.Round(float64(size.W) / (targetAcross * tile))
if sc < 2 {
sc = 2
}
if sc > 6 {
sc = 6
}
return float32(tile) * float32(sc)
}
// vignette darkens the frame edges, so the eye settles on the torch instead of
// the corners and the dungeon feels enclosed.
func (s *gameState) vignette(c paint.Canvas, size geom.Size) {
const clear = 0.0
edge := paint.Color{A: 0.55}
none := paint.Color{}
w := size.W * 0.20
h := size.H * 0.20
c.FillRRectGradient(geom.RectXYWH(0, 0, w, size.H), 0, edge, none, true)
c.FillRRectGradient(geom.RectXYWH(size.W-w, 0, w, size.H), 0, none, edge, true)
c.FillRRectGradient(geom.RectXYWH(0, 0, size.W, h), 0, edge, none, false)
c.FillRRectGradient(geom.RectXYWH(0, size.H-h, size.W, h), 0, none, edge, false)
_ = clear
}
// hudHeight is the bottom panel's height, reserved from the world viewport.
const hudHeight float32 = 90
func (s *gameState) drawHUD(c paint.Canvas, size geom.Size) {
g := s.g
h := hudHeight
top := size.H - h
c.FillRect(geom.RectXYWH(0, top, size.W, h), colPanel)
// A lit rule along the top edge, so the panel reads as a frame around the
// dungeon rather than a slab dropped on top of it.
c.FillRect(geom.RectXYWH(0, top, size.W, 1), paint.Color{R: 0.55, G: 0.48, B: 0.38, A: 0.5})
const pad = 18
x, y := float32(pad), top+20
// HP, with a trailing ghost so a hit reads as damage taken rather than a
// bar that was always that length.
const bw, bh = 190, 13
frac := clamp01(float32(g.player.HP) / float32(g.player.MaxHP))
c.FillRRect(geom.RectXYWH(x, y, bw, bh), bh/2, colHPbg)
if s.hpGhost > frac {
c.FillRRect(geom.RectXYWH(x, y, bw*s.hpGhost, bh), bh/2, paint.Color{R: 0.9, G: 0.5, B: 0.5, A: 0.35})
}
c.FillRRect(geom.RectXYWH(x, y, bw*frac, bh), bh/2, colHP)
c.TextIn("bold", fmt.Sprintf("%d/%d", g.player.HP, g.player.MaxHP),
geom.Pt{X: x + 8, Y: y + bh - 2}, 11, colInk)
// Experience toward the next level, directly under HP: the two bars are
// the run in one glance — how close to dying, how close to stronger.
xy := y + bh + 6
xf := clamp01(float32(g.xp) / float32(xpToLevel(g.level)))
c.FillRRect(geom.RectXYWH(x, xy, bw, 5), 2.5, colHPbg)
c.FillRRect(geom.RectXYWH(x, xy, bw*xf, 5), 2.5, colXP)
// Stats.
sx := x + bw + 26
stat := func(label string, val string, col paint.Color) {
c.TextIn("", label, geom.Pt{X: sx, Y: y + 2}, 11, colDim)
c.TextIn("bold", val, geom.Pt{X: sx, Y: y + 18}, 15, col)
sx += 78
}
stat("LEVEL", fmt.Sprintf("%d", g.level), colInk)
stat("DEPTH", fmt.Sprintf("%d", g.depth), colInk)
stat("GOLD", fmt.Sprintf("%d", g.gold), colCoin)
stat("POTIONS", fmt.Sprintf("%d", g.potions), potionColor(g.potions))
// Controls, so the game explains itself without a manual.
c.TextIn("", "move ←↑↓→ / wasd Q quaff space wait",
geom.Pt{X: size.W - 300, Y: y + 2}, 11, colDim)
c.TextIn("", fmt.Sprintf("Amulet · depth %d", maxDepth),
geom.Pt{X: size.W - 300, Y: y + 20}, 12, colDim)
// The last few log lines, newest brightest, oldest fading out.
// Two lines, sized and placed to sit inside the panel — three at 15px
// pushed the oldest off the bottom edge.
ly := top + 58
n := len(g.log)
from := max(0, n-2)
for i, line := range g.log[from:] {
col := colDim
if i < len(g.log[from:])-1 {
col.A = 0.5 // older line recedes
}
c.TextIn("", line, geom.Pt{X: pad, Y: ly + float32(i)*15}, 12, col)
}
}
// potionColor greys the potion count out at zero, so "none left" is legible
// at a glance in the middle of a fight.
func potionColor(n int) paint.Color {
if n == 0 {
return colDim
}
return paint.RGB(0.72, 0.86, 0.62)
}
func clamp01(v float32) float32 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// summary is the end-of-run card: what this run amounted to, so a death reads
// as a result rather than an interruption. A roguelike run you cannot look
// back on is just a session that stopped.
func (s *gameState) summary(c paint.Canvas, size geom.Size, title string, win bool) {
g := s.g
w, h := float32(360), float32(210)
x, y := (size.W-w)/2, (size.H-hudHeight-h)/2
c.FillRect(geom.RectXYWH(0, 0, size.W, size.H-hudHeight), colBanner)
c.FillRRect(geom.RectXYWH(x, y, w, h), 14, paint.Color{R: 0.10, G: 0.10, B: 0.14, A: 0.97})
accent := colHP
if win {
accent = colCoin
}
c.FillRRect(geom.RectXYWH(x, y, w, 4), 2, accent)
tw := s.ctx.Painter().MeasureWidthIn("bold", title, 22)
c.TextIn("bold", title, geom.Pt{X: x + (w-tw)/2, Y: y + 46}, 22, colInk)
rows := [][2]string{
{"Depth reached", fmt.Sprintf("%d of %d", g.depth, maxDepth)},
{"Level", fmt.Sprintf("%d", g.level)},
{"Monsters slain", fmt.Sprintf("%d", g.kills)},
{"Gold", fmt.Sprintf("%d", g.gold)},
{"Turns", fmt.Sprintf("%d", g.turns)},
}
ry := y + 78
for _, r := range rows {
c.TextIn("", r[0], geom.Pt{X: x + 28, Y: ry}, 13, colDim)
vw := s.ctx.Painter().MeasureWidthIn("bold", r[1], 13)
c.TextIn("bold", r[1], geom.Pt{X: x + w - 28 - vw, Y: ry}, 13, colInk)
ry += 22
}
const hint = "press any key to delve again"
hw := s.ctx.Painter().MeasureWidthIn("", hint, 12)
c.TextIn("", hint, geom.Pt{X: x + (w-hw)/2, Y: y + h - 20}, 12, colDim)
}
// --- presentation effects -------------------------------------------------
//
// Turn-based does not have to mean snapping. Entities ease toward their new
// cell over a few frames, hits shove and flash, damage floats off, and the
// screen kicks when you are the one taking it. None of this changes a rule;
// it changes whether a turn reads as an event or as a diff.
const (
moveEase = 14.0 // higher settles faster
flashDecay = 6.0
lungeDecay = 12.0
floatLife = 0.9
shakeDecay = 7.0
)
// advance steps every effect. It reports whether anything is still moving.
func (fx *effects) advance(dt float32, g *Game, ts float32) bool {
busy := false
// Drop entries for entities that are gone — the dead, and everything left
// behind on previous levels. These maps are keyed by pointer, so without
// this a long descent accumulates one entry per monster ever spawned.
live := make(map[*Entity]bool, len(g.monsters)+1)
for _, e := range g.all() {
live[e] = true
}
for e := range fx.pos {
if !live[e] {
delete(fx.pos, e)
delete(fx.flash, e)
delete(fx.lunge, e)
}
}
// Ease render positions toward the true cell.
for _, e := range g.all() {
want := geom.Pt{X: float32(e.X) * ts, Y: float32(e.Y) * ts}
cur, ok := fx.pos[e]
if !ok || ts == 0 {
fx.pos[e] = want
continue
}
d := geom.Pt{X: want.X - cur.X, Y: want.Y - cur.Y}
if d.X*d.X+d.Y*d.Y < 0.25 {
fx.pos[e] = want
continue
}
k := min(1, dt*moveEase)
fx.pos[e] = geom.Pt{X: cur.X + d.X*k, Y: cur.Y + d.Y*k}
busy = true
}
for e, v := range fx.flash {
v -= dt * flashDecay
if v <= 0 {
delete(fx.flash, e)
continue
}
fx.flash[e] = v
busy = true
}
for e, v := range fx.lunge {
k := 1 - min(1, dt*lungeDecay)
v = geom.Pt{X: v.X * k, Y: v.Y * k}
if v.X*v.X+v.Y*v.Y < 0.2 {
delete(fx.lunge, e)
continue
}
fx.lunge[e] = v
busy = true
}
if n := fx.floats[:0]; true {
for _, f := range fx.floats {
f.age += dt
if f.age < floatLife {
n = append(n, f)
busy = true
}
}
fx.floats = n
}
if fx.shake > 0 {
fx.shake -= dt * shakeDecay
if fx.shake < 0 {
fx.shake = 0
}
busy = true
}
return busy
}
// renderPos is where an entity should be drawn: its eased position plus any
// attack lunge, falling back to the true cell before the first frame.
func (s *gameState) renderPos(e *Entity) geom.Pt {
p, ok := s.fx.pos[e]
if !ok {
p = geom.Pt{X: float32(e.X) * s.ts, Y: float32(e.Y) * s.ts}
}
if l, ok := s.fx.lunge[e]; ok {
p = geom.Pt{X: p.X + l.X, Y: p.Y + l.Y}
}
return p
}
// onHit is the game's report that a blow landed, turned into things to look at.
func (s *gameState) onHit(attacker, target *Entity, dmg int) {
s.fx.flash[target] = 1
if s.ts > 0 {
dx := float32(target.X-attacker.X) * s.ts * 0.28
dy := float32(target.Y-attacker.Y) * s.ts * 0.28
s.fx.lunge[attacker] = geom.Pt{X: dx, Y: dy}
}
col := colDamage
if target == s.g.player {
col = colHP
s.fx.shake = 1
}
s.fx.floats = append(s.fx.floats, floater{
x: float32(target.X)*s.ts + s.ts/2, y: float32(target.Y) * s.ts,
text: fmt.Sprintf("-%d", dmg), col: col,
})
s.SetState(nil)
}
// drawFloats paints the damage numbers rising off recent hits.
func (s *gameState) drawFloats(c paint.Canvas) {
for _, f := range s.fx.floats {
t := f.age / floatLife
col := f.col
col.A = 1 - t*t
y := f.y - t*s.ts*0.9
w := s.ctx.Painter().MeasureWidthIn("bold", f.text, 15)
c.TextIn("bold", f.text, geom.Pt{X: f.x - s.origin.X - w/2, Y: y - s.origin.Y}, 15, col)
}
}
// torch returns the current flicker multiplier — two offset sines, so it never
// settles into an obvious loop.
func (s *gameState) torch() float32 {
t := s.fx.clock
return float32(1 + 0.055*math.Sin(t*7.3) + 0.035*math.Sin(t*2.9+1.7))
}
tiles.go
package main
import (
"image"
"image/color"
"math"
)
// The tileset is generated in Go at startup — no binary assets. Every tile is a
// 16×16 region of one shared atlas image, blitted with paint.DrawSprite; the
// shared atlas means one cached GPU texture for the whole map.
const tile = 16
// TileID indexes a tile in the atlas strip.
type TileID int
const (
TFloor TileID = iota
TFloor2
TFloor3
TWall
TWallTop
TShadow
TPlayer
TGoblin
TRat
TPotion
TGold
TStairs
TDoor
TAmulet
TGlow
tileCount
)
// src is the atlas source rectangle for a tile.
func src(id TileID) image.Rectangle {
x := int(id) * tile
return image.Rect(x, 0, x+tile, tile)
}
var (
cOutline = color.RGBA{18, 20, 28, 255}
cFloor = color.RGBA{104, 98, 104, 255}
cFloor2 = color.RGBA{124, 117, 122, 255}
cWall = color.RGBA{124, 110, 96, 255}
cWall2 = color.RGBA{92, 80, 70, 255}
// A wall seen from above catches no torchlight, so it is painted as cold
// stone rather than a dimmer copy of the lit face. That difference is what
// gives a flat tile grid its sense of height.
cWallTop = color.RGBA{46, 46, 58, 255}
cWallTop2 = color.RGBA{38, 38, 50, 255}
cPlayer = color.RGBA{86, 196, 214, 255}
cGoblin = color.RGBA{86, 168, 78, 255}
cRat = color.RGBA{140, 130, 128, 255}
cWhite = color.RGBA{240, 244, 248, 255}
cRed = color.RGBA{206, 66, 68, 255}
cGold = color.RGBA{226, 186, 74, 255}
cBrown = color.RGBA{120, 84, 52, 255}
cGlass = color.RGBA{170, 210, 220, 255}
)
// buildAtlas draws every tile into one image and returns it.
func buildAtlas() *image.RGBA {
a := image.NewRGBA(image.Rect(0, 0, int(tileCount)*tile, tile))
// Floor: stone with a few lighter specks. Three variants, picked per cell
// by position hash, so a large room does not read as one flat texture.
floorTile(a, TFloor, [][2]int{{3, 4}, {10, 3}, {6, 11}, {12, 9}, {2, 13}, {9, 8}})
floorTile(a, TFloor2, [][2]int{{5, 2}, {13, 6}, {2, 8}, {8, 13}, {11, 11}})
floorTile(a, TFloor3, [][2]int{{7, 5}, {4, 9}, {12, 3}, {14, 12}, {1, 5}, {9, 2}, {6, 14}})
// Wall face: brick with mortar lines, and a lighter top course so the
// upper edge catches the light like a real ledge.
fill(a, TWall, cWall)
for y := range tile {
for x := range tile {
if y%5 == 4 || (x+((y/5)%2)*4)%8 == 7 {
px(a, TWall, x, y, cWall2)
}
}
}
for x := range tile {
px(a, TWall, x, 0, shade(cWall, 1.22))
px(a, TWall, x, 1, shade(cWall, 1.10))
}
// Wall top: cold, coarse stone with no mortar — read as unlit rock above
// the room rather than another lit face.
fill(a, TWallTop, cWallTop)
for y := range tile {
for x := range tile {
if (x*7+y*13)%11 == 0 {
px(a, TWallTop, x, y, cWallTop2)
}
}
}
// Shadow: a soft elliptical blot that grounds an entity on its tile.
shadowTile(a)
// Player: cyan adventurer blob with eyes.
creature(a, TPlayer, cPlayer, cWhite, cOutline)
// Goblin: green, angry (red pupils).
creature(a, TGoblin, cGoblin, cRed, cOutline)
// Rat: small gray, with a tail.
disc(a, TRat, 8, 9, 4, cRat)
outline(a, TRat, cOutline)
px(a, TRat, 13, 11, cRat)
px(a, TRat, 14, 12, cRat)
px(a, TRat, 6, 8, cOutline)
px(a, TRat, 10, 8, cOutline)
// Potion: flask with red liquid.
rect(a, TPotion, 7, 3, 8, 5, cGlass)
disc(a, TPotion, 8, 10, 4, cGlass)
disc(a, TPotion, 8, 11, 3, cRed)
outline(a, TPotion, cOutline)
// Gold: a small pile of coins.
for _, p := range [][2]int{{6, 10}, {9, 10}, {7, 7}} {
disc(a, TGold, p[0], p[1], 2, cGold)
}
// Stairs down: nested steps.
for i := range 4 {
rect(a, TStairs, 3+i, 3+i*3, 12, 5+i*3, shade(cFloor2, 1-float64(i)*0.18))
}
// Door: brown with a knob.
rect(a, TDoor, 3, 2, 12, 13, cBrown)
outlineRect(a, TDoor, 3, 2, 12, 13, cOutline)
px(a, TDoor, 10, 7, cGold)
// Amulet: gold medallion with a red gem and a chain.
px(a, TAmulet, 8, 3, cGold)
px(a, TAmulet, 8, 4, cGold)
disc(a, TAmulet, 8, 9, 4, cGold)
disc(a, TAmulet, 8, 9, 2, cRed)
outline(a, TAmulet, cOutline)
// Glow: a soft radial warm halo (premultiplied alpha) for the torch aura.
glowTile(a)
return a
}
// floorTile paints one floor variant: a base fill plus lighter grit.
func floorTile(a *image.RGBA, id TileID, specks [][2]int) {
fill(a, id, cFloor)
for _, p := range specks {
px(a, id, p[0], p[1], cFloor2)
}
}
// shadowTile paints a soft dark ellipse, wider than it is tall, sitting in the
// lower half of the tile where a standing figure's feet are.
func shadowTile(a *image.RGBA) {
const cx, cy = 7.5, 11.0
for y := range tile {
for x := range tile {
dx := (float64(x) - cx) / 5.5
dy := (float64(y) - cy) / 2.6
f := 1 - math.Sqrt(dx*dx+dy*dy)
if f < 0 {
f = 0
}
f *= f * 0.55
ax, ay := at(TShadow, x, y)
a.SetRGBA(ax, ay, color.RGBA{A: uint8(255 * f)})
}
}
}
func glowTile(a *image.RGBA) {
const cx, cy = 7.5, 7.5
for y := range tile {
for x := range tile {
f := 1 - math.Hypot(float64(x)-cx, float64(y)-cy)/8
if f < 0 {
f = 0
}
f *= f
ax, ay := at(TGlow, x, y)
a.SetRGBA(ax, ay, color.RGBA{
R: uint8(255 * f), G: uint8(238 * f), B: uint8(205 * f), A: uint8(255 * f)})
}
}
}
// creature draws a rounded body with two eyes — the shared entity shape.
func creature(a *image.RGBA, id TileID, body, eye, out color.RGBA) {
disc(a, id, 8, 9, 5, body)
outline(a, id, out)
disc(a, id, 6, 8, 1, cWhite)
disc(a, id, 10, 8, 1, cWhite)
px(a, id, 6, 8, eye)
px(a, id, 10, 8, eye)
}
// --- low-level tile painters (tile-local coordinates) ---
func at(id TileID, x, y int) (int, int) { return int(id)*tile + x, y }
func px(a *image.RGBA, id TileID, x, y int, c color.RGBA) {
if x < 0 || y < 0 || x >= tile || y >= tile {
return
}
ax, ay := at(id, x, y)
a.SetRGBA(ax, ay, c)
}
func fill(a *image.RGBA, id TileID, c color.RGBA) {
for y := range tile {
for x := range tile {
px(a, id, x, y, c)
}
}
}
func rect(a *image.RGBA, id TileID, x0, y0, x1, y1 int, c color.RGBA) {
for y := y0; y <= y1; y++ {
for x := x0; x <= x1; x++ {
px(a, id, x, y, c)
}
}
}
func disc(a *image.RGBA, id TileID, cx, cy, r int, c color.RGBA) {
for y := -r; y <= r; y++ {
for x := -r; x <= r; x++ {
if x*x+y*y <= r*r {
px(a, id, cx+x, cy+y, c)
}
}
}
}
// outline darkens the transparent-adjacent border of already-drawn body pixels.
func outline(a *image.RGBA, id TileID, out color.RGBA) {
// Collect the edge pixels first, then write them.
//
// Writing into the image while still scanning it makes each freshly
// outlined pixel look like body on the next iteration, so the outline
// seeds another outline and floods the whole tile. That is why every
// creature and item used to sit on an opaque black square, unable to
// stand on the lit floor beneath it.
var edge [][2]int
for y := range tile {
for x := range tile {
ax, ay := at(id, x, y)
if a.RGBAAt(ax, ay).A == 0 {
continue
}
for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
nx, ny := x+d[0], y+d[1]
if nx < 0 || ny < 0 || nx >= tile || ny >= tile {
continue
}
bx, by := at(id, nx, ny)
if a.RGBAAt(bx, by).A == 0 {
edge = append(edge, [2]int{nx, ny})
}
}
}
}
for _, p := range edge {
px(a, id, p[0], p[1], out)
}
}
func outlineRect(a *image.RGBA, id TileID, x0, y0, x1, y1 int, c color.RGBA) {
for x := x0; x <= x1; x++ {
px(a, id, x, y0, c)
px(a, id, x, y1, c)
}
for y := y0; y <= y1; y++ {
px(a, id, x0, y, c)
px(a, id, x1, y, c)
}
}
func shade(c color.RGBA, f float64) color.RGBA {
cl := func(v uint8) uint8 {
n := float64(v) * f
if n > 255 {
n = 255
}
return uint8(n)
}
return color.RGBA{cl(c.R), cl(c.G), cl(c.B), c.A}
}