Files
2026-04-27 16:09:03 +08:00

530 lines
16 KiB
Lua

local bm = require("board_manager")
local display = require("display")
local delay = require("delay")
local audio_ok, audio = pcall(require, "audio")
local lcd_touch_ok, lcd_touch = pcall(require, "lcd_touch")
local button_ok, button = pcall(require, "button")
local FRAME_MS = 33
local RUN_TIME_MS = 180000
local GRAVITY = 1.22
local FLAP_VELOCITY = -7.2
local PIPE_SPEED = 3.8
local PIPE_WIDTH = 34
local PIPE_GAP = 74
local PIPE_SPAWN_MS = 1500
local GROUND_HEIGHT = 28
local BIRD_RADIUS = 10
local BIRD_X_RATIO = 0.28
local CLOUD_COUNT = 4
local PIPE_MARGIN = 42
local PIPE_STEP = 10
local MAX_PIPE_SHIFT = 26
local SOUND_VOLUME = 90
local SKY_R, SKY_G, SKY_B = 138, 213, 255
local SUN_R, SUN_G, SUN_B = 255, 223, 120
local CLOUD_R, CLOUD_G, CLOUD_B = 248, 252, 255
local PIPE_R, PIPE_G, PIPE_B = 67, 166, 74
local PIPE_SHADE_R, PIPE_SHADE_G, PIPE_SHADE_B = 44, 118, 55
local PIPE_CAP_R, PIPE_CAP_G, PIPE_CAP_B = 102, 204, 96
local GROUND_R, GROUND_G, GROUND_B = 217, 182, 92
local DIRT_R, DIRT_G, DIRT_B = 158, 112, 56
local BIRD_R, BIRD_G, BIRD_B = 255, 229, 76
local BIRD_WING_R, BIRD_WING_G, BIRD_WING_B = 245, 182, 45
local BEAK_R, BEAK_G, BEAK_B = 255, 136, 58
local EYE_R, EYE_G, EYE_B = 36, 32, 32
local TEXT_R, TEXT_G, TEXT_B = 20, 36, 54
local PANEL_R, PANEL_G, PANEL_B = 248, 251, 255
local PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B = 98, 132, 168
local DANGER_R, DANGER_G, DANGER_B = 214, 74, 74
local input_mode = "none"
local touch_handle = nil
local button_handle = nil
local button_active_level = 0
local button_last_level = 1
local audio_output = nil
local panel_handle, io_handle, width, height, panel_if = bm.get_display_lcd_params("display_lcd")
if not panel_handle then
print("[lappybird] ERROR: get_display_lcd_params(display_lcd) failed: " .. tostring(io_handle))
return
end
local ok, err = pcall(display.init, panel_handle, io_handle, width, height, panel_if)
if not ok then
print("[lappybird] ERROR: init failed: " .. tostring(err))
return
end
local screen_created = true
local function cleanup()
if button_handle then
pcall(button.off, button_handle)
pcall(button.close, button_handle)
button_handle = nil
end
if audio_output then
pcall(audio.close, audio_output)
audio_output = nil
end
if screen_created then
pcall(display.end_frame)
pcall(display.deinit)
screen_created = false
end
end
width = display.width()
height = display.height()
if width <= 0 or height <= 0 then
print("[lappybird] ERROR: invalid display size after init")
cleanup()
return
end
local play_top = 0
local play_bottom = height - GROUND_HEIGHT
local play_height = play_bottom - play_top
local bird_x = math.floor(width * BIRD_X_RATIO)
local score = 0
local best_score = 0
local frame_count = 0
local state = "title"
local bird_y = math.floor(play_height * 0.45)
local bird_vy = 0
local spawn_timer_ms = 0
local pipes = {}
local cloud_offsets = {}
local touch_consumed = false
local last_gap_top = nil
math.randomseed(os.time() + width * 13 + height * 17)
for i = 1, CLOUD_COUNT do
cloud_offsets[i] = {
x = ((i - 1) * width) // CLOUD_COUNT + math.random(0, 24),
y = 12 + math.random(0, math.max(10, math.floor(play_height * 0.18))),
size = 12 + math.random(0, 10),
speed = 0.3 + math.random() * 0.4,
}
end
local function clamp(v, min_v, max_v)
if v < min_v then
return min_v
end
if v > max_v then
return max_v
end
return v
end
local function snap_step(v, step)
return (v // step) * step
end
local function new_pipe(x)
local min_gap_top = PIPE_MARGIN
local max_gap_top = play_bottom - PIPE_GAP - PIPE_MARGIN
local base_gap_top
if last_gap_top == nil then
local range = math.max(0, max_gap_top - min_gap_top)
base_gap_top = min_gap_top + math.random(0, range)
else
local shift = math.random(-MAX_PIPE_SHIFT, MAX_PIPE_SHIFT)
base_gap_top = last_gap_top + shift
end
local gap_top = clamp(base_gap_top, min_gap_top, max_gap_top)
gap_top = snap_step(gap_top, PIPE_STEP)
gap_top = clamp(gap_top, min_gap_top, max_gap_top)
last_gap_top = gap_top
return {
x = x,
gap_top = gap_top,
gap_bottom = gap_top + PIPE_GAP,
scored = false,
}
end
local function reset_round(next_state)
score = 0
bird_y = math.floor(play_height * 0.45)
bird_vy = 0
spawn_timer_ms = 0
last_gap_top = nil
pipes = {
new_pipe(width + 48),
new_pipe(width + 48 + math.floor(width * 0.56)),
}
state = next_state or "title"
end
local function flap()
bird_vy = FLAP_VELOCITY
if audio_output then
pcall(audio.play_tone, audio_output, 920, 35)
end
end
local function draw_cloud(x, y, size)
display.fill_circle(x, y, size, CLOUD_R, CLOUD_G, CLOUD_B)
display.fill_circle(x + size, y - 2, math.floor(size * 0.85), CLOUD_R, CLOUD_G, CLOUD_B)
display.fill_circle(x + size * 2 - 2, y, math.floor(size * 0.72), CLOUD_R, CLOUD_G, CLOUD_B)
display.fill_rect(x, y - math.floor(size * 0.5), size * 2, size, CLOUD_R, CLOUD_G, CLOUD_B)
end
local function draw_background()
display.clear(SKY_R, SKY_G, SKY_B)
display.fill_circle(width - 34, 28, 18, SUN_R, SUN_G, SUN_B)
for i = 1, CLOUD_COUNT do
local cloud = cloud_offsets[i]
local drift = math.floor((frame_count * cloud.speed + cloud.x) % (width + 56)) - 28
draw_cloud(drift, cloud.y, cloud.size)
end
display.fill_rect(0, play_bottom, width, GROUND_HEIGHT, GROUND_R, GROUND_G, GROUND_B)
display.fill_rect(0, play_bottom + GROUND_HEIGHT - 8, width, 8, DIRT_R, DIRT_G, DIRT_B)
local stripe_w = 14
for x = 0, width + stripe_w, stripe_w * 2 do
local offset = (frame_count * 2) % (stripe_w * 2)
display.fill_rect(x - offset, play_bottom, stripe_w, 6, 234, 208, 108)
end
end
local function draw_pipe(pipe)
local x = math.floor(pipe.x)
local top_h = pipe.gap_top
local bottom_y = pipe.gap_bottom
local bottom_h = play_bottom - bottom_y
display.fill_rect(x, 0, PIPE_WIDTH, top_h, PIPE_R, PIPE_G, PIPE_B)
display.fill_rect(x + PIPE_WIDTH - 7, 0, 7, top_h, PIPE_SHADE_R, PIPE_SHADE_G, PIPE_SHADE_B)
display.fill_rect(x - 2, top_h - 10, PIPE_WIDTH + 4, 10, PIPE_CAP_R, PIPE_CAP_G, PIPE_CAP_B)
display.fill_rect(x, bottom_y, PIPE_WIDTH, bottom_h, PIPE_R, PIPE_G, PIPE_B)
display.fill_rect(x + PIPE_WIDTH - 7, bottom_y, 7, bottom_h, PIPE_SHADE_R, PIPE_SHADE_G, PIPE_SHADE_B)
display.fill_rect(x - 2, bottom_y, PIPE_WIDTH + 4, 10, PIPE_CAP_R, PIPE_CAP_G, PIPE_CAP_B)
end
local function draw_bird()
local bx = bird_x
local by = math.floor(bird_y)
local tilt = math.max(-8, math.min(8, math.floor(bird_vy)))
local leg_y = by + BIRD_RADIUS + 2 + (tilt // 2)
display.fill_circle(bx, by, BIRD_RADIUS, BIRD_R, BIRD_G, BIRD_B)
display.fill_circle(bx - 2, by + 2, math.floor(BIRD_RADIUS * 0.65), BIRD_WING_R, BIRD_WING_G, BIRD_WING_B)
display.fill_triangle(
bx + BIRD_RADIUS - 1, by - 2,
bx + BIRD_RADIUS + 10, by + 1,
bx + BIRD_RADIUS - 1, by + 5,
BEAK_R, BEAK_G, BEAK_B
)
display.fill_circle(bx + 3, by - 3, 3, 255, 255, 255)
display.fill_circle(bx + 4, by - 3, 1, EYE_R, EYE_G, EYE_B)
display.draw_line(bx - 6, by + BIRD_RADIUS - 2, bx - 2, leg_y, EYE_R, EYE_G, EYE_B)
display.draw_line(bx + 1, by + BIRD_RADIUS - 2, bx + 5, leg_y, EYE_R, EYE_G, EYE_B)
end
local function draw_scoreboard()
local box_w = 112
local left_x = 8
local right_x = width - box_w - 8
display.fill_round_rect(left_x, 8, box_w, 40, 8, PANEL_R, PANEL_G, PANEL_B)
display.draw_round_rect(left_x, 8, box_w, 40, 8, PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B)
display.draw_text(left_x + 8, 18, "SCORE " .. tostring(score), {
r = TEXT_R,
g = TEXT_G,
b = TEXT_B,
font_size = 14,
bg_r = PANEL_R,
bg_g = PANEL_G,
bg_b = PANEL_B,
})
display.fill_round_rect(right_x, 8, box_w, 40, 8, PANEL_R, PANEL_G, PANEL_B)
display.draw_round_rect(right_x, 8, box_w, 40, 8, PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B)
display.draw_text(right_x + 8, 18, "BEST " .. tostring(best_score), {
r = TEXT_R,
g = TEXT_G,
b = TEXT_B,
font_size = 14,
bg_r = PANEL_R,
bg_g = PANEL_G,
bg_b = PANEL_B,
})
end
local function draw_center_panel(title, subtitle, subtitle_color)
local panel_w = math.min(width - 8, 232)
local panel_h = 88
local panel_x = (width - panel_w) // 2
local panel_y = math.floor(play_height * 0.18)
display.fill_round_rect(panel_x, panel_y, panel_w, panel_h, 12, PANEL_R, PANEL_G, PANEL_B)
display.draw_round_rect(panel_x, panel_y, panel_w, panel_h, 12, PANEL_BORDER_R, PANEL_BORDER_G, PANEL_BORDER_B)
display.draw_text_aligned(panel_x, panel_y + 8, panel_w, 24, title, {
r = TEXT_R,
g = TEXT_G,
b = TEXT_B,
font_size = 22,
bg_r = PANEL_R,
bg_g = PANEL_G,
bg_b = PANEL_B,
align = "center",
valign = "middle",
})
display.draw_text_aligned(panel_x + 12, panel_y + 42, panel_w - 24, 18, subtitle, {
r = subtitle_color.r,
g = subtitle_color.g,
b = subtitle_color.b,
font_size = 14,
bg_r = PANEL_R,
bg_g = PANEL_G,
bg_b = PANEL_B,
align = "center",
valign = "middle",
})
end
local function action_label(verb)
if input_mode == "lcd_touch" then
return "tap to " .. verb
end
if input_mode == "button" then
return "press button to " .. verb
end
return verb
end
local function render()
draw_background()
for i = 1, #pipes do
draw_pipe(pipes[i])
end
draw_bird()
draw_scoreboard()
if state == "title" then
draw_center_panel("Lappy Bird", action_label("start") .. " and " .. action_label("flap"), { r = 44, g = 86, b = 128 })
elseif state == "crashed" then
draw_center_panel("Crash", action_label("restart"), { r = DANGER_R, g = DANGER_G, b = DANGER_B })
end
display.present()
end
local function circle_rect_hit(cx, cy, radius, rx, ry, rw, rh)
local nearest_x = math.max(rx, math.min(cx, rx + rw))
local nearest_y = math.max(ry, math.min(cy, ry + rh))
local dx = cx - nearest_x
local dy = cy - nearest_y
return dx * dx + dy * dy <= radius * radius
end
local function set_crashed()
if state == "crashed" then
return
end
if score > best_score then
best_score = score
end
if audio_output then
pcall(audio.play_tone, audio_output, 180, 220)
end
state = "crashed"
end
local function update_playing()
bird_vy = bird_vy + GRAVITY
bird_y = bird_y + bird_vy
spawn_timer_ms = spawn_timer_ms + FRAME_MS
if spawn_timer_ms >= PIPE_SPAWN_MS then
spawn_timer_ms = spawn_timer_ms - PIPE_SPAWN_MS
pipes[#pipes + 1] = new_pipe(width + PIPE_WIDTH + 8)
end
for i = #pipes, 1, -1 do
local pipe = pipes[i]
pipe.x = pipe.x - PIPE_SPEED
if not pipe.scored and pipe.x + PIPE_WIDTH < bird_x then
pipe.scored = true
score = score + 1
if score > best_score then
best_score = score
end
if audio_output then
pcall(audio.play_tone, audio_output, 1320, 50)
end
end
if pipe.x + PIPE_WIDTH < -4 then
table.remove(pipes, i)
elseif circle_rect_hit(bird_x, bird_y, BIRD_RADIUS, pipe.x, 0, PIPE_WIDTH, pipe.gap_top)
or circle_rect_hit(bird_x, bird_y, BIRD_RADIUS, pipe.x, pipe.gap_bottom, PIPE_WIDTH, play_bottom - pipe.gap_bottom) then
set_crashed()
end
end
if bird_y - BIRD_RADIUS <= play_top or bird_y + BIRD_RADIUS >= play_bottom then
set_crashed()
end
end
local function init_input()
if lcd_touch_ok then
local touch_err
touch_handle, touch_err = bm.get_lcd_touch_handle("lcd_touch")
if touch_handle then
ok, err = pcall(lcd_touch.sync, touch_handle)
if ok then
input_mode = "lcd_touch"
return true
end
print("[lappybird] WARN: lcd_touch.sync failed: " .. tostring(err))
else
print("[lappybird] WARN: get_lcd_touch_handle(lcd_touch) failed: " .. tostring(touch_err))
end
else
print("[lappybird] WARN: require(lcd_touch) failed")
end
if not button_ok then
print("[lappybird] ERROR: require(button) failed")
return false
end
local button_err
button_handle, button_err = button.new(0, 0)
if not button_handle then
print("[lappybird] ERROR: button.new failed: " .. tostring(button_err))
return false
end
local level, level_err = button.get_key_level(button_handle)
if level == nil then
print("[lappybird] ERROR: button.get_key_level failed: " .. tostring(level_err))
cleanup()
return false
end
button_last_level = level
input_mode = "button"
return true
end
local function consume_input_tap()
if input_mode == "lcd_touch" then
local polled, info = pcall(lcd_touch.poll, touch_handle)
if not polled then
print("[lappybird] ERROR: lcd_touch.poll failed: " .. tostring(info))
return nil
end
local tapped = info.just_pressed and not touch_consumed
touch_consumed = info.pressed
return tapped
end
if input_mode == "button" then
local level, level_err = button.get_key_level(button_handle)
if level == nil then
print("[lappybird] ERROR: button.get_key_level failed: " .. tostring(level_err))
return nil
end
local tapped = level == button_active_level and button_last_level ~= button_active_level
button_last_level = level
return tapped
end
return false
end
local function init_audio()
if not audio_ok then
print("[lappybird] WARN: require(audio) failed")
return
end
local output_codec, output_rate, output_channels, output_bits =
bm.get_audio_codec_output_params("audio_dac")
if not output_codec then
print("[lappybird] WARN: get_audio_codec_output_params(audio_dac) failed: " .. tostring(output_rate))
return
end
local output, out_err = audio.new_output(output_codec, output_rate, output_channels, output_bits)
if not output then
print("[lappybird] WARN: audio.new_output failed: " .. tostring(out_err))
return
end
audio_output = output
pcall(audio.set_volume, audio_output, SOUND_VOLUME)
end
if not init_input() then
cleanup()
return
end
init_audio()
reset_round("title")
display.begin_frame({ clear = true, r = SKY_R, g = SKY_G, b = SKY_B })
print(string.format("[lappybird] ready screen=%dx%d", width, height))
if input_mode == "lcd_touch" then
print("[lappybird] lcd_touch ready, tap anywhere to flap, tap after crashing to restart")
else
print("[lappybird] lcd_touch unavailable, using button to flap and restart")
end
for _ = 1, RUN_TIME_MS // FRAME_MS do
local tapped = consume_input_tap()
if tapped == nil then
break
end
if tapped then
if state == "title" then
reset_round("playing")
flap()
elseif state == "playing" then
flap()
elseif state == "crashed" then
reset_round("playing")
flap()
end
end
if state == "playing" then
update_playing()
end
render()
frame_count = frame_count + 1
delay.delay_ms(FRAME_MS)
end
cleanup()
print("[lappybird] done")