This commit is contained in:
2026-05-28 19:27:23 +02:00
parent ce95df7f05
commit 33adc53e5a
8 changed files with 451 additions and 409 deletions
+55
View File
@@ -0,0 +1,55 @@
package lib
import (
"bytes"
"fmt"
"image/png"
"io/fs"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"golang.org/x/image/font/gofont/gobold"
)
type Assets struct {
Bg *ebiten.Image
Scooter *ebiten.Image
Rabbit [4]*ebiten.Image
HudFont *text.GoTextFace
}
func LoadAssets(assetsFS fs.FS) (*Assets, error) {
src, err := text.NewGoTextFaceSource(bytes.NewReader(gobold.TTF))
if err != nil {
return nil, fmt.Errorf("load font: %w", err)
}
a := &Assets{
HudFont: &text.GoTextFace{Source: src, Size: 8},
}
if a.Bg, err = loadImage(assetsFS, "assets/background.png"); err != nil {
return nil, err
}
if a.Scooter, err = loadImage(assetsFS, "assets/scooter.png"); err != nil {
return nil, err
}
for i := range a.Rabbit {
name := fmt.Sprintf("assets/rabbit_f%d.png", i)
if a.Rabbit[i], err = loadImage(assetsFS, name); err != nil {
return nil, err
}
}
return a, nil
}
func loadImage(assetsFS fs.FS, name string) (*ebiten.Image, error) {
f, err := assetsFS.Open(name)
if err != nil {
return nil, fmt.Errorf("open %s: %w", name, err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", name, err)
}
return ebiten.NewImageFromImage(img), nil
}
+42
View File
@@ -0,0 +1,42 @@
package lib
import "image/color"
const (
// Logical screen matches the bezel artwork (assets/background.png) 1:1.
ScreenW = 1200
ScreenH = 896
// Game world is rendered to this offscreen, then scaled into the LCD cutout.
LcdW = 240
LcdH = 136
// Olive LCD screen rectangle within the bezel image (measured from the PNG).
LcdScreenX = 331
LcdScreenY = 203
LcdScreenW = 537
LcdScreenH = 399
LaneCount = 5
HorizonY = 28.0
HorizonX = 120.0
PlayerY = 116.0
MaxMiss = 3
)
var LaneX = [LaneCount]float32{40, 80, 120, 160, 200}
// How the LcdW×LcdH world maps onto the bezel's LCD cutout (uniform fit, centered).
var (
LcdScale = float64(LcdScreenW) / float64(LcdW)
LcdDrawX = float64(LcdScreenX)
LcdDrawY = float64(LcdScreenY) + (float64(LcdScreenH)-float64(LcdH)*LcdScale)/2
)
// LCD palette — ColorBg matches the bezel's printed screen olive so the
// letterboxed margins blend into the case art.
var (
ColorBg = color.RGBA{0xbf, 0xc2, 0xa3, 0xff}
ColorFg = color.RGBA{0x1a, 0x1c, 0x2c, 0xff}
ColorDim = color.RGBA{0x6a, 0x76, 0x6e, 0xff}
)
+57
View File
@@ -0,0 +1,57 @@
package lib
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
"github.com/hajimehoshi/ebiten/v2/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)
}
// blitH draws img scaled to targetH pixels tall, anchored so (cx,cy) is the
// horizontal center / vertical bottom of the sprite.
func blitH(dst, img *ebiten.Image, cx, cy, targetH float32) {
b := img.Bounds()
s := float64(targetH) / float64(b.Dy())
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(s, s)
op.GeoM.Translate(float64(cx)-float64(b.Dx())*s/2, float64(cy)-float64(b.Dy())*s)
dst.DrawImage(img, op)
}
func drawText(dst *ebiten.Image, s string, x, y float64, face text.Face, c color.Color) {
opts := &text.DrawOptions{}
opts.GeoM.Translate(x, y)
opts.ColorScale.ScaleWithColor(c)
text.Draw(dst, s, face, opts)
}
func drawTextCentered(dst *ebiten.Image, s string, cx, y float64, face text.Face, c color.Color) {
w, _ := text.Measure(s, face, 0)
drawText(dst, s, cx-w/2, y, face, c)
}
func drawTextRight(dst *ebiten.Image, s string, rx, y float64, face text.Face, c color.Color) {
w, _ := text.Measure(s, face, 0)
drawText(dst, s, rx-w, y, face, c)
}
+174
View File
@@ -0,0 +1,174 @@
package lib
import (
"fmt"
"sort"
"github.com/hajimehoshi/ebiten/v2"
)
type State int
const (
StateTitle State = iota
StatePlay
StateOver
)
type Game struct {
assets *Assets
state State
playerLane int
score int
miss int
rabbits []*Rabbit
spawnTimer int
frame int
flash int
lcd *ebiten.Image
}
func NewGame(a *Assets) *Game {
return &Game{
assets: a,
state: StateTitle,
playerLane: 2,
lcd: ebiten.NewImage(LcdW, LcdH),
}
}
func (g *Game) reset() {
g.playerLane = 2
g.score = 0
g.miss = 0
g.rabbits = g.rabbits[:0]
g.spawnTimer = 0
g.flash = 0
}
// --- ebiten.Game ---
func (g *Game) Update() error {
g.frame++
switch g.state {
case StateTitle, StateOver:
if startPressed() {
g.reset()
g.state = StatePlay
}
case StatePlay:
g.updatePlay()
}
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
screen.DrawImage(g.assets.Bg, nil)
switch g.state {
case StateTitle:
g.drawTitle(g.lcd)
case StatePlay:
g.drawPlay(g.lcd)
case StateOver:
g.drawOver(g.lcd)
}
opts := &ebiten.DrawImageOptions{}
opts.GeoM.Scale(LcdScale, LcdScale)
opts.GeoM.Translate(LcdDrawX, LcdDrawY)
screen.DrawImage(g.lcd, opts)
}
func (g *Game) Layout(_, _ int) (int, int) {
return ScreenW, ScreenH
}
// --- play loop ---
func (g *Game) updatePlay() {
if pressedRepeat(ebiten.KeyLeft, 12, 6) && g.playerLane > 0 {
g.playerLane--
}
if pressedRepeat(ebiten.KeyRight, 12, 6) && g.playerLane < LaneCount-1 {
g.playerLane++
}
g.spawnTimer++
interval := 70 - g.score/2
if interval < 22 {
interval = 22
}
if g.spawnTimer >= interval {
g.spawnTimer = 0
g.rabbits = append(g.rabbits, newRabbit(g.score))
}
for i := len(g.rabbits) - 1; i >= 0; i-- {
r := g.rabbits[i]
r.T += r.Speed
if r.T < 1 {
continue
}
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--
}
}
// --- scene composition ---
func (g *Game) drawPlay(dst *ebiten.Image) {
dst.Fill(ColorBg)
drawRoad(dst)
drawHud(dst, g.assets.HudFont, g.score, g.miss)
// far rabbits first so closer ones overlap on top
sort.Slice(g.rabbits, func(i, j int) bool { return g.rabbits[i].T < g.rabbits[j].T })
for _, r := range g.rabbits {
r.Draw(dst, g.assets.Rabbit)
}
drawScooter(dst, g.assets.Scooter, LaneX[g.playerLane], PlayerY, g.flash > 0, g.frame)
}
func (g *Game) drawTitle(dst *ebiten.Image) {
dst.Fill(ColorBg)
drawRoad(dst)
for i := 0; i < LaneCount; i++ {
drawScooter(dst, g.assets.Scooter, LaneX[i], PlayerY, false, g.frame)
}
for i := 1; i <= 3; i++ {
demo := &Rabbit{
Lane: (g.frame/30 + i) % LaneCount,
T: float64((g.frame+i*25)%90) / 90.0,
}
demo.Draw(dst, g.assets.Rabbit)
}
if (g.frame/30)%2 == 0 {
drawTextCentered(dst, "PRESS SPACE TO START", 120, 56, g.assets.HudFont, ColorFg)
}
drawTextCentered(dst, "ARROWS MOVE SPACE START", 120, 72, g.assets.HudFont, ColorFg)
}
func (g *Game) drawOver(dst *ebiten.Image) {
g.drawPlay(dst)
rectFill(dst, 60, 50, 120, 36, ColorBg)
rectStroke(dst, 60, 50, 120, 36, ColorFg)
drawTextCentered(dst, "GAME OVER", 120, 54, g.assets.HudFont, ColorFg)
drawTextCentered(dst, fmt.Sprintf("SCORE %03d", g.score), 120, 66, g.assets.HudFont, ColorFg)
if (g.frame/30)%2 == 0 {
drawTextCentered(dst, "SPACE = RETRY", 120, 76, g.assets.HudFont, ColorFg)
}
}
+24
View File
@@ -0,0 +1,24 @@
package lib
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
// pressedRepeat 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)
}
+40
View File
@@ -0,0 +1,40 @@
package lib
import (
"math"
"math/rand"
"github.com/hajimehoshi/ebiten/v2"
)
type Rabbit struct {
Lane int
T float64
Speed float64
}
// newRabbit spawns a rabbit at the horizon with a speed that ramps up with score.
func newRabbit(score int) *Rabbit {
return &Rabbit{
Lane: rand.Intn(LaneCount),
Speed: 0.010 + rand.Float64()*0.006 + math.Min(float64(score), 60)*0.0002,
}
}
// Position returns the on-screen pixel position along the perspective curve.
func (r *Rabbit) Position() (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)
}
// Draw renders the rabbit, growing from the horizon and cycling hop frames.
func (r *Rabbit) Draw(dst *ebiten.Image, frames [4]*ebiten.Image) {
x, y := r.Position()
h := float32(6 + r.T*22)
frame := frames[int(r.T*16)%len(frames)]
blitH(dst, frame, x, y+h/2, h)
}
+51
View File
@@ -0,0 +1,51 @@
package lib
import (
"fmt"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text/v2"
)
func drawRoad(dst *ebiten.Image) {
// trapezoidal road outline
line(dst, HorizonX-20, HorizonY, 0, 136, ColorFg)
line(dst, HorizonX+20, HorizonY, 239, 136, ColorFg)
// 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, ColorDim)
px(dst, rx+1, y, ColorDim)
}
drawTree(dst, 14, 70)
}
func drawTree(dst *ebiten.Image, x, y float32) {
rectFill(dst, x+3, y, 3, 10, ColorFg)
circFill(dst, x+4, y-3, 5, ColorFg)
circFill(dst, x+8, y-1, 4, ColorFg)
circFill(dst, x, y-1, 4, ColorFg)
circFill(dst, x+5, y-7, 3, ColorFg)
}
// drawScooter blinks the sprite when `blink` is true, using the global frame counter.
func drawScooter(dst, sprite *ebiten.Image, x, y float32, blink bool, frame int) {
if blink && (frame/4)%2 == 0 {
return
}
blitH(dst, sprite, x, y+8, 24)
}
func drawHud(dst *ebiten.Image, font text.Face, score, miss int) {
drawText(dst, "SCORE", 4, 2, font, ColorFg)
drawText(dst, fmt.Sprintf("%03d", score), 40, 2, font, ColorFg)
missSlotRight := float64(LcdW - 4)
missSlotX := missSlotRight - float64(MaxMiss*8)
drawTextRight(dst, "MISS", missSlotX-2, 2, font, ColorFg)
for i := 0; i < miss; i++ {
drawText(dst, "X", missSlotX+float64(i*8), 2, font, ColorFg)
}
}
+8 -409
View File
@@ -1,429 +1,28 @@
package main package main
import ( import (
"bytes"
"embed" "embed"
"fmt"
"image/color"
"image/png"
"log" "log"
"math"
"math/rand"
"sort"
"github.com/hajimehoshi/ebiten/v2" "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/gofont/gobold"
)
const ( "rabbitroller/lib"
// Logical screen matches the bezel artwork (assets/background.png) 1:1.
screenW = 1200
screenH = 896
// Game world is rendered to this offscreen, then scaled into the LCD cutout.
lcdW = 240
lcdH = 136
// Olive LCD screen rectangle within the bezel image (measured from the PNG).
lcdScreenX = 331
lcdScreenY = 203
lcdScreenW = 537
lcdScreenH = 399
nLanes = 5
horizonY = 28.0
horizonX = 120.0
playerY = 116.0
maxMiss = 3
)
var laneX = [nLanes]float32{40, 80, 120, 160, 200}
// How the lcdW×lcdH world maps onto the bezel's LCD cutout (uniform fit, centered).
var (
lcdScale = float64(lcdScreenW) / float64(lcdW)
lcdDrawX = float64(lcdScreenX)
lcdDrawY = float64(lcdScreenY) + (float64(lcdScreenH)-float64(lcdH)*lcdScale)/2
)
// LCD palette — lcdBg matches the bezel's printed screen olive so the
// letterboxed margins blend into the case art.
var (
lcdBg = color.RGBA{0xbf, 0xc2, 0xa3, 0xff} // bezel screen olive
lcdFg = color.RGBA{0x1a, 0x1c, 0x2c, 0xff}
lcdDim = color.RGBA{0x6a, 0x76, 0x6e, 0xff}
) )
//go:embed assets/background.png assets/scooter.png assets/rabbit_f0.png assets/rabbit_f1.png assets/rabbit_f2.png assets/rabbit_f3.png //go:embed assets/background.png assets/scooter.png assets/rabbit_f0.png assets/rabbit_f1.png assets/rabbit_f2.png assets/rabbit_f3.png
var assetsFS embed.FS var assetsFS embed.FS
var (
bgImg *ebiten.Image
scooterImg *ebiten.Image
rabbitImgs [4]*ebiten.Image
)
var bezelFontSource *text.GoTextFaceSource
func loadImg(name string) *ebiten.Image {
f, err := assetsFS.Open(name)
if err != nil {
log.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
log.Fatal(err)
}
return ebiten.NewImageFromImage(img)
}
func init() {
s, err := text.NewGoTextFaceSource(bytes.NewReader(gobold.TTF))
if err != nil {
log.Fatal(err)
}
bezelFontSource = s
bgImg = loadImg("assets/background.png")
scooterImg = loadImg("assets/scooter.png")
for i := range rabbitImgs {
rabbitImgs[i] = loadImg(fmt.Sprintf("assets/rabbit_f%d.png", i))
}
}
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
lcdImg *ebiten.Image
hudFont *text.GoTextFace
}
func newGame() *Game {
return &Game{
state: stateTitle,
playerLane: 2,
lcdImg: ebiten.NewImage(lcdW, lcdH),
hudFont: &text.GoTextFace{Source: bezelFontSource, Size: 8},
}
}
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.Face, c color.Color) {
opts := &text.DrawOptions{}
opts.GeoM.Translate(x, y)
opts.ColorScale.ScaleWithColor(c)
text.Draw(dst, s, face, opts)
}
func drawTextCentered(dst *ebiten.Image, s string, cx, y float64, face text.Face, c color.Color) {
w, _ := text.Measure(s, face, 0)
drawText(dst, s, cx-w/2, y, face, c)
}
func drawTextRight(dst *ebiten.Image, s string, rx, y float64, face text.Face, c color.Color) {
w, _ := text.Measure(s, face, 0)
drawText(dst, s, rx-w, y, face, c)
}
// --- 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)
}
// LCD tree silhouette on the left shoulder
drawLcdTree(dst, 14, 70)
}
func drawLcdTree(dst *ebiten.Image, x, y float32) {
// trunk
rectFill(dst, x+3, y, 3, 10, lcdFg)
// blobby canopy
circFill(dst, x+4, y-3, 5, lcdFg)
circFill(dst, x+8, y-1, 4, lcdFg)
circFill(dst, x, y-1, 4, lcdFg)
circFill(dst, x+5, y-7, 3, lcdFg)
}
func (g *Game) drawHud(dst *ebiten.Image) {
drawText(dst, "SCORE", 4, 2, g.hudFont, lcdFg)
drawText(dst, fmt.Sprintf("%03d", g.score), 40, 2, g.hudFont, lcdFg)
// Reserve fixed slot for miss markers on the right
missSlotRight := float64(lcdW - 4)
missSlotX := missSlotRight - float64(maxMiss*8)
drawTextRight(dst, "MISS", missSlotX-2, 2, g.hudFont, lcdFg)
for i := 0; i < g.miss; i++ {
drawText(dst, "X", missSlotX+float64(i*8), 2, g.hudFont, lcdFg)
}
}
// blitH draws img scaled to targetH pixels tall, anchored so (cx,cy) is the
// horizontal center / vertical bottom of the sprite. Nearest filtering keeps
// the LCD pixels crisp.
func blitH(dst, img *ebiten.Image, cx, cy, targetH float32) {
b := img.Bounds()
s := float64(targetH) / float64(b.Dy())
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(s, s)
op.GeoM.Translate(float64(cx)-float64(b.Dx())*s/2, float64(cy)-float64(b.Dy())*s)
dst.DrawImage(img, op)
}
func drawRabbit(dst *ebiten.Image, x, y float32, t float64) {
// Grows from far (small) to near (large); the hop cycle picks one of 4 frames.
h := float32(6 + t*22)
frame := rabbitImgs[int(t*16)%len(rabbitImgs)]
blitH(dst, frame, x, y+h/2, h)
}
func (g *Game) drawScooter(dst *ebiten.Image, x, y float32, blink bool) {
if blink && (g.frame/4)%2 == 0 {
return
}
blitH(dst, scooterImg, x, y+8, 24)
}
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)
// 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 {
drawTextCentered(dst, "PRESS SPACE TO START", 120, 56, g.hudFont, lcdFg)
}
drawTextCentered(dst, "ARROWS MOVE SPACE START", 120, 72, g.hudFont, 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)
drawTextCentered(dst, "GAME OVER", 120, 54, g.hudFont, lcdFg)
drawTextCentered(dst, fmt.Sprintf("SCORE %03d", g.score), 120, 66, g.hudFont, lcdFg)
if (g.frame/30)%2 == 0 {
drawTextCentered(dst, "SPACE = RETRY", 120, 76, g.hudFont, 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) {
// Bezel artwork fills the whole logical screen 1:1.
screen.DrawImage(bgImg, nil)
switch g.state {
case stateTitle:
g.drawTitle(g.lcdImg)
case statePlay:
g.drawPlay(g.lcdImg)
case stateOver:
g.drawOver(g.lcdImg)
}
// Scale the game world into the bezel's LCD cutout.
opts := &ebiten.DrawImageOptions{}
opts.GeoM.Scale(lcdScale, lcdScale)
opts.GeoM.Translate(lcdDrawX, lcdDrawY)
screen.DrawImage(g.lcdImg, opts)
}
func (g *Game) Layout(_, _ int) (int, int) {
return screenW, screenH
}
func main() { func main() {
assets, err := lib.LoadAssets(assetsFS)
if err != nil {
log.Fatal(err)
}
ebiten.SetWindowSize(450, 336) ebiten.SetWindowSize(450, 336)
ebiten.SetWindowTitle("Rabbit Scooter") ebiten.SetWindowTitle("Rabbit Scooter")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
if err := ebiten.RunGame(newGame()); err != nil {
if err := ebiten.RunGame(lib.NewGame(assets)); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }