refact
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user