From 6ee8a55efb9b3d44a0074ed7670bfc5f8b2ad600 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Wed, 20 May 2026 21:44:16 +0200 Subject: [PATCH] initial commit --- .gitignore | 4 + Makefile | 76 +++++++++++ go.mod | 20 +++ go.sum | 28 ++++ main.go | 354 ++++++++++++++++++++++++++++++++++++++++++++++++++ metadata.json | 9 ++ 6 files changed, 491 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 metadata.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa5f08b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.claude +.local +.vscode +rabbitroller diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..49bd39e --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +# ----------------------------------------- +# Makefile – Ebitengine project builder +# ----------------------------------------- + +PROJECT = rabbitroller + +BIN_DIR = bin +DIST_DIR = dist +WASM_NAME = game.wasm +OUTPUT_BIN = $(BIN_DIR)/$(PROJECT) +OUTPUT_WASM = $(DIST_DIR)/$(WASM_NAME) +OUTPUT_JS = $(DIST_DIR)/wasm_exec.js +OUTPUT_ZIP = $(PROJECT)-$(VERSION).html.zip + +VERSION_FILE = .version +INDEX_HTML_URL = https://git.teletype.hu/tools/ebitengine-tools/raw/branch/master/web/index.html + +all: build + +build: + @mkdir -p $(BIN_DIR) + go build -o $(OUTPUT_BIN) . + +wasm: + @mkdir -p $(DIST_DIR) + GOOS=js GOARCH=wasm go build -o $(OUTPUT_WASM) . + cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" $(OUTPUT_JS) + +export: wasm + @if [ -z "$(VERSION)" ]; then \ + echo "ERROR: VERSION not set!"; exit 1; \ + fi + @echo "==> Downloading index.html" + curl -sSL $(INDEX_HTML_URL) -o $(DIST_DIR)/index.html + @echo "==> Packaging HTML/WASM for $(VERSION)" + zip -r $(OUTPUT_ZIP) -j $(DIST_DIR)/$(WASM_NAME) $(DIST_DIR)/wasm_exec.js $(DIST_DIR)/index.html + @echo "==> Cleaning temporary files" + rm -f $(DIST_DIR)/$(WASM_NAME) $(DIST_DIR)/wasm_exec.js $(DIST_DIR)/index.html + +watch: + fswatch -o . | while read; do make build; done + +clean: + rm -rf $(BIN_DIR) $(DIST_DIR) + +ci-version: + @if [ -f metadata.json ]; then \ + VERSION=$$(jq -r '.version' metadata.json); \ + else \ + VERSION=$$(git rev-parse --short HEAD); \ + fi; \ + BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$BRANCH" != "main" ] && [ "$$BRANCH" != "master" ]; then \ + VERSION="dev-$$VERSION-$$BRANCH"; \ + fi; \ + echo $$VERSION > $(VERSION_FILE) + +ci-export: + @VERSION=$$(cat $(VERSION_FILE)); \ + $(MAKE) export VERSION=$$VERSION + +ci-upload: + @VERSION=$$(cat $(VERSION_FILE)); \ + FILE="$(PROJECT)-$$VERSION.html.zip"; \ + META_SRC="metadata.json"; \ + META_DST="$(PROJECT)-$$VERSION.metadata.json"; \ + cp $$META_SRC $$META_DST; \ + sshpass -p "$(DROPAREA_SSH_PASSWORD)" scp -o StrictHostKeyChecking=no -P $(DROPAREA_PORT) \ + $$FILE $$META_DST \ + $(DROPAREA_USER)@$(DROPAREA_HOST):$(DROPAREA_TARGET_PATH)/ + +ci-update: + @VERSION=$$(cat $(VERSION_FILE)); \ + curl "$(UPDATE_SERVER)/update?secret=$(UPDATE_SECRET)&name=$(PROJECT)&platform=ebitengine&version=$$VERSION" + +.PHONY: all build wasm export watch clean ci-version ci-export ci-upload ci-update \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95fc229 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module rabbitroller + +go 1.26.3 + +require ( + github.com/hajimehoshi/ebiten/v2 v2.9.9 + golang.org/x/image v0.40.0 +) + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/go-text/typesetting v0.3.0 // indirect + github.com/jezek/xgb v1.1.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5e858e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= +github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/hajimehoshi/bitmapfont/v4 v4.1.0 h1:eE3qa5Do4qhowZVIHjsrX5pYyyPN6sAFWMsO7QREm3U= +github.com/hajimehoshi/bitmapfont/v4 v4.1.0/go.mod h1:/PD+aLjAJ0F2UoQx6hkOfXqWN7BkroDUMr5W+IT1dpE= +github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M= +github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/main.go b/main.go new file mode 100644 index 0000000..53e9e21 --- /dev/null +++ b/main.go @@ -0,0 +1,354 @@ +package main + +import ( + "fmt" + "image/color" + "log" + "math" + "math/rand" + "sort" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + "golang.org/x/image/font/basicfont" +) + +const ( + screenW = 240 + screenH = 136 + scale = 4 + + nLanes = 5 + horizonY = 28.0 + horizonX = 120.0 + playerY = 116.0 + maxMiss = 3 +) + +var laneX = [nLanes]float32{40, 80, 120, 160, 200} + +// Sweetie-16 palette +var ( + lcdBg = color.RGBA{0xf4, 0xf4, 0xf4, 0xff} // #12 + lcdFg = color.RGBA{0x1a, 0x1c, 0x2c, 0xff} // #0 + lcdDim = color.RGBA{0x94, 0xb0, 0xc2, 0xff} // #13 +) + +type stateKind int + +const ( + stateTitle stateKind = iota + statePlay + stateOver +) + +type rabbit struct { + lane int + t float64 + speed float64 +} + +type Game struct { + state stateKind + playerLane int + score int + miss int + rabbits []*rabbit + spawnTimer int + frame int + flash int + font *text.GoXFace +} + +func newGame() *Game { + return &Game{ + state: stateTitle, + playerLane: 2, + font: text.NewGoXFace(basicfont.Face7x13), + } +} + +func (g *Game) reset() { + g.playerLane = 2 + g.score = 0 + g.miss = 0 + g.rabbits = g.rabbits[:0] + g.spawnTimer = 0 + g.flash = 0 +} + +// btnp(key, hold, period): fires on press, then every `period` frames after held for `hold` frames. +func pressedRepeat(key ebiten.Key, hold, period int) bool { + if inpututil.IsKeyJustPressed(key) { + return true + } + d := inpututil.KeyPressDuration(key) + if d > hold && (d-hold)%period == 0 { + return true + } + return false +} + +func startPressed() bool { + return inpututil.IsKeyJustPressed(ebiten.KeyZ) || + inpututil.IsKeyJustPressed(ebiten.KeySpace) || + inpututil.IsKeyJustPressed(ebiten.KeyEnter) +} + +func (g *Game) spawnRabbit() { + r := &rabbit{ + lane: rand.Intn(nLanes), + t: 0, + speed: 0.010 + rand.Float64()*0.006 + math.Min(float64(g.score), 60)*0.0002, + } + g.rabbits = append(g.rabbits, r) +} + +func rabbitPos(r *rabbit) (float32, float32) { + endX := float64(laneX[r.lane]) + endY := playerY - 6.0 + x := horizonX + (endX-horizonX)*(r.t*r.t) + y := horizonY + (endY-horizonY)*r.t + hop := math.Abs(math.Sin(r.t*14)) * (2 + r.t*4) + return float32(x), float32(y - hop) +} + +func (g *Game) updatePlay() { + if pressedRepeat(ebiten.KeyLeft, 12, 6) && g.playerLane > 0 { + g.playerLane-- + } + if pressedRepeat(ebiten.KeyRight, 12, 6) && g.playerLane < nLanes-1 { + g.playerLane++ + } + + g.spawnTimer++ + interval := 70 - g.score/2 + if interval < 22 { + interval = 22 + } + if g.spawnTimer >= interval { + g.spawnTimer = 0 + g.spawnRabbit() + } + + for i := len(g.rabbits) - 1; i >= 0; i-- { + r := g.rabbits[i] + r.t += r.speed + if r.t >= 1 { + if r.lane == g.playerLane { + g.miss++ + g.flash = 12 + if g.miss >= maxMiss { + g.state = stateOver + } + } else { + g.score++ + } + g.rabbits = append(g.rabbits[:i], g.rabbits[i+1:]...) + } + } + + if g.flash > 0 { + g.flash-- + } +} + +// --- drawing primitives wrapping ebiten/vector --- + +func px(dst *ebiten.Image, x, y float32, c color.Color) { + vector.DrawFilledRect(dst, x, y, 1, 1, c, false) +} + +func line(dst *ebiten.Image, x1, y1, x2, y2 float32, c color.Color) { + vector.StrokeLine(dst, x1, y1, x2, y2, 1, c, false) +} + +func rectFill(dst *ebiten.Image, x, y, w, h float32, c color.Color) { + vector.DrawFilledRect(dst, x, y, w, h, c, false) +} + +func rectStroke(dst *ebiten.Image, x, y, w, h float32, c color.Color) { + vector.StrokeRect(dst, x, y, w, h, 1, c, false) +} + +func circFill(dst *ebiten.Image, cx, cy, r float32, c color.Color) { + vector.DrawFilledCircle(dst, cx, cy, r, c, false) +} + +func circStroke(dst *ebiten.Image, cx, cy, r float32, c color.Color) { + vector.StrokeCircle(dst, cx, cy, r, 1, c, false) +} + +func drawText(dst *ebiten.Image, s string, x, y float64, face *text.GoXFace, c color.Color) { + opts := &text.DrawOptions{} + opts.GeoM.Translate(x, y) + opts.ColorScale.ScaleWithColor(c) + text.Draw(dst, s, face, opts) +} + +// --- scene drawing --- + +func (g *Game) drawRoad(dst *ebiten.Image) { + // trapezoidal road outline + line(dst, horizonX-20, horizonY, 0, 136, lcdFg) + line(dst, horizonX+20, horizonY, 239, 136, lcdFg) + // shoulder hash marks (perspective) + for i := 1; i <= 6; i++ { + t := float32(i) / 7.0 + y := float32(horizonY) + (136-float32(horizonY))*t + lx := (float32(horizonX) - 20) + (0-(float32(horizonX)-20))*t + rx := (float32(horizonX) + 20) + (239-(float32(horizonX)+20))*t + px(dst, lx-1, y, lcdDim) + px(dst, rx+1, y, lcdDim) + } + // left tree + rectFill(dst, 14, 44, 3, 10, lcdFg) + circFill(dst, 15, 42, 5, lcdFg) + // right field hint + for i := 0; i < 5; i++ { + fi := float32(i) + line(dst, 225, 50+fi*4, 235, 46+fi*4, lcdDim) + } +} + +func (g *Game) drawHud(dst *ebiten.Image) { + drawText(dst, "SCORE", 4, 2, g.font, lcdFg) + drawText(dst, fmt.Sprintf("%03d", g.score), 40, 2, g.font, lcdFg) + drawText(dst, "MISS", 170, 2, g.font, lcdFg) + for i := 0; i < g.miss; i++ { + drawText(dst, "X", float64(206+i*8), 2, g.font, lcdFg) + } +} + +func drawRabbit(dst *ebiten.Image, x, y float32, t float64) { + s := float32(1 + int(t*3)) + // body + rectFill(dst, x-s, y-s, s*2, s*2, lcdFg) + // head + rectFill(dst, x+s-1, y-s-1, 2, 2, lcdFg) + // ears + px(dst, x+s, y-s-2, lcdFg) + px(dst, x+s-2, y-s-2, lcdFg) + // feet stretch on hop + stretch := float32(int(math.Abs(math.Sin(t*14)) * 2)) + px(dst, x-s, y+s, lcdFg) + px(dst, x-s-stretch, y+s, lcdFg) +} + +func (g *Game) drawScooter(dst *ebiten.Image, x, y float32, blink bool) { + if blink && (g.frame/4)%2 == 0 { + return + } + // wheels + circStroke(dst, x-6, y+4, 3, lcdFg) + circStroke(dst, x+6, y+4, 3, lcdFg) + px(dst, x-6, y+4, lcdFg) + px(dst, x+6, y+4, lcdFg) + // deck + rectFill(dst, x-8, y, 16, 3, lcdFg) + // seat post + seat + line(dst, x+4, y, x+4, y-5, lcdFg) + rectFill(dst, x+2, y-6, 5, 2, lcdFg) + // handlebar column + line(dst, x-4, y, x-5, y-8, lcdFg) + // handlebars + line(dst, x-8, y-8, x-2, y-8, lcdFg) + // rider head + circStroke(dst, x-5, y-12, 2, lcdFg) +} + +func (g *Game) drawPlay(dst *ebiten.Image) { + dst.Fill(lcdBg) + g.drawRoad(dst) + g.drawHud(dst) + // draw far rabbits first + sort.Slice(g.rabbits, func(i, j int) bool { return g.rabbits[i].t < g.rabbits[j].t }) + for _, r := range g.rabbits { + x, y := rabbitPos(r) + drawRabbit(dst, x, y, r.t) + } + g.drawScooter(dst, laneX[g.playerLane], playerY, g.flash > 0) +} + +func (g *Game) drawTitle(dst *ebiten.Image) { + dst.Fill(lcdBg) + g.drawRoad(dst) + drawText(dst, "RABBIT ROLLER", 60, 0, g.font, lcdFg) + drawText(dst, "GAME & WATCH", 2, 0, g.font, lcdFg) + drawText(dst, "WIDE SCREEN", 158, 0, g.font, lcdFg) + // preview scooters + for i := 0; i < nLanes; i++ { + g.drawScooter(dst, laneX[i], playerY, false) + } + // demo rabbits + for i := 1; i <= 3; i++ { + fake := &rabbit{ + lane: (g.frame/30 + i) % nLanes, + t: float64((g.frame+i*25)%90) / 90.0, + } + x, y := rabbitPos(fake) + drawRabbit(dst, x, y, fake.t) + } + if (g.frame/30)%2 == 0 { + drawText(dst, "PRESS Z TO START", 60, 58, g.font, lcdFg) + } + drawText(dst, "ARROWS MOVE Z START", 48, 72, g.font, lcdFg) +} + +func (g *Game) drawOver(dst *ebiten.Image) { + g.drawPlay(dst) + rectFill(dst, 60, 50, 120, 36, lcdBg) + rectStroke(dst, 60, 50, 120, 36, lcdFg) + drawText(dst, "GAME OVER", 80, 54, g.font, lcdFg) + drawText(dst, fmt.Sprintf("SCORE %03d", g.score), 76, 66, g.font, lcdFg) + if (g.frame/30)%2 == 0 { + drawText(dst, "Z TO RESTART", 74, 76, g.font, lcdFg) + } +} + +// --- ebiten Game interface --- + +func (g *Game) Update() error { + g.frame++ + switch g.state { + case stateTitle: + if startPressed() { + g.reset() + g.state = statePlay + } + case statePlay: + g.updatePlay() + case stateOver: + if startPressed() { + g.reset() + g.state = statePlay + } + } + return nil +} + +func (g *Game) Draw(screen *ebiten.Image) { + switch g.state { + case stateTitle: + g.drawTitle(screen) + case statePlay: + g.drawPlay(screen) + case stateOver: + g.drawOver(screen) + } +} + +func (g *Game) Layout(_, _ int) (int, int) { + return screenW, screenH +} + +func main() { + ebiten.SetWindowSize(screenW*scale, screenH*scale) + ebiten.SetWindowTitle("Rabbit Roller") + ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) + if err := ebiten.RunGame(newGame()); err != nil { + log.Fatal(err) + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..3477c8c --- /dev/null +++ b/metadata.json @@ -0,0 +1,9 @@ +{ + "name": "rabbitroller", + "title": "Rabbit Roller", + "author": "Teletype Games", + "desc": "Jump and roll your way through the world as a cute rabbit in this endless runner game.", + "site": "https://git.teletype.hu/games/rabbitroller", + "license": "MIT License", + "version": "1.0.0" +}