mirror of
https://github.com/m5stack/ESP-Claw.git
synced 2026-05-20 11:51:49 -07:00
530 lines
16 KiB
Lua
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")
|