From b41530157a705e11bf2bcb9e373c52e443844ba9 Mon Sep 17 00:00:00 2001 From: sago35 Date: Sat, 4 Jul 2026 13:04:15 +0900 Subject: [PATCH 1/5] Add ./games/hexbobble/ --- Makefile | 1 + games/all/all/all.go | 11 + games/hexbobble/hexbobble/game.go | 604 ++++++++++++++++++++++++++++++ games/hexbobble/main.go | 21 ++ 4 files changed, 637 insertions(+) create mode 100644 games/hexbobble/hexbobble/game.go create mode 100644 games/hexbobble/main.go diff --git a/Makefile b/Makefile index 7d5f15f..e647a30 100644 --- a/Makefile +++ b/Makefile @@ -15,5 +15,6 @@ smoketest: FORCE tinygo build -o ./out/all.conf2025badge.uf2 --size short --target ./targets/conf2025badge.json ./games/all/ tinygo build -o ./out/all.wasm --size short --target wasm --no-debug --panic trap ./games/all/ tinygo build -o ./out/goradius.zero-kb02.uf2 --size short --target ./targets/zero-kb02.json ./games/goradius/ + tinygo build -o ./out/hexbobble.zero-kb02.uf2 --size short --target ./targets/zero-kb02.json ./games/hexbobble/ FORCE: diff --git a/games/all/all/all.go b/games/all/all/all.go index 9ec31a9..8770fb8 100644 --- a/games/all/all/all.go +++ b/games/all/all/all.go @@ -7,6 +7,7 @@ import ( "github.com/sago35/koebiten/games/blocks/blocks" "github.com/sago35/koebiten/games/flappygopher/flappygopher" "github.com/sago35/koebiten/games/goradius/goradius" + "github.com/sago35/koebiten/games/hexbobble/hexbobble" "github.com/sago35/koebiten/games/jumpingopher/jumpingopher" "github.com/sago35/koebiten/games/snakegame/snakegame" ) @@ -77,6 +78,16 @@ func NewGame() *Menu { } }, }, + { + Title: "Hex Bobble", + Game: func() { + koebiten.SetRotation(koebiten.Rotation0) + game := hexbobble.NewGame() + if err := koebiten.RunGame(game); err != nil { + log.Fatal(err) + } + }, + }, }) return menu diff --git a/games/hexbobble/hexbobble/game.go b/games/hexbobble/hexbobble/game.go new file mode 100644 index 0000000..d9d86f4 --- /dev/null +++ b/games/hexbobble/hexbobble/game.go @@ -0,0 +1,604 @@ +package hexbobble + +import ( + "math" + "math/rand/v2" + + "github.com/sago35/koebiten" + "tinygo.org/x/drivers/pixel" +) + +// パズルボブルを 90 度回転させたゲーム。 +// 左の砲台から球を発射し、球は上下の壁で反射しながら右へ進む。 +// 右壁または既存の球に当たると、不可視のヘックスグリッドに吸着する。 +// 同種の球が 3 つ以上つながると消える。 + +const ( + screenW = 128 + screenH = 64 + + maxCols = 18 // 右壁からの列数(col 0 が最も右) + maxRows = 8 // 偶数列の行数(奇数列は 7) + gameOverCol = 15 // この列以降に球が固定されたらゲームオーバー + + numTypes = 6 + + colPitch = 7 // 列の x 間隔 + rowPitch = 8 // 行の y 間隔(奇数列は +4 オフセット) + ballR = 3 // 描画半径(直径 7) + + shooterX = 8 + shooterY = 32 + angleMax = 72 // 発射角の上限(度) + + speed = 3.0 // 球速(px/フレーム) +) + +var ( + white = pixel.NewMonochrome(0xFF, 0xFF, 0xFF) + black = pixel.NewMonochrome(0x00, 0x00, 0x00) +) + +type Game struct { + grid [maxCols][maxRows]int8 // 球の種類。-1 は空 + angle int // 発射角(度)。0 で真右、負で上向き + + cur, next int8 // 現在装填中の球と次の球 + + flying bool + fx, fy float32 + fvx, fvy float32 + ftype int8 + + // マッチ消去エフェクト(拡大するリング) + pops [maxCols * maxRows]popFx + nPop int + // 孤立球の落下エフェクト + falls [maxCols * maxRows]fallFx + nFall int + + score int + scene string +} + +type popFx struct { + x, y int16 + frame int8 +} + +type fallFx struct { + t int8 + x int16 + y, vy float32 +} + +func NewGame() *Game { + g := &Game{} + g.reset() + g.scene = "title" + return g +} + +func (g *Game) reset() { + for c := range g.grid { + for r := range g.grid[c] { + g.grid[c][r] = -1 + } + } + // 初期配置: 右端 4 列を埋める + for c := 0; c < 4; c++ { + for r := 0; r < rowsInCol(c); r++ { + g.grid[c][r] = int8(rand.N(numTypes)) + } + } + g.angle = 0 + g.cur = int8(rand.N(numTypes)) + g.next = int8(rand.N(numTypes)) + g.flying = false + g.nPop = 0 + g.nFall = 0 + g.score = 0 +} + +// --- ヘックスグリッド --- + +func rowsInCol(c int) int { + if c%2 == 0 { + return maxRows + } + return maxRows - 1 +} + +func colX(c int) int { + return screenW - 4 - colPitch*c +} + +func rowY(c, r int) int { + if c%2 == 0 { + return 4 + rowPitch*r + } + return 8 + rowPitch*r +} + +func validCell(c, r int) bool { + return 0 <= c && c < maxCols && 0 <= r && r < rowsInCol(c) +} + +// neighbors は (c, r) に隣接するヘックスセルを返す(最大 6 個) +func neighbors(c, r int) ([6][2]int, int) { + var nb [6][2]int + n := 0 + add := func(nc, nr int) { + if validCell(nc, nr) { + nb[n] = [2]int{nc, nr} + n++ + } + } + add(c, r-1) + add(c, r+1) + if c%2 == 0 { + add(c-1, r-1) + add(c-1, r) + add(c+1, r-1) + add(c+1, r) + } else { + add(c-1, r) + add(c-1, r+1) + add(c+1, r) + add(c+1, r+1) + } + return nb, n +} + +// --- 更新 --- + +func (g *Game) Update() error { + switch g.scene { + case "title": + if isFireJustPressed() { + g.reset() + g.scene = "game" + } + case "game": + g.updateEffects() + g.updateGame() + case "gameover", "clear": + g.updateEffects() + if isFireJustPressed() { + g.scene = "title" + } + } + return nil +} + +func (g *Game) updateGame() { + // 照準(十字キーは押しっぱなしでゆっくり回り、ロータリーは 1 ノッチ 2° の微調整) + if koebiten.IsKeyPressed(koebiten.KeyUp) { + g.angle-- + } + if koebiten.IsKeyPressed(koebiten.KeyDown) { + g.angle++ + } + if koebiten.IsKeyJustPressed(koebiten.KeyRotaryLeft) { + g.angle -= 2 + } + if koebiten.IsKeyJustPressed(koebiten.KeyRotaryRight) { + g.angle += 2 + } + if g.angle < -angleMax { + g.angle = -angleMax + } + if g.angle > angleMax { + g.angle = angleMax + } + + // 発射 + if !g.flying && isFireJustPressed() { + rad := float64(g.angle) * math.Pi / 180 + g.fx = shooterX + g.fy = shooterY + g.fvx = float32(math.Cos(rad)) * speed + g.fvy = float32(math.Sin(rad)) * speed + g.ftype = g.cur + g.flying = true + + // 発射直後に次弾を装填する + g.cur = g.next + g.next = int8(rand.N(numTypes)) + } + + // 飛翔(トンネル防止のため 1px 刻みのサブステップで進める) + if g.flying { + for i := 0; i < 3; i++ { + g.fx += g.fvx / 3 + g.fy += g.fvy / 3 + + // 上下の壁で反射 + if g.fy < ballR+1 { + g.fy = 2*(ballR+1) - g.fy + g.fvy = -g.fvy + } + if g.fy > screenH-ballR-1 { + g.fy = 2*(screenH-ballR-1) - g.fy + g.fvy = -g.fvy + } + + if g.hitAt(g.fx, g.fy) { + g.land() + break + } + } + } +} + +// hitAt は座標 (x, y) が右壁または既存の球に接触しているかを判定する +func (g *Game) hitAt(x, y float32) bool { + if x >= float32(colX(0)) { + return true + } + for c := 0; c < maxCols; c++ { + dx := x - float32(colX(c)) + if dx < -7 || 7 < dx { + continue + } + for r := 0; r < rowsInCol(c); r++ { + if g.grid[c][r] < 0 { + continue + } + dy := y - float32(rowY(c, r)) + if dx*dx+dy*dy < 42 { // (直径-0.5)^2 ≈ 6.5^2 + return true + } + } + } + return false +} + +// land は飛翔中の球を最寄りの空きセルに吸着させる +func (g *Game) land() { + g.flying = false + + bestC, bestR := -1, -1 + bestD := float32(150) // 約 12px 以内のみ候補 + for c := 0; c < maxCols; c++ { + for r := 0; r < rowsInCol(c); r++ { + if g.grid[c][r] >= 0 { + continue + } + if c != 0 && !g.hasOccupiedNeighbor(c, r) { + continue // 右壁(col 0)か既存の球に接するセルのみ + } + dx := g.fx - float32(colX(c)) + dy := g.fy - float32(rowY(c, r)) + d := dx*dx + dy*dy + if d < bestD { + bestD = d + bestC, bestR = c, r + } + } + } + + if bestC < 0 { + g.scene = "gameover" + return + } + + g.grid[bestC][bestR] = g.ftype + g.popMatches(bestC, bestR) + g.removeOrphans() + + // 全消しでゲームクリア + if g.isBoardEmpty() { + g.scene = "clear" + return + } + + // ゲームオーバー判定 + for c := gameOverCol; c < maxCols; c++ { + for r := 0; r < rowsInCol(c); r++ { + if g.grid[c][r] >= 0 { + g.scene = "gameover" + return + } + } + } +} + +func (g *Game) isBoardEmpty() bool { + for c := 0; c < maxCols; c++ { + for r := 0; r < rowsInCol(c); r++ { + if g.grid[c][r] >= 0 { + return false + } + } + } + return true +} + +func (g *Game) hasOccupiedNeighbor(c, r int) bool { + nb, n := neighbors(c, r) + for i := 0; i < n; i++ { + if g.grid[nb[i][0]][nb[i][1]] >= 0 { + return true + } + } + return false +} + +// popMatches は (c, r) と同種でつながる球が 3 つ以上なら消す +func (g *Game) popMatches(c, r int) { + t := g.grid[c][r] + var visited [maxCols][maxRows]bool + var stack, found [maxCols * maxRows][2]int8 + sp, fp := 0, 0 + + visited[c][r] = true + stack[sp] = [2]int8{int8(c), int8(r)} + sp++ + for sp > 0 { + sp-- + cur := stack[sp] + found[fp] = cur + fp++ + nb, n := neighbors(int(cur[0]), int(cur[1])) + for i := 0; i < n; i++ { + nc, nr := nb[i][0], nb[i][1] + if !visited[nc][nr] && g.grid[nc][nr] == t { + visited[nc][nr] = true + stack[sp] = [2]int8{int8(nc), int8(nr)} + sp++ + } + } + } + + if fp < 3 { + return + } + for i := 0; i < fp; i++ { + c, r := int(found[i][0]), int(found[i][1]) + g.grid[c][r] = -1 + g.addPop(colX(c), rowY(c, r)) + } + g.score += fp * 10 +} + +// removeOrphans は右壁(col 0)につながっていない球を落とす +func (g *Game) removeOrphans() { + var reach [maxCols][maxRows]bool + var stack [maxCols * maxRows][2]int8 + sp := 0 + + for r := 0; r < rowsInCol(0); r++ { + if g.grid[0][r] >= 0 { + reach[0][r] = true + stack[sp] = [2]int8{0, int8(r)} + sp++ + } + } + for sp > 0 { + sp-- + cur := stack[sp] + nb, n := neighbors(int(cur[0]), int(cur[1])) + for i := 0; i < n; i++ { + nc, nr := nb[i][0], nb[i][1] + if !reach[nc][nr] && g.grid[nc][nr] >= 0 { + reach[nc][nr] = true + stack[sp] = [2]int8{int8(nc), int8(nr)} + sp++ + } + } + } + + removed := 0 + for c := 0; c < maxCols; c++ { + for r := 0; r < rowsInCol(c); r++ { + if g.grid[c][r] >= 0 && !reach[c][r] { + g.addFall(colX(c), rowY(c, r), g.grid[c][r]) + g.grid[c][r] = -1 + removed++ + } + } + } + g.score += removed * 20 +} + +// --- エフェクト --- + +func (g *Game) addPop(x, y int) { + if g.nPop >= len(g.pops) { + return + } + g.pops[g.nPop] = popFx{x: int16(x), y: int16(y)} + g.nPop++ +} + +func (g *Game) addFall(x, y int, t int8) { + if g.nFall >= len(g.falls) { + return + } + g.falls[g.nFall] = fallFx{t: t, x: int16(x), y: float32(y)} + g.nFall++ +} + +func (g *Game) updateEffects() { + // マッチ消去: リングを 1 フレームずつ拡大し、終わったら詰める + for i := 0; i < g.nPop; { + g.pops[i].frame++ + if g.pops[i].frame > 6 { + g.pops[i] = g.pops[g.nPop-1] + g.nPop-- + } else { + i++ + } + } + // 孤立球: 重力で加速しながら落下し、画面外に出たら詰める + for i := 0; i < g.nFall; { + g.falls[i].vy += 0.35 + g.falls[i].y += g.falls[i].vy + if g.falls[i].y > screenH+ballR+1 { + g.falls[i] = g.falls[g.nFall-1] + g.nFall-- + } else { + i++ + } + } +} + +func (g *Game) drawEffects(screen *koebiten.Image) { + for i := 0; i < g.nPop; i++ { + p := g.pops[i] + koebiten.DrawCircle(screen, int(p.x), int(p.y), ballR+int(p.frame), black) + } + for i := 0; i < g.nFall; i++ { + f := g.falls[i] + drawBall(screen, int(f.x), int(f.y), f.t) + } +} + +func isFireJustPressed() bool { + keys := koebiten.AppendJustPressedKeys(nil) + for _, k := range keys { + switch k { + case koebiten.KeyUp, koebiten.KeyDown, + koebiten.KeyRotaryLeft, koebiten.KeyRotaryRight: + // 照準操作は発射しない + default: + return true + } + } + return false +} + +// --- 描画 --- + +func (g *Game) Draw(screen *koebiten.Image) { + switch g.scene { + case "title": + g.drawTitle(screen) + case "game": + g.drawGame(screen) + case "gameover": + g.drawBoard(screen) + g.drawEffects(screen) + koebiten.Println("Game Over") + koebiten.Println("Score", g.score) + case "clear": + g.drawClear(screen) + g.drawEffects(screen) + } +} + +func (g *Game) drawClear(screen *koebiten.Image) { + koebiten.Println("Game Clear!") + koebiten.Println("Score", g.score) + for t := 0; t < numTypes; t++ { + drawBall(screen, 16+t*12, 40, int8(t)) + drawBall(screen, 22+t*12, 50, int8((t+3)%numTypes)) + } +} + +func (g *Game) drawTitle(screen *koebiten.Image) { + koebiten.Println("HEX BOBBLE") + koebiten.Println("press any key") + for t := 0; t < numTypes; t++ { + drawBall(screen, 16+t*12, 40, int8(t)) + } +} + +func (g *Game) drawGame(screen *koebiten.Image) { + g.drawBoard(screen) + g.drawEffects(screen) + + // 照準線と軌道ガイド、装填中の球 + g.drawAimGuide(screen) + drawBall(screen, shooterX, shooterY, g.cur) + + // 次の球 + drawBall(screen, shooterX, shooterY+20, g.next) + + // 飛翔中の球 + if g.flying { + drawBall(screen, int(g.fx), int(g.fy), g.ftype) + } + + koebiten.Println("Score", g.score) +} + +// drawAimGuide は照準線(球から少し離す)と、反射を含む軌道ガイドの点線を描く +func (g *Game) drawAimGuide(screen *koebiten.Image) { + rad := float64(g.angle) * math.Pi / 180 + dx := float32(math.Cos(rad)) + dy := float32(math.Sin(rad)) + + // 方向線: 装填中の球が見えるよう中心から 6px 離して描く + x1 := shooterX + int(6*dx) + y1 := shooterY + int(6*dy) + x2 := shooterX + int(13*dx) + y2 := shooterY + int(13*dy) + koebiten.DrawLine(screen, x1, y1, x2, y2, black) + + // 軌道ガイド: 実際の飛翔と同じ反射計算で 2px 刻みに進め、5 点ごとに点を打つ + px := float32(shooterX) + py := float32(shooterY) + vx := dx * 2 + vy := dy * 2 + for i := 1; i <= 200; i++ { + px += vx + py += vy + if py < ballR+1 { + py = 2*(ballR+1) - py + vy = -vy + } + if py > screenH-ballR-1 { + py = 2*(screenH-ballR-1) - py + vy = -vy + } + if g.hitAt(px, py) { + break + } + if i >= 8 && i%3 == 0 { + koebiten.DrawFilledRect(screen, int(px), int(py), 1, 1, black) + } + } +} + +func (g *Game) drawBoard(screen *koebiten.Image) { + // 右壁 + koebiten.DrawLine(screen, screenW-1, 0, screenW-1, screenH-1, black) + + for c := 0; c < maxCols; c++ { + for r := 0; r < rowsInCol(c); r++ { + if g.grid[c][r] >= 0 { + drawBall(screen, colX(c), rowY(c, r), g.grid[c][r]) + } + } + } +} + +// drawBall は球の種類ごとに異なる模様で描く(モノクロのため模様で区別) +func drawBall(screen *koebiten.Image, x, y int, t int8) { + switch t { + case 0: // 塗りつぶし + koebiten.DrawFilledCircle(screen, x, y, ballR, black) + case 1: // 輪郭のみ + koebiten.DrawCircle(screen, x, y, ballR, black) + case 2: // 輪郭+中央ドット + koebiten.DrawCircle(screen, x, y, ballR, black) + koebiten.DrawFilledRect(screen, x-1, y-1, 3, 3, black) + case 3: // 輪郭+横線 + koebiten.DrawCircle(screen, x, y, ballR, black) + koebiten.DrawLine(screen, x-2, y, x+2, y, black) + case 4: // 輪郭+縦線 + koebiten.DrawCircle(screen, x, y, ballR, black) + koebiten.DrawLine(screen, x, y-2, x, y+2, black) + case 5: // 輪郭+X + koebiten.DrawCircle(screen, x, y, ballR, black) + koebiten.DrawLine(screen, x-2, y-2, x+2, y+2, black) + koebiten.DrawLine(screen, x-2, y+2, x+2, y-2, black) + } +} + +// Layout はスクリーンサイズを返す +func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { + return screenW, screenH +} diff --git a/games/hexbobble/main.go b/games/hexbobble/main.go new file mode 100644 index 0000000..e5e517c --- /dev/null +++ b/games/hexbobble/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + + "github.com/sago35/koebiten" + "github.com/sago35/koebiten/games/hexbobble/hexbobble" + "github.com/sago35/koebiten/hardware" +) + +func main() { + koebiten.SetHardware(hardware.Device) + koebiten.SetWindowSize(128, 64) + koebiten.SetWindowTitle("Hex Bobble") + + game := hexbobble.NewGame() + + if err := koebiten.RunGame(game); err != nil { + log.Fatal(err) + } +} From 051ef5e7fe0ffe856d675f4b65fe91ea6f0c91e6 Mon Sep 17 00:00:00 2001 From: sago35 Date: Sat, 4 Jul 2026 14:58:49 +0900 Subject: [PATCH 2/5] update --- games/hexbobble/hexbobble/game.go | 387 +++++++++++++++++++++++------- 1 file changed, 294 insertions(+), 93 deletions(-) diff --git a/games/hexbobble/hexbobble/game.go b/games/hexbobble/hexbobble/game.go index d9d86f4..0f1a075 100644 --- a/games/hexbobble/hexbobble/game.go +++ b/games/hexbobble/hexbobble/game.go @@ -21,7 +21,7 @@ const ( maxRows = 8 // 偶数列の行数(奇数列は 7) gameOverCol = 15 // この列以降に球が固定されたらゲームオーバー - numTypes = 6 + numTypes = 5 colPitch = 7 // 列の x 間隔 rowPitch = 8 // 行の y 間隔(奇数列は +4 オフセット) @@ -32,6 +32,20 @@ const ( angleMax = 72 // 発射角の上限(度) speed = 3.0 // 球速(px/フレーム) + + reloadFrames = 8 // 次弾装填アニメーションの長さ(フレーム数) + + cardinalFastFrames = 6 // 上下キーがこのフレーム数以上押され続けたら粗調整(±2)に切り替わる +) + +// せり上がりパラメータ。一定間隔で右から新しい球が 1 列ぶんせり出し、 +// 既存の球を左へ押し出す。せり上がるたびに間隔が riseIntervalStep ずつ +// 短くなり(riseIntervalMin が下限)、徐々にテンポが上がっていく。 +var ( + riseIntervalInit = 300 // 最初のせり上がり間隔(フレーム数。32ms/フレームなので約 9.6 秒) + riseIntervalMin = 90 // せり上がり間隔の下限(フレーム数。約 2.9 秒) + riseIntervalStep = 10 // せり上がりが起きるたびに間隔を短縮するフレーム数 + riseFillPercent = 60 // せり出す新しい列の各セルが埋まる確率(%)。100 未満なので列が全部埋まるとは限らない ) var ( @@ -45,11 +59,18 @@ type Game struct { cur, next int8 // 現在装填中の球と次の球 + reloadFrame int // 装填アニメーションの経過フレーム。0 ならアニメーション中でない + reloadBall int8 // アニメーション中に next 位置から cur 位置へ動く球の種類 + flying bool fx, fy float32 fvx, fvy float32 ftype int8 + riseTimer int // 次のせり上がりまでのフレーム数 + riseInterval int // 現在のせり上がり間隔(フレーム数。徐々に短くなる) + pushCount int // これまでのせり上がり(1 列押し出し)回数。列の偶奇の基準をずらすために使う + // マッチ消去エフェクト(拡大するリング) pops [maxCols * maxRows]popFx nPop int @@ -57,8 +78,9 @@ type Game struct { falls [maxCols * maxRows]fallFx nFall int - score int - scene string + score int + highScore int // reset() では初期化しない(電源を入れ直すまでプレイ間で保持される) + scene string } type popFx struct { @@ -85,25 +107,38 @@ func (g *Game) reset() { g.grid[c][r] = -1 } } + g.pushCount = 0 // 初期配置: 右端 4 列を埋める for c := 0; c < 4; c++ { - for r := 0; r < rowsInCol(c); r++ { + for r := 0; r < g.rowsInCol(c); r++ { g.grid[c][r] = int8(rand.N(numTypes)) } } g.angle = 0 g.cur = int8(rand.N(numTypes)) g.next = int8(rand.N(numTypes)) + g.reloadFrame = 0 g.flying = false g.nPop = 0 g.nFall = 0 + g.riseInterval = riseIntervalInit + g.riseTimer = g.riseInterval g.score = 0 } // --- ヘックスグリッド --- - -func rowsInCol(c int) int { - if c%2 == 0 { +// +// 列 c の行数・y オフセットは (pushCount + c) の偶奇で決まる(pushCount は +// これまでのせり上がり回数)。せり上がりで列の中身を 1 列分シフトすると +// 同時に pushCount が 1 増えるため、あるボールについて「シフト後の列番号 + +// シフト後の pushCount」は常に元の値と偶奇が一致する(差が常に 2 になるた +// め)。つまりボール 1 個 1 個の書式(8 行 or 7 行、オフセット)はせり上がり +// を跨いでも変化しない。列の物理的な位置(生成された瞬間の偶奇)と結び付け +// ず、pushCount 込みで毎回計算し直すことで、1 列ずつの押し出しでも書式の +// 不整合(行が消える・Y 座標が飛ぶ)が起きない。 + +func (g *Game) rowsInCol(c int) int { + if (g.pushCount+c)%2 == 0 { return maxRows } return maxRows - 1 @@ -113,30 +148,30 @@ func colX(c int) int { return screenW - 4 - colPitch*c } -func rowY(c, r int) int { - if c%2 == 0 { +func (g *Game) rowY(c, r int) int { + if (g.pushCount+c)%2 == 0 { return 4 + rowPitch*r } return 8 + rowPitch*r } -func validCell(c, r int) bool { - return 0 <= c && c < maxCols && 0 <= r && r < rowsInCol(c) +func (g *Game) validCell(c, r int) bool { + return 0 <= c && c < maxCols && 0 <= r && r < g.rowsInCol(c) } // neighbors は (c, r) に隣接するヘックスセルを返す(最大 6 個) -func neighbors(c, r int) ([6][2]int, int) { +func (g *Game) neighbors(c, r int) ([6][2]int, int) { var nb [6][2]int n := 0 add := func(nc, nr int) { - if validCell(nc, nr) { + if g.validCell(nc, nr) { nb[n] = [2]int{nc, nr} n++ } } add(c, r-1) add(c, r+1) - if c%2 == 0 { + if (g.pushCount+c)%2 == 0 { add(c-1, r-1) add(c-1, r) add(c+1, r-1) @@ -162,7 +197,14 @@ func (g *Game) Update() error { case "game": g.updateEffects() g.updateGame() - case "gameover", "clear": + case "gameover": + g.updateEffects() + // 球が落ち切るまではキー入力を無視する。直後にボタンを押して + // スコアが見えなくなるのを防ぐため。 + if g.nFall == 0 && isFireJustPressed() { + g.scene = "title" + } + case "clear": g.updateEffects() if isFireJustPressed() { g.scene = "title" @@ -172,18 +214,66 @@ func (g *Game) Update() error { } func (g *Game) updateGame() { - // 照準(十字キーは押しっぱなしでゆっくり回り、ロータリーは 1 ノッチ 2° の微調整) - if koebiten.IsKeyPressed(koebiten.KeyUp) { - g.angle-- + // 発射してから固定されるまではせり上がりを止める + if !g.flying { + g.updateRise() + } + if g.scene != "game" { + return } - if koebiten.IsKeyPressed(koebiten.KeyDown) { + + // 装填アニメーションの進行。次弾が撃てるようになるより前に必ず終わらせる + // (着弾が早い場合に備えて、飛翔が終わったら強制的に完了させる)。 + if g.reloadFrame > 0 { + g.reloadFrame++ + if g.reloadFrame > reloadFrames || !g.flying { + g.cur = g.reloadBall + g.reloadFrame = 0 + } + } + + // 照準: 上下単体は押し始め(cardinalFastFrames フレーム未満)は微調整 + // ±1、それ以上押し続けると粗調整 ±2 に切り替わる(コンコンと短く叩けば + // 微調整、押しっぱなしにすると速く動く)。左を同時押しした斜め(左斜め + // 上/左斜め下)は速度が変わらず毎フレーム ±1 の微調整。右(押した瞬間) + // でセンター(0°)に戻す。ロータリーは 1 ノッチ ±1 の微調整。 + upDur := koebiten.KeyPressDuration(koebiten.KeyUp) + downDur := koebiten.KeyPressDuration(koebiten.KeyDown) + up := upDur > 0 + down := downDur > 0 + left := koebiten.IsKeyPressed(koebiten.KeyLeft) + switch { + case up && left: + g.angle-- + case down && left: g.angle++ + case up: + if upDur >= cardinalFastFrames { + g.angle -= 2 + } else { + g.angle-- + } + case down: + if downDur >= cardinalFastFrames { + g.angle += 2 + } else { + g.angle++ + } + } + if koebiten.IsKeyJustPressed(koebiten.KeyRight) { + g.angle = 0 + } + // 左キー単体(上下との同時押しでない)は一列詰める(せり上がりを手動で + // 前倒しする)。ゲームテンポを自分で速められる。飛翔中は着弾の判定と + // 盤面がずれてしまうため無効。 + if !g.flying && !up && !down && koebiten.IsKeyJustPressed(koebiten.KeyLeft) { + g.performRise() } if koebiten.IsKeyJustPressed(koebiten.KeyRotaryLeft) { - g.angle -= 2 + g.angle-- } if koebiten.IsKeyJustPressed(koebiten.KeyRotaryRight) { - g.angle += 2 + g.angle++ } if g.angle < -angleMax { g.angle = -angleMax @@ -202,8 +292,10 @@ func (g *Game) updateGame() { g.ftype = g.cur g.flying = true - // 発射直後に次弾を装填する - g.cur = g.next + // 発射直後に次弾の装填アニメーションを開始する(cur は + // アニメーション完了時に更新される) + g.reloadBall = g.next + g.reloadFrame = 1 g.next = int8(rand.N(numTypes)) } @@ -231,6 +323,64 @@ func (g *Game) updateGame() { } } +// updateRise はタイマーを消化し、0 になったらせり上がりを実行する +func (g *Game) updateRise() { + if g.riseTimer > 0 { + g.riseTimer-- + return + } + g.performRise() +} + +// performRise はせり上がりを 1 回実行し、次回に向けて間隔を短縮してタイマー +// をリセットする。自動(タイマー満了)・手動(左キー)の両方から呼ばれる。 +func (g *Game) performRise() { + g.doRise() + if g.scene != "game" { + return // せり上がりでゲームオーバーになった + } + + g.riseInterval -= riseIntervalStep + if g.riseInterval < riseIntervalMin { + g.riseInterval = riseIntervalMin + } + g.riseTimer = g.riseInterval +} + +// doRise は右から新しい球を 1 列ぶんせり出させ、既存の球を左へ押し出す。 +// 列の中身を 1 つ隣の列インデックスへずらし、pushCount を 1 増やす。 +// rowsInCol/rowY が pushCount を織り込んで書式を決めるため、シフトされた +// 側のボールの書式(行数・Y オフセット)は変化しない([[ヘックスグリッド]] +// の説明を参照)。 +func (g *Game) doRise() { + for c := maxCols - 1; c >= 1; c-- { + g.grid[c] = g.grid[c-1] + } + g.pushCount++ + + // 列 0 の書式(有効な行数)はせり上がりのたびに偶奇が入れ替わる。 + // 無効な行を -1 のままにしておかないと、2 回後のせり上がりで + // 書式が元に戻った際に前回以前の古いデータが復活してしまうため、 + // maxRows 全体を必ず塗り直す。 + for r := 0; r < maxRows; r++ { + if r < g.rowsInCol(0) && rand.N(100) < riseFillPercent { + g.grid[0][r] = int8(rand.N(numTypes)) + } else { + g.grid[0][r] = -1 + } + } + + // ゲームオーバー判定(せり出しで押し出された球が危険域に達した場合) + for c := gameOverCol; c < maxCols; c++ { + for r := 0; r < g.rowsInCol(c); r++ { + if g.grid[c][r] >= 0 { + g.triggerGameOver() + return + } + } + } +} + // hitAt は座標 (x, y) が右壁または既存の球に接触しているかを判定する func (g *Game) hitAt(x, y float32) bool { if x >= float32(colX(0)) { @@ -241,11 +391,11 @@ func (g *Game) hitAt(x, y float32) bool { if dx < -7 || 7 < dx { continue } - for r := 0; r < rowsInCol(c); r++ { + for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] < 0 { continue } - dy := y - float32(rowY(c, r)) + dy := y - float32(g.rowY(c, r)) if dx*dx+dy*dy < 42 { // (直径-0.5)^2 ≈ 6.5^2 return true } @@ -261,7 +411,7 @@ func (g *Game) land() { bestC, bestR := -1, -1 bestD := float32(150) // 約 12px 以内のみ候補 for c := 0; c < maxCols; c++ { - for r := 0; r < rowsInCol(c); r++ { + for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { continue } @@ -269,7 +419,7 @@ func (g *Game) land() { continue // 右壁(col 0)か既存の球に接するセルのみ } dx := g.fx - float32(colX(c)) - dy := g.fy - float32(rowY(c, r)) + dy := g.fy - float32(g.rowY(c, r)) d := dx*dx + dy*dy if d < bestD { bestD = d @@ -279,34 +429,73 @@ func (g *Game) land() { } if bestC < 0 { - g.scene = "gameover" + g.triggerGameOver() return } g.grid[bestC][bestR] = g.ftype - g.popMatches(bestC, bestR) - g.removeOrphans() + + // 消去判定はあくまで自分が撃った球によるマッチが起点。マッチ消去そのもの + // で新たに壁と非連結(浮いた球)になった球だけを道連れにする。マッチ前 + // から既に壁と非連結だった球(せり上がりが原因で浮いていたもの)は対象 + // 外とし、そのまま浮かせておく。 + reachBefore := g.reachableFromWall() + if g.popMatches(bestC, bestR) > 0 { + reachAfter := g.reachableFromWall() + for c := 0; c < maxCols; c++ { + for r := 0; r < g.rowsInCol(c); r++ { + if g.grid[c][r] >= 0 && reachBefore[c][r] && !reachAfter[c][r] { + g.addFall(colX(c), g.rowY(c, r), g.grid[c][r]) + g.grid[c][r] = -1 + g.score += 2 + } + } + } + } // 全消しでゲームクリア if g.isBoardEmpty() { + g.updateHighScore() g.scene = "clear" return } // ゲームオーバー判定 for c := gameOverCol; c < maxCols; c++ { - for r := 0; r < rowsInCol(c); r++ { + for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { - g.scene = "gameover" + g.triggerGameOver() return } } } } +// triggerGameOver はゲームオーバーへ遷移し、盤面上の全ての球を落下エフェクト +// に変える。球が落ち切るまでは Update() 側でキー入力を無視するため、直後に +// ボタンを押してもスコアが見えなくなることはない。 +func (g *Game) triggerGameOver() { + g.scene = "gameover" + for c := 0; c < maxCols; c++ { + for r := 0; r < g.rowsInCol(c); r++ { + if g.grid[c][r] >= 0 { + g.addFall(colX(c), g.rowY(c, r), g.grid[c][r]) + g.grid[c][r] = -1 + } + } + } + g.updateHighScore() +} + +func (g *Game) updateHighScore() { + if g.score > g.highScore { + g.highScore = g.score + } +} + func (g *Game) isBoardEmpty() bool { for c := 0; c < maxCols; c++ { - for r := 0; r < rowsInCol(c); r++ { + for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { return false } @@ -315,8 +504,38 @@ func (g *Game) isBoardEmpty() bool { return true } +// reachableFromWall は現在の盤面において右壁(col 0)から連結している +// セルを BFS で求める +func (g *Game) reachableFromWall() [maxCols][maxRows]bool { + var reach [maxCols][maxRows]bool + var stack [maxCols * maxRows][2]int8 + sp := 0 + + for r := 0; r < g.rowsInCol(0); r++ { + if g.grid[0][r] >= 0 { + reach[0][r] = true + stack[sp] = [2]int8{0, int8(r)} + sp++ + } + } + for sp > 0 { + sp-- + cur := stack[sp] + nb, n := g.neighbors(int(cur[0]), int(cur[1])) + for i := 0; i < n; i++ { + nc, nr := nb[i][0], nb[i][1] + if !reach[nc][nr] && g.grid[nc][nr] >= 0 { + reach[nc][nr] = true + stack[sp] = [2]int8{int8(nc), int8(nr)} + sp++ + } + } + } + return reach +} + func (g *Game) hasOccupiedNeighbor(c, r int) bool { - nb, n := neighbors(c, r) + nb, n := g.neighbors(c, r) for i := 0; i < n; i++ { if g.grid[nb[i][0]][nb[i][1]] >= 0 { return true @@ -325,8 +544,8 @@ func (g *Game) hasOccupiedNeighbor(c, r int) bool { return false } -// popMatches は (c, r) と同種でつながる球が 3 つ以上なら消す -func (g *Game) popMatches(c, r int) { +// popMatches は (c, r) と同種でつながる球が 3 つ以上なら消し、消した数を返す +func (g *Game) popMatches(c, r int) int { t := g.grid[c][r] var visited [maxCols][maxRows]bool var stack, found [maxCols * maxRows][2]int8 @@ -340,7 +559,7 @@ func (g *Game) popMatches(c, r int) { cur := stack[sp] found[fp] = cur fp++ - nb, n := neighbors(int(cur[0]), int(cur[1])) + nb, n := g.neighbors(int(cur[0]), int(cur[1])) for i := 0; i < n; i++ { nc, nr := nb[i][0], nb[i][1] if !visited[nc][nr] && g.grid[nc][nr] == t { @@ -352,54 +571,15 @@ func (g *Game) popMatches(c, r int) { } if fp < 3 { - return + return 0 } for i := 0; i < fp; i++ { c, r := int(found[i][0]), int(found[i][1]) g.grid[c][r] = -1 - g.addPop(colX(c), rowY(c, r)) - } - g.score += fp * 10 -} - -// removeOrphans は右壁(col 0)につながっていない球を落とす -func (g *Game) removeOrphans() { - var reach [maxCols][maxRows]bool - var stack [maxCols * maxRows][2]int8 - sp := 0 - - for r := 0; r < rowsInCol(0); r++ { - if g.grid[0][r] >= 0 { - reach[0][r] = true - stack[sp] = [2]int8{0, int8(r)} - sp++ - } - } - for sp > 0 { - sp-- - cur := stack[sp] - nb, n := neighbors(int(cur[0]), int(cur[1])) - for i := 0; i < n; i++ { - nc, nr := nb[i][0], nb[i][1] - if !reach[nc][nr] && g.grid[nc][nr] >= 0 { - reach[nc][nr] = true - stack[sp] = [2]int8{int8(nc), int8(nr)} - sp++ - } - } - } - - removed := 0 - for c := 0; c < maxCols; c++ { - for r := 0; r < rowsInCol(c); r++ { - if g.grid[c][r] >= 0 && !reach[c][r] { - g.addFall(colX(c), rowY(c, r), g.grid[c][r]) - g.grid[c][r] = -1 - removed++ - } - } + g.addPop(colX(c), g.rowY(c, r)) } - g.score += removed * 20 + g.score += fp + return fp } // --- エフェクト --- @@ -459,7 +639,7 @@ func isFireJustPressed() bool { keys := koebiten.AppendJustPressedKeys(nil) for _, k := range keys { switch k { - case koebiten.KeyUp, koebiten.KeyDown, + case koebiten.KeyUp, koebiten.KeyDown, koebiten.KeyLeft, koebiten.KeyRight, koebiten.KeyRotaryLeft, koebiten.KeyRotaryRight: // 照準操作は発射しない default: @@ -478,10 +658,9 @@ func (g *Game) Draw(screen *koebiten.Image) { case "game": g.drawGame(screen) case "gameover": - g.drawBoard(screen) g.drawEffects(screen) koebiten.Println("Game Over") - koebiten.Println("Score", g.score) + koebiten.Println(g.score) case "clear": g.drawClear(screen) g.drawEffects(screen) @@ -490,7 +669,7 @@ func (g *Game) Draw(screen *koebiten.Image) { func (g *Game) drawClear(screen *koebiten.Image) { koebiten.Println("Game Clear!") - koebiten.Println("Score", g.score) + koebiten.Println(g.score) for t := 0; t < numTypes; t++ { drawBall(screen, 16+t*12, 40, int8(t)) drawBall(screen, 22+t*12, 50, int8((t+3)%numTypes)) @@ -499,6 +678,7 @@ func (g *Game) drawClear(screen *koebiten.Image) { func (g *Game) drawTitle(screen *koebiten.Image) { koebiten.Println("HEX BOBBLE") + koebiten.Println("High Score", g.highScore) koebiten.Println("press any key") for t := 0; t < numTypes; t++ { drawBall(screen, 16+t*12, 40, int8(t)) @@ -508,10 +688,21 @@ func (g *Game) drawTitle(screen *koebiten.Image) { func (g *Game) drawGame(screen *koebiten.Image) { g.drawBoard(screen) g.drawEffects(screen) + g.drawRiseTimer(screen) // 照準線と軌道ガイド、装填中の球 g.drawAimGuide(screen) - drawBall(screen, shooterX, shooterY, g.cur) + if g.reloadFrame > 0 { + // 装填アニメーション: next だった球が下から上へスライドしてくる + t := float32(g.reloadFrame) / float32(reloadFrames) + if t > 1 { + t = 1 + } + y := shooterY + int(20*(1-t)) + drawBall(screen, shooterX, y, g.reloadBall) + } else { + drawBall(screen, shooterX, shooterY, g.cur) + } // 次の球 drawBall(screen, shooterX, shooterY+20, g.next) @@ -521,7 +712,7 @@ func (g *Game) drawGame(screen *koebiten.Image) { drawBall(screen, int(g.fx), int(g.fy), g.ftype) } - koebiten.Println("Score", g.score) + koebiten.Println(g.score) } // drawAimGuide は照準線(球から少し離す)と、反射を含む軌道ガイドの点線を描く @@ -567,14 +758,27 @@ func (g *Game) drawBoard(screen *koebiten.Image) { koebiten.DrawLine(screen, screenW-1, 0, screenW-1, screenH-1, black) for c := 0; c < maxCols; c++ { - for r := 0; r < rowsInCol(c); r++ { + for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { - drawBall(screen, colX(c), rowY(c, r), g.grid[c][r]) + drawBall(screen, colX(c), g.rowY(c, r), g.grid[c][r]) } } } } +// drawRiseTimer は次のせり上がりまでの残り時間を、画面一番左の 1 ピクセル列 +// (x=0)に縦棒で表示する。せり上がり(自動でタイマーが 0 になる、または +// 左キーで手動発生)のたびにタイマーがリセットされ、棒は画面いっぱいに戻る。 +func (g *Game) drawRiseTimer(screen *koebiten.Image) { + if g.riseInterval <= 0 { + return + } + h := g.riseTimer * screenH / g.riseInterval + if h > 0 { + koebiten.DrawLine(screen, 0, 0, 0, h-1, black) + } +} + // drawBall は球の種類ごとに異なる模様で描く(モノクロのため模様で区別) func drawBall(screen *koebiten.Image, x, y int, t int8) { switch t { @@ -585,13 +789,10 @@ func drawBall(screen *koebiten.Image, x, y int, t int8) { case 2: // 輪郭+中央ドット koebiten.DrawCircle(screen, x, y, ballR, black) koebiten.DrawFilledRect(screen, x-1, y-1, 3, 3, black) - case 3: // 輪郭+横線 - koebiten.DrawCircle(screen, x, y, ballR, black) - koebiten.DrawLine(screen, x-2, y, x+2, y, black) - case 4: // 輪郭+縦線 + case 3: // 輪郭+縦線 koebiten.DrawCircle(screen, x, y, ballR, black) koebiten.DrawLine(screen, x, y-2, x, y+2, black) - case 5: // 輪郭+X + case 4: // 輪郭+X koebiten.DrawCircle(screen, x, y, ballR, black) koebiten.DrawLine(screen, x-2, y-2, x+2, y+2, black) koebiten.DrawLine(screen, x-2, y+2, x+2, y-2, black) From 65c28cac59892cf179870ee7ce718e3c10aa0656 Mon Sep 17 00:00:00 2001 From: sago35 Date: Sat, 4 Jul 2026 18:37:44 +0900 Subject: [PATCH 3/5] update --- games/hexbobble/hexbobble/game.go | 250 +++++++++++++++++++++--------- 1 file changed, 177 insertions(+), 73 deletions(-) diff --git a/games/hexbobble/hexbobble/game.go b/games/hexbobble/hexbobble/game.go index 0f1a075..87b55fc 100644 --- a/games/hexbobble/hexbobble/game.go +++ b/games/hexbobble/hexbobble/game.go @@ -6,6 +6,7 @@ import ( "github.com/sago35/koebiten" "tinygo.org/x/drivers/pixel" + "tinygo.org/x/tinyfont" ) // パズルボブルを 90 度回転させたゲーム。 @@ -36,18 +37,26 @@ const ( reloadFrames = 8 // 次弾装填アニメーションの長さ(フレーム数) cardinalFastFrames = 6 // 上下キーがこのフレーム数以上押され続けたら粗調整(±2)に切り替わる + + shakeThresholdPercent = 15 // せり上がりまでの残り時間がこの割合以下になったら固定球がプルプル震える + shakeUpdatePeriod = 3 // プルプルの位相をこのフレーム数ごとにしか更新しない(控えめにするため) + + allClearMessageFrames = 90 // 全消しメッセージを表示し、入力を止めておくフレーム数 ) // せり上がりパラメータ。一定間隔で右から新しい球が 1 列ぶんせり出し、 // 既存の球を左へ押し出す。せり上がるたびに間隔が riseIntervalStep ずつ // 短くなり(riseIntervalMin が下限)、徐々にテンポが上がっていく。 var ( - riseIntervalInit = 300 // 最初のせり上がり間隔(フレーム数。32ms/フレームなので約 9.6 秒) - riseIntervalMin = 90 // せり上がり間隔の下限(フレーム数。約 2.9 秒) + riseIntervalInit = 600 // 最初のせり上がり間隔(フレーム数。32ms/フレームなので約 19.2 秒) + riseIntervalMin = 180 // せり上がり間隔の下限(フレーム数。約 5.8 秒) riseIntervalStep = 10 // せり上がりが起きるたびに間隔を短縮するフレーム数 riseFillPercent = 60 // せり出す新しい列の各セルが埋まる確率(%)。100 未満なので列が全部埋まるとは限らない ) +// allClearBonus は盤面を全消しした際に加算されるボーナス得点 +var allClearBonus = 200 + var ( white = pixel.NewMonochrome(0xFF, 0xFF, 0xFF) black = pixel.NewMonochrome(0x00, 0x00, 0x00) @@ -70,6 +79,10 @@ type Game struct { riseTimer int // 次のせり上がりまでのフレーム数 riseInterval int // 現在のせり上がり間隔(フレーム数。徐々に短くなる) pushCount int // これまでのせり上がり(1 列押し出し)回数。列の偶奇の基準をずらすために使う + tick int // プルプル演出のアニメーション位相用フレームカウンタ + + allClearTimer int // 全消しメッセージを表示中の残りフレーム数。0 なら通常状態 + allClearBonusLeft int // ボーナス得点のうち、まだスコアに加算していない残り // マッチ消去エフェクト(拡大するリング) pops [maxCols * maxRows]popFx @@ -81,6 +94,12 @@ type Game struct { score int highScore int // reset() では初期化しない(電源を入れ直すまでプレイ間で保持される) scene string + + // BFS 用のワークバッファ。ローカル変数にすると 288 バイトでスタック確保上限 + // (256 バイト)を超え、reachableFromWall/popMatches を呼ぶたびにヒープ確保 + // が発生してしまうため、フィールドとして使い回す(--print-allocs で確認済み)。 + bfsStack [maxCols * maxRows][2]int8 + popStack, popFound [maxCols * maxRows][2]int8 } type popFx struct { @@ -123,6 +142,9 @@ func (g *Game) reset() { g.nFall = 0 g.riseInterval = riseIntervalInit g.riseTimer = g.riseInterval + g.tick = 0 + g.allClearTimer = 0 + g.allClearBonusLeft = 0 g.score = 0 } @@ -159,28 +181,54 @@ func (g *Game) validCell(c, r int) bool { return 0 <= c && c < maxCols && 0 <= r && r < g.rowsInCol(c) } -// neighbors は (c, r) に隣接するヘックスセルを返す(最大 6 個) +// neighbors は (c, r) に隣接するヘックスセルを返す(最大 6 個)。 +// クロージャ経由の追加はゲーム中に毎回ヒープへエスケープしてしまうため、 +// 素朴な if の並びで書く(--print-allocs で確認済み)。 func (g *Game) neighbors(c, r int) ([6][2]int, int) { var nb [6][2]int n := 0 - add := func(nc, nr int) { - if g.validCell(nc, nr) { - nb[n] = [2]int{nc, nr} - n++ - } + if g.validCell(c, r-1) { + nb[n] = [2]int{c, r - 1} + n++ + } + if g.validCell(c, r+1) { + nb[n] = [2]int{c, r + 1} + n++ } - add(c, r-1) - add(c, r+1) if (g.pushCount+c)%2 == 0 { - add(c-1, r-1) - add(c-1, r) - add(c+1, r-1) - add(c+1, r) + if g.validCell(c-1, r-1) { + nb[n] = [2]int{c - 1, r - 1} + n++ + } + if g.validCell(c-1, r) { + nb[n] = [2]int{c - 1, r} + n++ + } + if g.validCell(c+1, r-1) { + nb[n] = [2]int{c + 1, r - 1} + n++ + } + if g.validCell(c+1, r) { + nb[n] = [2]int{c + 1, r} + n++ + } } else { - add(c-1, r) - add(c-1, r+1) - add(c+1, r) - add(c+1, r+1) + if g.validCell(c-1, r) { + nb[n] = [2]int{c - 1, r} + n++ + } + if g.validCell(c-1, r+1) { + nb[n] = [2]int{c - 1, r + 1} + n++ + } + if g.validCell(c+1, r) { + nb[n] = [2]int{c + 1, r} + n++ + } + if g.validCell(c+1, r+1) { + nb[n] = [2]int{c + 1, r + 1} + n++ + } } return nb, n } @@ -204,39 +252,20 @@ func (g *Game) Update() error { if g.nFall == 0 && isFireJustPressed() { g.scene = "title" } - case "clear": - g.updateEffects() - if isFireJustPressed() { - g.scene = "title" - } } return nil } func (g *Game) updateGame() { - // 発射してから固定されるまではせり上がりを止める - if !g.flying { - g.updateRise() - } - if g.scene != "game" { - return - } - - // 装填アニメーションの進行。次弾が撃てるようになるより前に必ず終わらせる - // (着弾が早い場合に備えて、飛翔が終わったら強制的に完了させる)。 - if g.reloadFrame > 0 { - g.reloadFrame++ - if g.reloadFrame > reloadFrames || !g.flying { - g.cur = g.reloadBall - g.reloadFrame = 0 - } - } + g.tick++ // 照準: 上下単体は押し始め(cardinalFastFrames フレーム未満)は微調整 // ±1、それ以上押し続けると粗調整 ±2 に切り替わる(コンコンと短く叩けば // 微調整、押しっぱなしにすると速く動く)。左を同時押しした斜め(左斜め // 上/左斜め下)は速度が変わらず毎フレーム ±1 の微調整。右(押した瞬間) // でセンター(0°)に戻す。ロータリーは 1 ノッチ ±1 の微調整。 + // 全消しメッセージ表示中も照準だけは変更できるようにするため、 + // 発射・せり上がりより先に(早期 return の前に)処理する。 upDur := koebiten.KeyPressDuration(koebiten.KeyUp) downDur := koebiten.KeyPressDuration(koebiten.KeyDown) up := upDur > 0 @@ -263,12 +292,6 @@ func (g *Game) updateGame() { if koebiten.IsKeyJustPressed(koebiten.KeyRight) { g.angle = 0 } - // 左キー単体(上下との同時押しでない)は一列詰める(せり上がりを手動で - // 前倒しする)。ゲームテンポを自分で速められる。飛翔中は着弾の判定と - // 盤面がずれてしまうため無効。 - if !g.flying && !up && !down && koebiten.IsKeyJustPressed(koebiten.KeyLeft) { - g.performRise() - } if koebiten.IsKeyJustPressed(koebiten.KeyRotaryLeft) { g.angle-- } @@ -282,6 +305,49 @@ func (g *Game) updateGame() { g.angle = angleMax } + // 全消しメッセージの表示中は照準以外の入力・せり上がりを止めておき、 + // 表示が終わったら 1 段詰めて(せり上がらせて)新しい球を供給する。 + // ボーナス得点は一括加算ではなく、残り表示フレーム数で均等に割った分だけ + // (端数は前倒しで)毎フレーム加算し、最終フレームでちょうど 0 になる + // ようにする(スコアがカウントアップしていくように見せるため)。 + if g.allClearTimer > 0 { + add := (g.allClearBonusLeft + g.allClearTimer - 1) / g.allClearTimer + g.score += add + g.allClearBonusLeft -= add + g.updateHighScore() + + g.allClearTimer-- + if g.allClearTimer == 0 { + g.performRise() + } + return + } + + // 発射してから固定されるまではせり上がりを止める + if !g.flying { + g.updateRise() + } + if g.scene != "game" { + return + } + + // 装填アニメーションの進行。次弾が撃てるようになるより前に必ず終わらせる + // (着弾が早い場合に備えて、飛翔が終わったら強制的に完了させる)。 + if g.reloadFrame > 0 { + g.reloadFrame++ + if g.reloadFrame > reloadFrames || !g.flying { + g.cur = g.reloadBall + g.reloadFrame = 0 + } + } + + // 左キー単体(上下との同時押しでない)は一列詰める(せり上がりを手動で + // 前倒しする)。ゲームテンポを自分で速められる。飛翔中は着弾の判定と + // 盤面がずれてしまうため無効。 + if !g.flying && !up && !down && koebiten.IsKeyJustPressed(koebiten.KeyLeft) { + g.performRise() + } + // 発射 if !g.flying && isFireJustPressed() { rad := float64(g.angle) * math.Pi / 180 @@ -435,16 +501,18 @@ func (g *Game) land() { g.grid[bestC][bestR] = g.ftype - // 消去判定はあくまで自分が撃った球によるマッチが起点。マッチ消去そのもの - // で新たに壁と非連結(浮いた球)になった球だけを道連れにする。マッチ前 - // から既に壁と非連結だった球(せり上がりが原因で浮いていたもの)は対象 - // 外とし、そのまま浮かせておく。 - reachBefore := g.reachableFromWall() + // 消去判定はあくまで自分が撃った球によるマッチが起点(せり上がりそのもの + // では何も消えない)。マッチが成立したら、その時点で壁(col 0)と非連結に + // なっている球を全て落とす。以前は「このマッチで新たに非連結になった球」 + // だけを対象にしていたが、それだと既にせり上がりで浮いていた塊の内部で + // マッチが起きても、その塊自体は元々壁と非連結なので永久に浮いたまま残っ + // てしまう不具合があった。マッチのたびに壁との連結を再判定することで、 + // 浮いた球がプレイを続けても解消されずに残り続ける状況を防ぐ。 if g.popMatches(bestC, bestR) > 0 { - reachAfter := g.reachableFromWall() + reach := g.reachableFromWall() for c := 0; c < maxCols; c++ { for r := 0; r < g.rowsInCol(c); r++ { - if g.grid[c][r] >= 0 && reachBefore[c][r] && !reachAfter[c][r] { + if g.grid[c][r] >= 0 && !reach[c][r] { g.addFall(colX(c), g.rowY(c, r), g.grid[c][r]) g.grid[c][r] = -1 g.score += 2 @@ -453,11 +521,12 @@ func (g *Game) land() { } } - // 全消しでゲームクリア + // 全消しでボーナス得点。スコアには即座に加算せず、メッセージを表示している + // 間に毎フレーム少しずつ加算していく(updateGame 側の allClearTimer 処理)。 + // 表示が終わったら 1 段詰めて新しい球を供給する。 if g.isBoardEmpty() { - g.updateHighScore() - g.scene = "clear" - return + g.allClearBonusLeft += allClearBonus + g.allClearTimer = allClearMessageFrames } // ゲームオーバー判定 @@ -508,7 +577,7 @@ func (g *Game) isBoardEmpty() bool { // セルを BFS で求める func (g *Game) reachableFromWall() [maxCols][maxRows]bool { var reach [maxCols][maxRows]bool - var stack [maxCols * maxRows][2]int8 + stack := &g.bfsStack sp := 0 for r := 0; r < g.rowsInCol(0); r++ { @@ -548,7 +617,7 @@ func (g *Game) hasOccupiedNeighbor(c, r int) bool { func (g *Game) popMatches(c, r int) int { t := g.grid[c][r] var visited [maxCols][maxRows]bool - var stack, found [maxCols * maxRows][2]int8 + stack, found := &g.popStack, &g.popFound sp, fp := 0, 0 visited[c][r] = true @@ -635,8 +704,13 @@ func (g *Game) drawEffects(screen *koebiten.Image) { } } +// fireKeysBuf は isFireJustPressed が毎フレーム呼ぶ AppendJustPressedKeys 用の +// 使い回しバッファ。nil を渡すとキーが押された瞬間に毎回ヒープ確保が走るため、 +// 外部で確保済みの配列を渡して確保を避ける。 +var fireKeysBuf [4]koebiten.Key + func isFireJustPressed() bool { - keys := koebiten.AppendJustPressedKeys(nil) + keys := koebiten.AppendJustPressedKeys(fireKeysBuf[:0]) for _, k := range keys { switch k { case koebiten.KeyUp, koebiten.KeyDown, koebiten.KeyLeft, koebiten.KeyRight, @@ -661,18 +735,6 @@ func (g *Game) Draw(screen *koebiten.Image) { g.drawEffects(screen) koebiten.Println("Game Over") koebiten.Println(g.score) - case "clear": - g.drawClear(screen) - g.drawEffects(screen) - } -} - -func (g *Game) drawClear(screen *koebiten.Image) { - koebiten.Println("Game Clear!") - koebiten.Println(g.score) - for t := 0; t < numTypes; t++ { - drawBall(screen, 16+t*12, 40, int8(t)) - drawBall(screen, 22+t*12, 50, int8((t+3)%numTypes)) } } @@ -712,9 +774,19 @@ func (g *Game) drawGame(screen *koebiten.Image) { drawBall(screen, int(g.fx), int(g.fy), g.ftype) } + if g.allClearTimer > 0 { + drawCenteredText(screen, "ALL CLEAR!") + } koebiten.Println(g.score) } +// drawCenteredText は画面中央に文字列を描画する +func drawCenteredText(screen *koebiten.Image, s string) { + w, _ := tinyfont.LineWidth(&tinyfont.Org01, s) + x := (screenW - int(w)) / 2 + koebiten.DrawText(screen, s, &tinyfont.Org01, int16(x), screenH/2+2, black) +} + // drawAimGuide は照準線(球から少し離す)と、反射を含む軌道ガイドの点線を描く func (g *Game) drawAimGuide(screen *koebiten.Image) { rad := float64(g.angle) * math.Pi / 180 @@ -757,15 +829,47 @@ func (g *Game) drawBoard(screen *koebiten.Image) { // 右壁 koebiten.DrawLine(screen, screenW-1, 0, screenW-1, screenH-1, black) + shake := g.shaking() + phase := g.tick / shakeUpdatePeriod for c := 0; c < maxCols; c++ { for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { - drawBall(screen, colX(c), g.rowY(c, r), g.grid[c][r]) + x, y := colX(c), g.rowY(c, r) + if shake { + x += shakeOffset(phase, c, r) + y += shakeOffset(phase+11, c, r) + } + drawBall(screen, x, y, g.grid[c][r]) } } } } +// shaking はせり上がりまでの残り時間が閾値以下かどうかを返す。true の間、 +// 固定された球をプルプル震わせて「もうすぐ詰める」ことを警告する。 +func (g *Game) shaking() bool { + if g.riseInterval <= 0 { + return false + } + return g.riseTimer*100/g.riseInterval <= shakeThresholdPercent +} + +// shakeOffset はプルプル演出用の -1/0/1 の疑似乱数オフセットを返す(控えめに +// なるよう半分以上は 0 になる)。math/rand を消費せず、seed(shakeUpdatePeriod +// フレームごとに変化)とセル座標から毎回計算するハッシュなので、球ごとに +// 震え方がずれて見える。 +func shakeOffset(seed, c, r int) int { + h := uint32(seed)*2654435761 + uint32(c)*40503 + uint32(r)*2246822519 + switch h % 4 { + case 2: + return -1 + case 3: + return 1 + default: + return 0 + } +} + // drawRiseTimer は次のせり上がりまでの残り時間を、画面一番左の 1 ピクセル列 // (x=0)に縦棒で表示する。せり上がり(自動でタイマーが 0 になる、または // 左キーで手動発生)のたびにタイマーがリセットされ、棒は画面いっぱいに戻る。 From 721aa8b33a8fafc3a9ce21c52bef242ecfee746f Mon Sep 17 00:00:00 2001 From: sago35 Date: Sat, 4 Jul 2026 22:13:31 +0900 Subject: [PATCH 4/5] JtoE --- games/hexbobble/hexbobble/game.go | 325 ++++++++++++++++-------------- 1 file changed, 169 insertions(+), 156 deletions(-) diff --git a/games/hexbobble/hexbobble/game.go b/games/hexbobble/hexbobble/game.go index 87b55fc..909f77e 100644 --- a/games/hexbobble/hexbobble/game.go +++ b/games/hexbobble/hexbobble/game.go @@ -9,52 +9,54 @@ import ( "tinygo.org/x/tinyfont" ) -// パズルボブルを 90 度回転させたゲーム。 -// 左の砲台から球を発射し、球は上下の壁で反射しながら右へ進む。 -// 右壁または既存の球に当たると、不可視のヘックスグリッドに吸着する。 -// 同種の球が 3 つ以上つながると消える。 +// A game like Puzzle Bobble rotated 90 degrees. +// Balls are fired from the cannon on the left and travel right while bouncing +// off the top and bottom walls. When a ball hits the right wall or an existing +// ball, it snaps to an invisible hex grid. +// When 3 or more balls of the same type connect, they pop. const ( screenW = 128 screenH = 64 - maxCols = 18 // 右壁からの列数(col 0 が最も右) - maxRows = 8 // 偶数列の行数(奇数列は 7) - gameOverCol = 15 // この列以降に球が固定されたらゲームオーバー + maxCols = 18 // number of columns from the right wall (col 0 is rightmost) + maxRows = 8 // number of rows in even columns (odd columns have 7) + gameOverCol = 15 // game over if a ball is fixed at or past this column numTypes = 5 - colPitch = 7 // 列の x 間隔 - rowPitch = 8 // 行の y 間隔(奇数列は +4 オフセット) - ballR = 3 // 描画半径(直径 7) + colPitch = 7 // x spacing between columns + rowPitch = 8 // y spacing between rows (odd columns are offset by +4) + ballR = 3 // draw radius (diameter 7) shooterX = 8 shooterY = 32 - angleMax = 72 // 発射角の上限(度) + angleMax = 72 // maximum firing angle (degrees) - speed = 3.0 // 球速(px/フレーム) + speed = 3.0 // ball speed (px/frame) - reloadFrames = 8 // 次弾装填アニメーションの長さ(フレーム数) + reloadFrames = 8 // length of the next-ball reload animation (frames) - cardinalFastFrames = 6 // 上下キーがこのフレーム数以上押され続けたら粗調整(±2)に切り替わる + cardinalFastFrames = 6 // holding up/down starts fast repeat (±2/frame) once held this many frames; before that only the initial tap (±1) moves - shakeThresholdPercent = 15 // せり上がりまでの残り時間がこの割合以下になったら固定球がプルプル震える - shakeUpdatePeriod = 3 // プルプルの位相をこのフレーム数ごとにしか更新しない(控えめにするため) + shakeThresholdPercent = 15 // fixed balls shake when the time left until the rise drops to this percentage or below + shakeUpdatePeriod = 3 // update the shake phase only once every this many frames (to keep it subtle) - allClearMessageFrames = 90 // 全消しメッセージを表示し、入力を止めておくフレーム数 + allClearMessageFrames = 90 // frames to show the all-clear message and hold input ) -// せり上がりパラメータ。一定間隔で右から新しい球が 1 列ぶんせり出し、 -// 既存の球を左へ押し出す。せり上がるたびに間隔が riseIntervalStep ずつ -// 短くなり(riseIntervalMin が下限)、徐々にテンポが上がっていく。 +// Rise parameters. At a fixed interval, a new column of balls pushes in from +// the right and shoves the existing balls to the left. Each rise shortens the +// interval by riseIntervalStep (down to a floor of riseIntervalMin), so the +// tempo gradually speeds up. var ( - riseIntervalInit = 600 // 最初のせり上がり間隔(フレーム数。32ms/フレームなので約 19.2 秒) - riseIntervalMin = 180 // せり上がり間隔の下限(フレーム数。約 5.8 秒) - riseIntervalStep = 10 // せり上がりが起きるたびに間隔を短縮するフレーム数 - riseFillPercent = 60 // せり出す新しい列の各セルが埋まる確率(%)。100 未満なので列が全部埋まるとは限らない + riseIntervalInit = 600 // initial rise interval (frames; at 32ms/frame, about 19.2 seconds) + riseIntervalMin = 180 // lower bound of the rise interval (frames; about 5.8 seconds) + riseIntervalStep = 10 // frames to shorten the interval by on each rise + riseFillPercent = 60 // probability (%) that each cell of a new pushed-in column is filled; below 100, so a column is not always full ) -// allClearBonus は盤面を全消しした際に加算されるボーナス得点 +// allClearBonus is the bonus score added when the board is fully cleared. var allClearBonus = 200 var ( @@ -63,41 +65,41 @@ var ( ) type Game struct { - grid [maxCols][maxRows]int8 // 球の種類。-1 は空 - angle int // 発射角(度)。0 で真右、負で上向き + grid [maxCols][maxRows]int8 // ball type; -1 is empty + angle int // firing angle (degrees); 0 is straight right, negative is upward - cur, next int8 // 現在装填中の球と次の球 + cur, next int8 // currently loaded ball and the next ball - reloadFrame int // 装填アニメーションの経過フレーム。0 ならアニメーション中でない - reloadBall int8 // アニメーション中に next 位置から cur 位置へ動く球の種類 + reloadFrame int // elapsed frames of the reload animation; 0 means not animating + reloadBall int8 // type of the ball moving from the next position to the cur position during the animation flying bool fx, fy float32 fvx, fvy float32 ftype int8 - riseTimer int // 次のせり上がりまでのフレーム数 - riseInterval int // 現在のせり上がり間隔(フレーム数。徐々に短くなる) - pushCount int // これまでのせり上がり(1 列押し出し)回数。列の偶奇の基準をずらすために使う - tick int // プルプル演出のアニメーション位相用フレームカウンタ + riseTimer int // frames until the next rise + riseInterval int // current rise interval (frames; gradually shortens) + pushCount int // number of rises (single-column pushes) so far; used to shift the column parity baseline + tick int // frame counter for the shake animation phase - allClearTimer int // 全消しメッセージを表示中の残りフレーム数。0 なら通常状態 - allClearBonusLeft int // ボーナス得点のうち、まだスコアに加算していない残り + allClearTimer int // frames left while showing the all-clear message; 0 means normal state + allClearBonusLeft int // remaining bonus score not yet added to the score - // マッチ消去エフェクト(拡大するリング) + // match-pop effects (expanding rings) pops [maxCols * maxRows]popFx nPop int - // 孤立球の落下エフェクト + // falling effects for orphaned balls falls [maxCols * maxRows]fallFx nFall int score int - highScore int // reset() では初期化しない(電源を入れ直すまでプレイ間で保持される) + highScore int // not reset by reset() (kept across plays until the power is cycled) scene string - // BFS 用のワークバッファ。ローカル変数にすると 288 バイトでスタック確保上限 - // (256 バイト)を超え、reachableFromWall/popMatches を呼ぶたびにヒープ確保 - // が発生してしまうため、フィールドとして使い回す(--print-allocs で確認済み)。 + // Work buffers for BFS. As local variables they are 288 bytes, which exceeds + // the stack allocation limit (256 bytes) and causes a heap allocation on + // every call to reachableFromWall/popMatches, so reuse them as fields bfsStack [maxCols * maxRows][2]int8 popStack, popFound [maxCols * maxRows][2]int8 } @@ -127,7 +129,7 @@ func (g *Game) reset() { } } g.pushCount = 0 - // 初期配置: 右端 4 列を埋める + // initial layout: fill the rightmost 4 columns for c := 0; c < 4; c++ { for r := 0; r < g.rowsInCol(c); r++ { g.grid[c][r] = int8(rand.N(numTypes)) @@ -148,16 +150,18 @@ func (g *Game) reset() { g.score = 0 } -// --- ヘックスグリッド --- +// --- hex grid --- // -// 列 c の行数・y オフセットは (pushCount + c) の偶奇で決まる(pushCount は -// これまでのせり上がり回数)。せり上がりで列の中身を 1 列分シフトすると -// 同時に pushCount が 1 増えるため、あるボールについて「シフト後の列番号 + -// シフト後の pushCount」は常に元の値と偶奇が一致する(差が常に 2 になるた -// め)。つまりボール 1 個 1 個の書式(8 行 or 7 行、オフセット)はせり上がり -// を跨いでも変化しない。列の物理的な位置(生成された瞬間の偶奇)と結び付け -// ず、pushCount 込みで毎回計算し直すことで、1 列ずつの押し出しでも書式の -// 不整合(行が消える・Y 座標が飛ぶ)が起きない。 +// A column c's row count and y offset are determined by the parity of +// (pushCount + c), where pushCount is the number of rises so far. When a rise +// shifts a column's contents by one column, pushCount also increases by 1, so +// for any given ball "shifted column index + shifted pushCount" always has the +// same parity as the original value (the difference is always 2). In other +// words, each individual ball's format (8 rows or 7 rows, and offset) does not +// change across rises. By recomputing everything with pushCount included each +// time, rather than tying it to a column's physical position (the parity at the +// moment it was generated), single-column pushes never cause format +// inconsistencies (rows disappearing or Y coordinates jumping). func (g *Game) rowsInCol(c int) int { if (g.pushCount+c)%2 == 0 { @@ -181,9 +185,9 @@ func (g *Game) validCell(c, r int) bool { return 0 <= c && c < maxCols && 0 <= r && r < g.rowsInCol(c) } -// neighbors は (c, r) に隣接するヘックスセルを返す(最大 6 個)。 -// クロージャ経由の追加はゲーム中に毎回ヒープへエスケープしてしまうため、 -// 素朴な if の並びで書く(--print-allocs で確認済み)。 +// neighbors returns the hex cells adjacent to (c, r) (up to 6). +// Adding via a closure escapes to the heap on every call during the game, so it +// is written as a plain sequence of ifs. func (g *Game) neighbors(c, r int) ([6][2]int, int) { var nb [6][2]int n := 0 @@ -233,7 +237,7 @@ func (g *Game) neighbors(c, r int) ([6][2]int, int) { return nb, n } -// --- 更新 --- +// --- update --- func (g *Game) Update() error { switch g.scene { @@ -247,8 +251,8 @@ func (g *Game) Update() error { g.updateGame() case "gameover": g.updateEffects() - // 球が落ち切るまではキー入力を無視する。直後にボタンを押して - // スコアが見えなくなるのを防ぐため。 + // Ignore key input until the balls have finished falling. This prevents + // pressing a button right away and losing sight of the score. if g.nFall == 0 && isFireJustPressed() { g.scene = "title" } @@ -259,13 +263,15 @@ func (g *Game) Update() error { func (g *Game) updateGame() { g.tick++ - // 照準: 上下単体は押し始め(cardinalFastFrames フレーム未満)は微調整 - // ±1、それ以上押し続けると粗調整 ±2 に切り替わる(コンコンと短く叩けば - // 微調整、押しっぱなしにすると速く動く)。左を同時押しした斜め(左斜め - // 上/左斜め下)は速度が変わらず毎フレーム ±1 の微調整。右(押した瞬間) - // でセンター(0°)に戻す。ロータリーは 1 ノッチ ±1 の微調整。 - // 全消しメッセージ表示中も照準だけは変更できるようにするため、 - // 発射・せり上がりより先に(早期 return の前に)処理する。 + // Aim: a quick tap of up/down alone nudges the angle by exactly ±1 (only on + // the first frame of the press) for fine adjustment. Keep holding and, after + // a short dead zone (frames 2 up to cardinalFastFrames), it repeats at a + // coarse ±2 per frame for fast movement. So a tap fine-tunes and a hold moves + // quickly, with nothing in between. Diagonals with left held (upper-left/ + // lower-left) keep a fine ±1 per frame. Right (on press) recenters to 0°. + // The rotary is a fine ±1 per notch. + // Process this before firing/rise (before the early return) so that aiming + // alone can still be changed while the all-clear message is showing. upDur := koebiten.KeyPressDuration(koebiten.KeyUp) downDur := koebiten.KeyPressDuration(koebiten.KeyDown) up := upDur > 0 @@ -277,16 +283,16 @@ func (g *Game) updateGame() { case down && left: g.angle++ case up: - if upDur >= cardinalFastFrames { - g.angle -= 2 - } else { + if upDur == 1 { g.angle-- + } else if upDur >= cardinalFastFrames { + g.angle -= 2 } case down: - if downDur >= cardinalFastFrames { - g.angle += 2 - } else { + if downDur == 1 { g.angle++ + } else if downDur >= cardinalFastFrames { + g.angle += 2 } } if koebiten.IsKeyJustPressed(koebiten.KeyRight) { @@ -305,11 +311,12 @@ func (g *Game) updateGame() { g.angle = angleMax } - // 全消しメッセージの表示中は照準以外の入力・せり上がりを止めておき、 - // 表示が終わったら 1 段詰めて(せり上がらせて)新しい球を供給する。 - // ボーナス得点は一括加算ではなく、残り表示フレーム数で均等に割った分だけ - // (端数は前倒しで)毎フレーム加算し、最終フレームでちょうど 0 になる - // ようにする(スコアがカウントアップしていくように見せるため)。 + // While the all-clear message is showing, hold all input except aiming and + // the rise; when it finishes, push in one column (a rise) to supply new + // balls. The bonus score is not added all at once: each frame adds the + // remaining bonus divided evenly by the remaining frames (rounding up so the + // remainder is front-loaded), reaching exactly 0 on the final frame (to make + // the score appear to count up). if g.allClearTimer > 0 { add := (g.allClearBonusLeft + g.allClearTimer - 1) / g.allClearTimer g.score += add @@ -323,7 +330,7 @@ func (g *Game) updateGame() { return } - // 発射してから固定されるまではせり上がりを止める + // stop the rise from occurring between firing and the ball being fixed if !g.flying { g.updateRise() } @@ -331,8 +338,8 @@ func (g *Game) updateGame() { return } - // 装填アニメーションの進行。次弾が撃てるようになるより前に必ず終わらせる - // (着弾が早い場合に備えて、飛翔が終わったら強制的に完了させる)。 + // Advance the reload animation. Always finish it before the next ball can be + // fired (in case landing is quick, force it to complete once the flight ends). if g.reloadFrame > 0 { g.reloadFrame++ if g.reloadFrame > reloadFrames || !g.flying { @@ -341,14 +348,15 @@ func (g *Game) updateGame() { } } - // 左キー単体(上下との同時押しでない)は一列詰める(せり上がりを手動で - // 前倒しする)。ゲームテンポを自分で速められる。飛翔中は着弾の判定と - // 盤面がずれてしまうため無効。 + // Left alone (not combined with up/down) pushes in one column (manually + // triggering a rise early). This lets the player speed up the game tempo. + // Disabled while a ball is in flight, since it would misalign the landing + // check with the board. if !g.flying && !up && !down && koebiten.IsKeyJustPressed(koebiten.KeyLeft) { g.performRise() } - // 発射 + // fire if !g.flying && isFireJustPressed() { rad := float64(g.angle) * math.Pi / 180 g.fx = shooterX @@ -358,20 +366,20 @@ func (g *Game) updateGame() { g.ftype = g.cur g.flying = true - // 発射直後に次弾の装填アニメーションを開始する(cur は - // アニメーション完了時に更新される) + // start the next-ball reload animation right after firing (cur is + // updated when the animation completes) g.reloadBall = g.next g.reloadFrame = 1 g.next = int8(rand.N(numTypes)) } - // 飛翔(トンネル防止のため 1px 刻みのサブステップで進める) + // flight (advance in 1px sub-steps to prevent tunneling) if g.flying { for i := 0; i < 3; i++ { g.fx += g.fvx / 3 g.fy += g.fvy / 3 - // 上下の壁で反射 + // bounce off the top and bottom walls if g.fy < ballR+1 { g.fy = 2*(ballR+1) - g.fy g.fvy = -g.fvy @@ -389,7 +397,7 @@ func (g *Game) updateGame() { } } -// updateRise はタイマーを消化し、0 になったらせり上がりを実行する +// updateRise ticks down the timer and performs a rise when it reaches 0. func (g *Game) updateRise() { if g.riseTimer > 0 { g.riseTimer-- @@ -398,12 +406,13 @@ func (g *Game) updateRise() { g.performRise() } -// performRise はせり上がりを 1 回実行し、次回に向けて間隔を短縮してタイマー -// をリセットする。自動(タイマー満了)・手動(左キー)の両方から呼ばれる。 +// performRise executes one rise, then shortens the interval for next time and +// resets the timer. Called from both the automatic (timer expiry) and manual +// (left key) paths. func (g *Game) performRise() { g.doRise() if g.scene != "game" { - return // せり上がりでゲームオーバーになった + return // the rise caused a game over } g.riseInterval -= riseIntervalStep @@ -413,21 +422,20 @@ func (g *Game) performRise() { g.riseTimer = g.riseInterval } -// doRise は右から新しい球を 1 列ぶんせり出させ、既存の球を左へ押し出す。 -// 列の中身を 1 つ隣の列インデックスへずらし、pushCount を 1 増やす。 -// rowsInCol/rowY が pushCount を織り込んで書式を決めるため、シフトされた -// 側のボールの書式(行数・Y オフセット)は変化しない([[ヘックスグリッド]] -// の説明を参照)。 +// doRise pushes in a new column of balls from the right and shoves the existing +// balls to the left. It shifts each column's contents to the next column index +// and increments pushCount by 1. Because rowsInCol/rowY factor pushCount into +// the format, the format (row count, Y offset) of the shifted balls does not +// change (see the "hex grid" explanation). func (g *Game) doRise() { for c := maxCols - 1; c >= 1; c-- { g.grid[c] = g.grid[c-1] } g.pushCount++ - // 列 0 の書式(有効な行数)はせり上がりのたびに偶奇が入れ替わる。 - // 無効な行を -1 のままにしておかないと、2 回後のせり上がりで - // 書式が元に戻った際に前回以前の古いデータが復活してしまうため、 - // maxRows 全体を必ず塗り直す。 + // Column 0's format (its valid row count) flips parity on every rise. Unless + // invalid rows are left as -1, old data from before could resurface when the + // format flips back two rises later, so always repaint the entire maxRows. for r := 0; r < maxRows; r++ { if r < g.rowsInCol(0) && rand.N(100) < riseFillPercent { g.grid[0][r] = int8(rand.N(numTypes)) @@ -436,7 +444,7 @@ func (g *Game) doRise() { } } - // ゲームオーバー判定(せり出しで押し出された球が危険域に達した場合) + // game over check (if a ball pushed out by the rise reaches the danger zone) for c := gameOverCol; c < maxCols; c++ { for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { @@ -447,7 +455,7 @@ func (g *Game) doRise() { } } -// hitAt は座標 (x, y) が右壁または既存の球に接触しているかを判定する +// hitAt reports whether the coordinate (x, y) is touching the right wall or an existing ball. func (g *Game) hitAt(x, y float32) bool { if x >= float32(colX(0)) { return true @@ -462,7 +470,7 @@ func (g *Game) hitAt(x, y float32) bool { continue } dy := y - float32(g.rowY(c, r)) - if dx*dx+dy*dy < 42 { // (直径-0.5)^2 ≈ 6.5^2 + if dx*dx+dy*dy < 42 { // (diameter-0.5)^2 ≈ 6.5^2 return true } } @@ -470,19 +478,19 @@ func (g *Game) hitAt(x, y float32) bool { return false } -// land は飛翔中の球を最寄りの空きセルに吸着させる +// land snaps the ball in flight to the nearest empty cell. func (g *Game) land() { g.flying = false bestC, bestR := -1, -1 - bestD := float32(150) // 約 12px 以内のみ候補 + bestD := float32(150) // only cells within about 12px are candidates for c := 0; c < maxCols; c++ { for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { continue } if c != 0 && !g.hasOccupiedNeighbor(c, r) { - continue // 右壁(col 0)か既存の球に接するセルのみ + continue // only cells touching the right wall (col 0) or an existing ball } dx := g.fx - float32(colX(c)) dy := g.fy - float32(g.rowY(c, r)) @@ -501,13 +509,14 @@ func (g *Game) land() { g.grid[bestC][bestR] = g.ftype - // 消去判定はあくまで自分が撃った球によるマッチが起点(せり上がりそのもの - // では何も消えない)。マッチが成立したら、その時点で壁(col 0)と非連結に - // なっている球を全て落とす。以前は「このマッチで新たに非連結になった球」 - // だけを対象にしていたが、それだと既にせり上がりで浮いていた塊の内部で - // マッチが起きても、その塊自体は元々壁と非連結なので永久に浮いたまま残っ - // てしまう不具合があった。マッチのたびに壁との連結を再判定することで、 - // 浮いた球がプレイを続けても解消されずに残り続ける状況を防ぐ。 + // Pop detection is always triggered by a match from the player's own shot + // (the rise itself pops nothing). Once a match occurs, drop every ball that + // is disconnected from the wall (col 0) at that point. Previously only balls + // "newly disconnected by this match" were targeted, but with that approach a + // match occurring inside a cluster that was already floating from a rise + // would leave the cluster itself floating forever, since it was disconnected + // from the wall to begin with. Re-checking wall connectivity on every match + // prevents floating balls from lingering unresolved as play continues. if g.popMatches(bestC, bestR) > 0 { reach := g.reachableFromWall() for c := 0; c < maxCols; c++ { @@ -521,15 +530,16 @@ func (g *Game) land() { } } - // 全消しでボーナス得点。スコアには即座に加算せず、メッセージを表示している - // 間に毎フレーム少しずつ加算していく(updateGame 側の allClearTimer 処理)。 - // 表示が終わったら 1 段詰めて新しい球を供給する。 + // Bonus score on a full clear. It is not added to the score immediately; + // instead it is added a little each frame while the message is showing + // (handled by the allClearTimer logic in updateGame). When the message + // finishes, push in one column to supply new balls. if g.isBoardEmpty() { g.allClearBonusLeft += allClearBonus g.allClearTimer = allClearMessageFrames } - // ゲームオーバー判定 + // game over check for c := gameOverCol; c < maxCols; c++ { for r := 0; r < g.rowsInCol(c); r++ { if g.grid[c][r] >= 0 { @@ -540,9 +550,9 @@ func (g *Game) land() { } } -// triggerGameOver はゲームオーバーへ遷移し、盤面上の全ての球を落下エフェクト -// に変える。球が落ち切るまでは Update() 側でキー入力を無視するため、直後に -// ボタンを押してもスコアが見えなくなることはない。 +// triggerGameOver transitions to game over and turns every ball on the board +// into a falling effect. Update() ignores key input until the balls finish +// falling, so pressing a button right away won't hide the score. func (g *Game) triggerGameOver() { g.scene = "gameover" for c := 0; c < maxCols; c++ { @@ -573,8 +583,8 @@ func (g *Game) isBoardEmpty() bool { return true } -// reachableFromWall は現在の盤面において右壁(col 0)から連結している -// セルを BFS で求める +// reachableFromWall computes, via BFS, the cells connected to the right wall +// (col 0) in the current board. func (g *Game) reachableFromWall() [maxCols][maxRows]bool { var reach [maxCols][maxRows]bool stack := &g.bfsStack @@ -613,7 +623,8 @@ func (g *Game) hasOccupiedNeighbor(c, r int) bool { return false } -// popMatches は (c, r) と同種でつながる球が 3 つ以上なら消し、消した数を返す +// popMatches pops the group of same-type balls connected to (c, r) if it has 3 +// or more, and returns the number popped. func (g *Game) popMatches(c, r int) int { t := g.grid[c][r] var visited [maxCols][maxRows]bool @@ -651,7 +662,7 @@ func (g *Game) popMatches(c, r int) int { return fp } -// --- エフェクト --- +// --- effects --- func (g *Game) addPop(x, y int) { if g.nPop >= len(g.pops) { @@ -670,7 +681,7 @@ func (g *Game) addFall(x, y int, t int8) { } func (g *Game) updateEffects() { - // マッチ消去: リングを 1 フレームずつ拡大し、終わったら詰める + // match pop: expand the ring one frame at a time, then compact when done for i := 0; i < g.nPop; { g.pops[i].frame++ if g.pops[i].frame > 6 { @@ -680,7 +691,7 @@ func (g *Game) updateEffects() { i++ } } - // 孤立球: 重力で加速しながら落下し、画面外に出たら詰める + // orphaned balls: fall while accelerating under gravity, then compact when off-screen for i := 0; i < g.nFall; { g.falls[i].vy += 0.35 g.falls[i].y += g.falls[i].vy @@ -704,9 +715,9 @@ func (g *Game) drawEffects(screen *koebiten.Image) { } } -// fireKeysBuf は isFireJustPressed が毎フレーム呼ぶ AppendJustPressedKeys 用の -// 使い回しバッファ。nil を渡すとキーが押された瞬間に毎回ヒープ確保が走るため、 -// 外部で確保済みの配列を渡して確保を避ける。 +// fireKeysBuf is a reusable buffer for the AppendJustPressedKeys call that +// isFireJustPressed makes every frame. Passing nil would heap-allocate every +// time a key is pressed, so pass a pre-allocated array to avoid the allocation. var fireKeysBuf [4]koebiten.Key func isFireJustPressed() bool { @@ -715,7 +726,7 @@ func isFireJustPressed() bool { switch k { case koebiten.KeyUp, koebiten.KeyDown, koebiten.KeyLeft, koebiten.KeyRight, koebiten.KeyRotaryLeft, koebiten.KeyRotaryRight: - // 照準操作は発射しない + // aiming controls do not fire default: return true } @@ -723,7 +734,7 @@ func isFireJustPressed() bool { return false } -// --- 描画 --- +// --- drawing --- func (g *Game) Draw(screen *koebiten.Image) { switch g.scene { @@ -752,10 +763,10 @@ func (g *Game) drawGame(screen *koebiten.Image) { g.drawEffects(screen) g.drawRiseTimer(screen) - // 照準線と軌道ガイド、装填中の球 + // aim line, trajectory guide, and the loaded ball g.drawAimGuide(screen) if g.reloadFrame > 0 { - // 装填アニメーション: next だった球が下から上へスライドしてくる + // reload animation: the ball that was "next" slides up from below t := float32(g.reloadFrame) / float32(reloadFrames) if t > 1 { t = 1 @@ -766,10 +777,10 @@ func (g *Game) drawGame(screen *koebiten.Image) { drawBall(screen, shooterX, shooterY, g.cur) } - // 次の球 + // next ball drawBall(screen, shooterX, shooterY+20, g.next) - // 飛翔中の球 + // ball in flight if g.flying { drawBall(screen, int(g.fx), int(g.fy), g.ftype) } @@ -780,27 +791,27 @@ func (g *Game) drawGame(screen *koebiten.Image) { koebiten.Println(g.score) } -// drawCenteredText は画面中央に文字列を描画する +// drawCenteredText draws a string centered on the screen. func drawCenteredText(screen *koebiten.Image, s string) { w, _ := tinyfont.LineWidth(&tinyfont.Org01, s) x := (screenW - int(w)) / 2 koebiten.DrawText(screen, s, &tinyfont.Org01, int16(x), screenH/2+2, black) } -// drawAimGuide は照準線(球から少し離す)と、反射を含む軌道ガイドの点線を描く +// drawAimGuide draws the aim line (offset a little from the ball) and the dotted trajectory guide including bounces. func (g *Game) drawAimGuide(screen *koebiten.Image) { rad := float64(g.angle) * math.Pi / 180 dx := float32(math.Cos(rad)) dy := float32(math.Sin(rad)) - // 方向線: 装填中の球が見えるよう中心から 6px 離して描く + // direction line: drawn 6px away from the center so the loaded ball stays visible x1 := shooterX + int(6*dx) y1 := shooterY + int(6*dy) x2 := shooterX + int(13*dx) y2 := shooterY + int(13*dy) koebiten.DrawLine(screen, x1, y1, x2, y2, black) - // 軌道ガイド: 実際の飛翔と同じ反射計算で 2px 刻みに進め、5 点ごとに点を打つ + // trajectory guide: advance in 2px steps using the same bounce math as actual flight, plotting a dot every 5 points px := float32(shooterX) py := float32(shooterY) vx := dx * 2 @@ -826,7 +837,7 @@ func (g *Game) drawAimGuide(screen *koebiten.Image) { } func (g *Game) drawBoard(screen *koebiten.Image) { - // 右壁 + // right wall koebiten.DrawLine(screen, screenW-1, 0, screenW-1, screenH-1, black) shake := g.shaking() @@ -845,8 +856,8 @@ func (g *Game) drawBoard(screen *koebiten.Image) { } } -// shaking はせり上がりまでの残り時間が閾値以下かどうかを返す。true の間、 -// 固定された球をプルプル震わせて「もうすぐ詰める」ことを警告する。 +// shaking reports whether the time left until the rise is at or below the +// threshold. While true, fixed balls shake to warn that a rise is imminent. func (g *Game) shaking() bool { if g.riseInterval <= 0 { return false @@ -854,10 +865,11 @@ func (g *Game) shaking() bool { return g.riseTimer*100/g.riseInterval <= shakeThresholdPercent } -// shakeOffset はプルプル演出用の -1/0/1 の疑似乱数オフセットを返す(控えめに -// なるよう半分以上は 0 になる)。math/rand を消費せず、seed(shakeUpdatePeriod -// フレームごとに変化)とセル座標から毎回計算するハッシュなので、球ごとに -// 震え方がずれて見える。 +// shakeOffset returns a pseudo-random -1/0/1 offset for the shake effect (more +// than half the time it is 0, to keep it subtle). It does not consume +// math/rand; it is a hash computed each time from the seed (which changes every +// shakeUpdatePeriod frames) and the cell coordinates, so each ball appears to +// shake out of phase with the others. func shakeOffset(seed, c, r int) int { h := uint32(seed)*2654435761 + uint32(c)*40503 + uint32(r)*2246822519 switch h % 4 { @@ -870,9 +882,10 @@ func shakeOffset(seed, c, r int) int { } } -// drawRiseTimer は次のせり上がりまでの残り時間を、画面一番左の 1 ピクセル列 -// (x=0)に縦棒で表示する。せり上がり(自動でタイマーが 0 になる、または -// 左キーで手動発生)のたびにタイマーがリセットされ、棒は画面いっぱいに戻る。 +// drawRiseTimer shows the time left until the next rise as a vertical bar in +// the leftmost pixel column (x=0) of the screen. On every rise (the timer +// automatically reaching 0, or a manual trigger with the left key) the timer +// resets and the bar returns to full height. func (g *Game) drawRiseTimer(screen *koebiten.Image) { if g.riseInterval <= 0 { return @@ -883,27 +896,27 @@ func (g *Game) drawRiseTimer(screen *koebiten.Image) { } } -// drawBall は球の種類ごとに異なる模様で描く(モノクロのため模様で区別) +// drawBall draws each ball type with a different pattern (patterns distinguish types since the display is monochrome). func drawBall(screen *koebiten.Image, x, y int, t int8) { switch t { - case 0: // 塗りつぶし + case 0: // filled koebiten.DrawFilledCircle(screen, x, y, ballR, black) - case 1: // 輪郭のみ + case 1: // outline only koebiten.DrawCircle(screen, x, y, ballR, black) - case 2: // 輪郭+中央ドット + case 2: // outline + center dot koebiten.DrawCircle(screen, x, y, ballR, black) koebiten.DrawFilledRect(screen, x-1, y-1, 3, 3, black) - case 3: // 輪郭+縦線 + case 3: // outline + vertical line koebiten.DrawCircle(screen, x, y, ballR, black) koebiten.DrawLine(screen, x, y-2, x, y+2, black) - case 4: // 輪郭+X + case 4: // outline + X koebiten.DrawCircle(screen, x, y, ballR, black) koebiten.DrawLine(screen, x-2, y-2, x+2, y+2, black) koebiten.DrawLine(screen, x-2, y+2, x+2, y-2, black) } } -// Layout はスクリーンサイズを返す +// Layout returns the screen size. func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return screenW, screenH } From 1502d37d5937027440fd3b26cf347f174657b2b1 Mon Sep 17 00:00:00 2001 From: sago35 Date: Sat, 4 Jul 2026 22:49:37 +0900 Subject: [PATCH 5/5] update --- games/hexbobble/hexbobble/game.go | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/games/hexbobble/hexbobble/game.go b/games/hexbobble/hexbobble/game.go index 909f77e..4786386 100644 --- a/games/hexbobble/hexbobble/game.go +++ b/games/hexbobble/hexbobble/game.go @@ -444,6 +444,39 @@ func (g *Game) doRise() { } } + // Prevent complete floating islands. Before the rise every connected + // component of the board touches col 0 (an invariant maintained here and by + // the post-match sweep in land), so after the shift each component contains + // at least one cell in col 1. If the randomly generated col 0 happens to + // leave a component disconnected from the wall, add one connecting ball in + // col 0 next to a col-1 cell of that component. Without this, repeated rises + // with riseFillPercent < 100 can slowly grow a large fully-floating island + // that all falls at once when the player pops anything inside it. + for i := 0; i <= maxRows; i++ { + reach := g.reachableFromWall() + fixed := false + for r := 0; r < g.rowsInCol(1) && !fixed; r++ { + if g.grid[1][r] < 0 || reach[1][r] { + continue + } + // An unreachable col-1 cell always has an empty col-0 neighbor: + // if one were occupied it would be a wall anchor, making this + // cell reachable. + nb, n := g.neighbors(1, r) + for j := 0; j < n; j++ { + nc, nr := nb[j][0], nb[j][1] + if nc == 0 && g.grid[nc][nr] < 0 { + g.grid[nc][nr] = int8(rand.N(numTypes)) + fixed = true + break + } + } + } + if !fixed { + break // all components are connected to the wall + } + } + // game over check (if a ball pushed out by the rise reaches the danger zone) for c := gameOverCol; c < maxCols; c++ { for r := 0; r < g.rowsInCol(c); r++ {