This commit is contained in:
2026-05-27 21:01:23 +02:00
parent 0c1e5c1db5
commit eb534f368c
8 changed files with 73 additions and 97 deletions

BIN
assets/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

BIN
assets/rabbit_f0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

BIN
assets/rabbit_f1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

BIN
assets/rabbit_f2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 B

BIN
assets/rabbit_f3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

BIN
assets/roller.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets/scooter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

164
main.go
View File

@@ -2,8 +2,10 @@ package main
import ( import (
"bytes" "bytes"
"embed"
"fmt" "fmt"
"image/color" "image/color"
"image/png"
"log" "log"
"math" "math"
"math/rand" "math/rand"
@@ -17,14 +19,19 @@ import (
) )
const ( const (
screenW = 320 // Logical screen matches the bezel artwork (assets/background.png) 1:1.
screenH = 240 screenW = 1200
scale = 4 screenH = 896
// Game world is rendered to this offscreen, then scaled into the LCD cutout.
lcdW = 240 lcdW = 240
lcdH = 136 lcdH = 136
lcdOffX = 40
lcdOffY = 60 // Olive LCD screen rectangle within the bezel image (measured from the PNG).
lcdScreenX = 331
lcdScreenY = 203
lcdScreenW = 537
lcdScreenH = 399
nLanes = 5 nLanes = 5
horizonY = 28.0 horizonY = 28.0
@@ -35,30 +42,57 @@ const (
var laneX = [nLanes]float32{40, 80, 120, 160, 200} var laneX = [nLanes]float32{40, 80, 120, 160, 200}
// LCD palette (sweetie-16 inspired) // How the lcdW×lcdH world maps onto the bezel's LCD cutout (uniform fit, centered).
var ( var (
lcdBg = color.RGBA{0xc8, 0xcc, 0xb8, 0xff} // pale olive — classic G&W LCD 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} lcdFg = color.RGBA{0x1a, 0x1c, 0x2c, 0xff}
lcdDim = color.RGBA{0x6a, 0x76, 0x6e, 0xff} lcdDim = color.RGBA{0x6a, 0x76, 0x6e, 0xff}
) )
// Bezel palette //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 ( var (
bezelRed = color.RGBA{0x8a, 0x1c, 0x1c, 0xff} bgImg *ebiten.Image
bezelTan = color.RGBA{0xd8, 0xcf, 0xb6, 0xff} scooterImg *ebiten.Image
bezelDark = color.RGBA{0x3a, 0x2e, 0x24, 0xff} rabbitImgs [4]*ebiten.Image
wheatGold = color.RGBA{0xb8, 0x9a, 0x5f, 0xff}
grassGreen = color.RGBA{0x55, 0x95, 0x42, 0xff}
) )
var bezelFontSource *text.GoTextFaceSource 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() { func init() {
s, err := text.NewGoTextFaceSource(bytes.NewReader(gobold.TTF)) s, err := text.NewGoTextFaceSource(bytes.NewReader(gobold.TTF))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
bezelFontSource = s 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 type stateKind int
@@ -88,8 +122,6 @@ type Game struct {
lcdImg *ebiten.Image lcdImg *ebiten.Image
hudFont *text.GoTextFace hudFont *text.GoTextFace
titleFont *text.GoTextFace
smallFont *text.GoTextFace
} }
func newGame() *Game { func newGame() *Game {
@@ -98,8 +130,6 @@ func newGame() *Game {
playerLane: 2, playerLane: 2,
lcdImg: ebiten.NewImage(lcdW, lcdH), lcdImg: ebiten.NewImage(lcdW, lcdH),
hudFont: &text.GoTextFace{Source: bezelFontSource, Size: 8}, hudFont: &text.GoTextFace{Source: bezelFontSource, Size: 8},
titleFont: &text.GoTextFace{Source: bezelFontSource, Size: 14},
smallFont: &text.GoTextFace{Source: bezelFontSource, Size: 8},
} }
} }
@@ -272,41 +302,30 @@ func (g *Game) drawHud(dst *ebiten.Image) {
} }
} }
// 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) { func drawRabbit(dst *ebiten.Image, x, y float32, t float64) {
s := float32(1 + int(t*3)) // Grows from far (small) to near (large); the hop cycle picks one of 4 frames.
// body h := float32(6 + t*22)
rectFill(dst, x-s, y-s, s*2, s*2, lcdFg) frame := rabbitImgs[int(t*16)%len(rabbitImgs)]
// head blitH(dst, frame, x, y+h/2, h)
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) { func (g *Game) drawScooter(dst *ebiten.Image, x, y float32, blink bool) {
if blink && (g.frame/4)%2 == 0 { if blink && (g.frame/4)%2 == 0 {
return return
} }
// wheels blitH(dst, scooterImg, x, y+8, 24)
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) { func (g *Game) drawPlay(dst *ebiten.Image) {
@@ -355,52 +374,6 @@ func (g *Game) drawOver(dst *ebiten.Image) {
} }
} }
// --- bezel (Game & Watch case art) ---
func (g *Game) drawBezel(dst *ebiten.Image) {
// Outer red frame
dst.Fill(bezelRed)
// Tan inner panel
rectFill(dst, 4, 4, screenW-8, screenH-8, bezelTan)
// "JÁTSZ & NÉZZ" boxed logo (top-left)
logoX, logoY, logoW, logoH := float32(8), float32(6), float32(56), float32(36)
rectStroke(dst, logoX, logoY, logoW, logoH, bezelDark)
rectStroke(dst, logoX+1, logoY+1, logoW-2, logoH-2, bezelDark)
drawTextCentered(dst, "JÁTSZ", float64(logoX+logoW/2), float64(logoY+4), g.smallFont, bezelDark)
drawTextCentered(dst, "&", float64(logoX+logoW/2), float64(logoY+15), g.smallFont, bezelDark)
drawTextCentered(dst, "NÉZZ", float64(logoX+logoW/2), float64(logoY+25), g.smallFont, bezelDark)
// Center title "NYÚLÓS RUGÓS"
drawTextCentered(dst, "NYÚLÓS RUGÓS", float64(screenW)/2, 18, g.titleFont, bezelDark)
// Wheat field on right bezel (printed art)
g.drawWheatField(dst, 286, 78)
// LCD inner frame (dark groove around the active screen)
rectFill(dst, lcdOffX-4, lcdOffY-4, lcdW+8, lcdH+8, bezelDark)
rectFill(dst, lcdOffX-2, lcdOffY-2, lcdW+4, lcdH+4, color.RGBA{0x15, 0x15, 0x18, 0xff})
}
func (g *Game) drawWheatField(dst *ebiten.Image, vx, vy float32) {
// diagonal hatching emanating from a vanishing point — wheat rows
for i := 0; i < 14; i++ {
angle := math.Pi*0.55 + float64(i)*math.Pi*0.025
ex := vx + float32(math.Cos(angle)*46)
ey := vy + float32(math.Sin(angle)*46)
line(dst, vx, vy, ex, ey, wheatGold)
}
// grass tufts along the road edge
for i := 0; i < 7; i++ {
gy := vy + 6 + float32(i)*8
gx := vx - 6 - float32(i)
px(dst, gx, gy, grassGreen)
px(dst, gx+1, gy-1, grassGreen)
px(dst, gx-1, gy-1, grassGreen)
px(dst, gx, gy-2, grassGreen)
}
}
// --- ebiten Game interface --- // --- ebiten Game interface ---
func (g *Game) Update() error { func (g *Game) Update() error {
@@ -423,7 +396,8 @@ func (g *Game) Update() error {
} }
func (g *Game) Draw(screen *ebiten.Image) { func (g *Game) Draw(screen *ebiten.Image) {
g.drawBezel(screen) // Bezel artwork fills the whole logical screen 1:1.
screen.DrawImage(bgImg, nil)
switch g.state { switch g.state {
case stateTitle: case stateTitle:
@@ -434,8 +408,10 @@ func (g *Game) Draw(screen *ebiten.Image) {
g.drawOver(g.lcdImg) g.drawOver(g.lcdImg)
} }
// Scale the game world into the bezel's LCD cutout.
opts := &ebiten.DrawImageOptions{} opts := &ebiten.DrawImageOptions{}
opts.GeoM.Translate(lcdOffX, lcdOffY) opts.GeoM.Scale(lcdScale, lcdScale)
opts.GeoM.Translate(lcdDrawX, lcdDrawY)
screen.DrawImage(g.lcdImg, opts) screen.DrawImage(g.lcdImg, opts)
} }
@@ -444,7 +420,7 @@ func (g *Game) Layout(_, _ int) (int, int) {
} }
func main() { func main() {
ebiten.SetWindowSize(screenW*scale, screenH*scale) ebiten.SetWindowSize(900, 672)
ebiten.SetWindowTitle("Rabbit Roller") ebiten.SetWindowTitle("Rabbit Roller")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
if err := ebiten.RunGame(newGame()); err != nil { if err := ebiten.RunGame(newGame()); err != nil {