diff --git a/lucksmith.lua b/lucksmith.lua index 43a964f..d7412ec 100644 --- a/lucksmith.lua +++ b/lucksmith.lua @@ -24,6 +24,13 @@ function lerp(a, b, t) return a + (b - a) * t end +-- Decelerating easing (fast start, soft landing) - used anywhere a lerp's t +-- would otherwise move at a constant, slightly lifeless speed. +function ease_out_cubic(t) + local inv = 1 - t + return 1 - inv * inv * inv +end + -- === 2. GAME DATA === -- enemies is defined further down (right after enemy_cards) so each entry -- can reference specific enemy_cards directly for its card_pool - see there. @@ -129,10 +136,22 @@ cards = {{ desc = "if tied, you win", timing = "post_result", energy = -1, -- reduced from -2 + -- Runs in its own pass before every other post_result card (see + -- reveal_and_apply_effects()) and reports the flipped result back, so a + -- card like Spike/Counter/Drain that checks player_won afterward sees + -- the tie as decided rather than silently no-op'ing on the old tie. + tiebreaker = true, use = function(p, e, player_won) if player_won == nil then -- tie occurred - e.hp = e.hp - (2 * p.mult) - current_damage.enemy = current_damage.enemy + (2 * p.mult) -- show damage taken + -- Rounded the same way the main clash damage is (see resolve_turn) - + -- mult can be fractional (Fateshaper's innate 1.25, Caution, Sap, + -- ...), and applying that straight to hp could leave it sitting on + -- a fraction like 0.5: every heart shows empty (i <= hp fails for + -- all i >= 1) but hp <= 0 is false, so game-over never fires. + local dmg = math.floor(2 * p.mult + 0.5) + e.hp = e.hp - dmg + current_damage.enemy = current_damage.enemy + dmg -- show damage taken + return true -- the tie is now a win for the player end end }, { @@ -433,11 +452,17 @@ enemy_cards = {{ desc = "if tie, you win", timing = "post_result", energy = -2, + tiebreaker = true, -- see the player's Break above - runs before Spike/Counter/Drain use = function(p, e, player_won) if player_won == nil then -- tie occurred - -- Enemy wins the tie, so player takes damage - p.hp = p.hp - (2 * e.mult) - current_damage.player = current_damage.player + (2 * e.mult) -- show damage taken + -- Enemy wins the tie, so player takes damage - rounded the same + -- way as the main clash damage (see the player's Break above for + -- why: an unrounded fractional mult can leave hp on a fraction + -- that shows as fully dead but doesn't actually trigger game over) + local dmg = math.floor(2 * e.mult + 0.5) + p.hp = p.hp - dmg + current_damage.player = current_damage.player + dmg -- show damage taken + return false -- the tie is now a win for the enemy end end }} @@ -604,28 +629,43 @@ function get_enemy(level) } end -function reset_player(energy) +-- Fights start below that ceiling instead of already at it, so +-- energy-positive cards (Focus, Turtle, Rest, Gamble, Spike) have real +-- headroom to climb toward - previously energy==max_energy from turn 1, so +-- any card that granted energy was immediately clamped away for nothing +-- unless you'd already spent some down (measured at only ~70-75% of gains +-- actually landing). A gap of 2 already gets that to ~95-100% landed with +-- no measurable win-rate cost; gap 3+ starts trading away difficulty for no +-- further benefit - see sim/balance_sim.lua's ENERGY_START_GAP sweep. +ENERGY_START_GAP = 2 + +function reset_player(max_energy) return { hp = 10, roll_mod = 0, mult = 1, - energy = energy, - max_energy = energy -- energy-positive cards can't push you past this level's cap + energy = math.max(1, max_energy - ENERGY_START_GAP), + max_energy = max_energy -- energy-positive cards can't push you past this level's cap } end -- Energy no longer regenerates each turn - it's a fixed budget for the whole -- fight, one point larger per level so longer fights against bigger enemies -- get a slightly bigger allowance. -function starting_energy() +function max_energy_for_level() return 4 + level end -- === 4. STATE === level = 1 -player = reset_player(starting_energy()) +player = reset_player(max_energy_for_level()) enemy = get_enemy(level) fight_turn = 0 -- turns elapsed in the current fight, so enemy.scripted_first_card only fires on turn 1 (see start_turn()) +-- Whole-run totals (unlike fight_turn, span every fight in the run) - reset +-- on Start (see phase_title()), used to set personal bests on a full clear +-- (see phase_game_over()'s victory branch / PMEM_BEST_TURNS/PMEM_BEST_DAMAGE). +run_turns = 0 +run_damage_taken = 0 -- animation + shake player.display_roll = 0 enemy.display_roll = 0 @@ -678,6 +718,8 @@ PMEM_RUNS_STARTED = 0 PMEM_RUNS_WON = 1 PMEM_HIGHEST_LEVEL = 2 PMEM_DEATHS_BASE = 3 -- slots 3..(3 + #enemies - 1), one per enemy by level index +PMEM_BEST_TURNS = 9 -- fewest turns across a full clear (see run_turns) +PMEM_BEST_DAMAGE = 10 -- least damage taken across a full clear (see run_damage_taken) function stat_incr(index) pmem(index, pmem(index) + 1) @@ -689,9 +731,20 @@ function stat_max(index, value) end end +function stat_min(index, value) + if value < pmem(index) then + pmem(index, value) + end +end + shake = 0 d = 2 -- shake intensity +-- Free-running frame counter for idle animation (portrait bob, title/logo +-- float, persist badge pulse, ...) - ticks every frame regardless of phase, +-- unlike phase_time which each phase resets/reinterprets for its own timing. +anim_tick = 0 + original_palette = {} for i = 0, 15 do original_palette[i] = peek(0x3FC0 + i) @@ -716,6 +769,12 @@ card_choice_options = {} -- for level up card selection picked = 1 card_pick_selection = 1 +-- Eases the selected hand card's lift in rather than snapping it instantly - +-- see phase_choose(). Resets to 0 whenever the selection moves to a +-- different card, then eases back up toward 1 each frame. +hand_lift_progress = 1 +hand_lift_last_picked = 1 + -- card-playing animation (hand -> center, before the dice roll) playing_card = nil play_from_x = 0 @@ -878,17 +937,27 @@ end -- Reveals every pending effect of a given timing one at a time: highlight the -- card responsible, show its name/description, apply it, then hold briefly so -- the resulting number change is readable before moving to the next one. -function reveal_and_apply_effects(timing, player_won) +-- only_tiebreaker (post_result only): pass true to reveal ONLY +-- tiebreaker-tagged effects (Break) or false to reveal everything else - +-- see resolve_turn()'s two-pass post_result call, which runs Break first so +-- it can flip a tie before Spike/Counter/Drain check player_won. Returns +-- the (possibly updated) player_won for the caller to thread into the next +-- pass. +function reveal_and_apply_effects(timing, player_won, only_tiebreaker) for _, group in ipairs(resolve_effect_groups) do for _, effect in ipairs(pending_effects[group.key]) do - if effect.timing == timing then + local pass_matches = only_tiebreaker == nil or (only_tiebreaker == (effect.tiebreaker == true)) + if effect.timing == timing and pass_matches then resolve_highlight = group.side resolve_message(effect.name, 12, effect.desc) for i = 1, RESOLVE_REVEAL_PAUSE do coroutine.yield() end if timing == "post_result" then - effect.use(player, enemy, player_won) + local result = effect.use(player, enemy, player_won) + if result ~= nil then + player_won = result + end else effect.use(player, enemy) end @@ -906,6 +975,7 @@ function reveal_and_apply_effects(timing, player_won) end resolve_highlight = nil clear_resolve_message() + return player_won end -- The full turn resolution, run as a coroutine on the gameplay effect queue so @@ -914,6 +984,11 @@ end -- then the post-result effects, and only then the actual HP damage - each -- step announced through the same message box so nothing lands as a surprise. function resolve_turn() + -- Captured for run_damage_taken below - net over the whole turn rather + -- than hooked at each individual damage source (main clash, Counter, + -- Break, Spike, ...), so any healing this same turn nets against it too. + local hp_before = player.hp + if card_fizzled then resolve_message("Not enough energy!", 14, current_card.name .. " fizzles - no effect") for i = 1, RESOLVE_REVEAL_PAUSE do @@ -1006,8 +1081,12 @@ function resolve_turn() end clear_resolve_message() - -- Post-result card effects (Counter, Break, Poison, Spike, Drain, ...) - reveal_and_apply_effects("post_result", player_won) + -- Post-result card effects (Counter, Break, Poison, Spike, Drain, ...) - + -- Break resolves first (it can flip a tie into a win) so Spike/Counter/ + -- Drain see the final result afterward, not the original tie (see + -- reveal_and_apply_effects()'s only_tiebreaker param). + player_won = reveal_and_apply_effects("post_result", player_won, true) + reveal_and_apply_effects("post_result", player_won, false) -- Announce the incoming damage before it actually lands, so the HP bar -- dropping is never a surprise @@ -1022,6 +1101,9 @@ function resolve_turn() damage_display_frames = 20, step_wait = 10 }) + -- Landing a hit should feel as punchy as taking one - previously only + -- the player-damage branch below shook the screen. + shake = 5 * dmg elseif damage_target == "player" then local shield_reduction = player.shield or 0 local final_dmg = math.max(0, dmg - shield_reduction) @@ -1052,12 +1134,14 @@ function resolve_turn() end clear_resolve_message() + run_damage_taken = run_damage_taken + math.max(0, hp_before - player.hp) + -- Reset for the next turn player.roll_mod, enemy.roll_mod = 0, 0 player.mult, enemy.mult = 1, 1 player.weaken, enemy.weaken = 0, 0 player.rage, enemy.rage = 0, 0 - -- no per-turn regen - energy is a fixed budget for the whole fight (see starting_energy()) + -- no per-turn regen - energy is a fixed budget for the whole fight (see max_energy_for_level()) current_damage.player = 0 current_damage.enemy = 0 @@ -1099,6 +1183,7 @@ function start_turn(card) current_card = card fight_turn = fight_turn + 1 + run_turns = run_turns + 1 -- Enemies without their own card_pool fall back to the shared list; an -- enemy.scripted_first_card (Lucksmith's Loaded Dice) always opens the -- fight instead of leaving that first impression up to chance. @@ -1276,6 +1361,23 @@ function print_centered(text, cx, y, color) print(text, cx - text_width(text) / 2, y, color, false, 1, true) end +-- Shrinks text to fit max_width pixels (measured for real via text_width, +-- not guessed), trimming a character at a time and appending "." to show it +-- was cut - a handful of card names (Double Vision, Intuition, Mulligan, +-- ...) don't fit a 28px hand card at full length. Used wherever a card name +-- is printed at a fixed width, so it stays correct if names or card sizes +-- change later. +function fit_text(text, max_width) + if text_width(text) <= max_width then + return text + end + local truncated = text + while #truncated > 0 and text_width(truncated .. ".") > max_width do + truncated = truncated:sub(1, #truncated - 1) + end + return truncated .. "." +end + -- Small pip drawn in a card's corner to mark cards tagged persists = true - -- the ones (Weaken, Rage, Poison, ...) whose effect keeps going into the next -- turn instead of being one-and-done. Colour 4 (yellow) isn't used anywhere @@ -1285,7 +1387,10 @@ PERSIST_BADGE_COLOR = 4 function draw_persist_badge(card, x, y) if card.persists then - circ(x, y, 2, PERSIST_BADGE_COLOR) + -- Gentle pulse instead of a static dot, so "this keeps going" reads + -- as something alive rather than just another fixed icon. + local radius = math.sin(anim_tick * 0.15) > 0 and 2 or 1 + circ(x, y, radius, PERSIST_BADGE_COLOR) end end @@ -1307,7 +1412,7 @@ function draw_played_card_label(x, y, w, h, color, card, highlighted, fizzled) rectb(x, y, w, h, border) local icon_w = 10 -- 8px icon + a little breathing room spr(card_art_id(card), x + 2, y + (h - 8) / 2, 0) - print_centered(card.name, x + icon_w + (w - icon_w) / 2, y + h / 2 - 3, border) + print_centered(fit_text(card.name, w - icon_w - 2), x + icon_w + (w - icon_w) / 2, y + h / 2 - 3, border) draw_persist_badge(card, x + w - 4, y + 4) if fizzled then line(x + 1, y + 1, x + w - 2, y + h - 2, FIZZLE_COLOR) @@ -1323,9 +1428,14 @@ end MESSAGE_W = 200 MESSAGE_X = 120 - MESSAGE_W / 2 MESSAGE_INFO_BG, MESSAGE_INFO_BORDER = 8, 10 -- cool blue -MESSAGE_EVENT_BG, MESSAGE_EVENT_BORDER = 1, 3 -- warm purple/orange +-- Fill dropped from purple(1) to near-black(0) - purple sat too close in +-- luminance to the red(2)/green(6) text resolve_message uses ("You win!", +-- damage callouts, ...), muddying contrast; near-black reads clearly under +-- white, red and green alike. Border stays orange for the same "event" identity. +MESSAGE_EVENT_BG, MESSAGE_EVENT_BORDER = 0, 3 function draw_message(y, color, title, subtitle, bg, border) + y = y - 5 -- shifted up 5px from every caller's originally-tuned position local line_h = 8 local h = subtitle and (line_h * 2 + 6) or (line_h + 6) rect(MESSAGE_X, y, MESSAGE_W, h, bg) @@ -1372,7 +1482,11 @@ function draw_hud() -- Update played cards UI if phase ~= "choose" then - draw_played_card_label(4, 36, 52, 14, 2, current_card, resolve_highlight == "current_player", card_fizzled) + -- A fizzled card gets a small "nope" wobble instead of just sitting + -- there crossed out, so the wasted play actually reads as a miss + local fizzle_wobble = card_fizzled and math.floor(math.sin(anim_tick * 0.8) * 2) or 0 + draw_played_card_label(4 + fizzle_wobble, 36, 52, 14, 2, current_card, resolve_highlight == "current_player", + card_fizzled) draw_played_card_label(186, 36, 52, 14, 6, enemy_card, resolve_highlight == "current_enemy") end draw_played_card_label(4, 20, 52, 14, 2, previous_card, resolve_highlight == "previous_player") @@ -1465,19 +1579,34 @@ function card_art_id(card) return card.art or card_art_by_timing[card.timing] or 6 end +-- Selection is shown by INVERTING which colour is the fill vs. the text, +-- not just a brighter border - every card now gets a solid fill (previously +-- only the selected one did, leaving unselected cards' text sitting +-- straight on the black background with poor contrast on small screens). +-- Affordable cards use cyan(11)/navy(8) as fill+text depending on +-- selection; unaffordable cards use the same idea one shade paler/duller so +-- "can't afford" still reads clearly at a glance. function draw_card(card, x, y, w, h, is_selected, can_afford, show_name) - local border_color = can_afford and (is_selected and 11 or 9) or (is_selected and 13 or 14) - rectb(x, y, w, h, border_color) + local fill_color, text_color, border_color if is_selected then - rect(x + 1, y + 1, w - 2, h - 2, 8) + fill_color = can_afford and 11 or 13 + text_color = can_afford and 0 or 15 -- dark text on the now-bright fill + border_color = can_afford and 9 or 14 -- darker ring so it stays visible against the bright fill + else + fill_color = 8 + text_color = can_afford and 11 or 13 -- bright text on the dark fill + border_color = text_color end + rectb(x, y, w, h, border_color) + rect(x + 1, y + 1, w - 2, h - 2, fill_color) - spr(card_art_id(card), x + w / 2 - 4, y + 4, 0) + -- Centered both ways now (was pinned to the top) so it sits clear of the + -- persist dot and energy pips up top instead of crowding them. + spr(card_art_id(card), x + w / 2 - 4, y + h / 2 - 6, 0) draw_persist_badge(card, x + 4, y + 4) if show_name then - local text_color = can_afford and (is_selected and 11 or 9) or (is_selected and 0 or 14) - print(card.name, x + 3, y + h - 12, text_color, false, 1, true) + print_centered(fit_text(card.name, w - 5), x + w / 2, y + h - 12, text_color) end -- Show energy cost/gain in the card's top-right corner @@ -1493,25 +1622,52 @@ TITLE_PROMPT_DELAY = 90 -- ~1.5s at 60fps - a clean title view before any prompt function phase_title() phase_time = phase_time + 1 - spr(320, 60, 20, 0, 1, 0, 0, 16, 8) - print("smith", 98, 60, 10, false, 2, false) + local logo_bob = math.floor(math.sin(anim_tick * 0.04) * 2) + spr(320, 60, 20 + logo_bob, 0, 1, 0, 0, 16, 8) + print("smith", 98, 60 + logo_bob, 10, false, 2, false) if phase_time <= TITLE_PROMPT_DELAY then return end - print_centered("Press X to Start", 120, 100, 14) - print_centered("Press Z for Stats", 120, 110, 14) + print_centered("Press X to Start", 120, 96, 14) + print_centered("Press Z for Stats", 120, 106, 14) + print_centered("Press Up for How to Play", 120, 116, 14) if btnp(5) then phase = "choose" level = 1 - player = reset_player(starting_energy()) + player = reset_player(max_energy_for_level()) enemy = get_enemy(level) fight_turn = 0 + run_turns = 0 + run_damage_taken = 0 initialize_deck() draw_hand() stat_incr(PMEM_RUNS_STARTED) elseif btnp(4) then phase = "stats" phase_time = 0 + elseif btnp(0) then + phase = "howto" + phase_time = 0 + end +end + +-- Static rules reference - a stopgap for a proper tutorial (may become a +-- one-time guided tutorial battle later); only reachable from the title +-- screen's Up prompt. +HOWTO_LINES = {"Pick a card, then roll dice each turn.", "Higher total wins - damage scales with your mult.", + "Energy is a budget for the whole fight, not per turn.", "Can't afford a card? It fizzles - no effect.", + "A dot marks cards whose effect echoes next turn.", + "Win a fight to permanently add a card to your deck."} + +function phase_howto() + print_centered("HOW TO PLAY", 120, 10, 12) + for i, line in ipairs(HOWTO_LINES) do + print_centered(line, 120, 10 + i * 14, 7) + end + print_centered("Press X or Z to return", 120, 118, 14) + if btnp(4) or btnp(5) then + phase = "title" + phase_time = 0 end end @@ -1524,20 +1680,27 @@ function phase_stats() local runs = pmem(PMEM_RUNS_STARTED) local wins = pmem(PMEM_RUNS_WON) local win_pct = runs > 0 and math.floor(100 * wins / runs + 0.5) or 0 - print_centered("Runs started: " .. runs, 120, 24, 7) - print_centered("Runs won: " .. wins .. " (" .. win_pct .. "%)", 120, 33, 7) + print_centered("Runs started: " .. runs, 120, 20, 7) + print_centered("Runs won: " .. wins .. " (" .. win_pct .. "%)", 120, 28, 7) local highest = pmem(PMEM_HIGHEST_LEVEL) local highest_label = highest > 0 and ("Lv" .. highest .. " - " .. enemies[highest].name) or "-" - print_centered("Furthest reached: " .. highest_label, 120, 42, 7) + print_centered("Furthest reached: " .. highest_label, 120, 36, 7) - print_centered("Deaths by enemy", 120, 56, 9) + -- Personal bests only exist once you've actually cleared a run - see + -- phase_game_over()'s victory branch (PMEM_BEST_TURNS/PMEM_BEST_DAMAGE). + local best_turns_label = wins > 0 and (pmem(PMEM_BEST_TURNS) .. " turns") or "-" + local best_damage_label = wins > 0 and tostring(pmem(PMEM_BEST_DAMAGE)) or "-" + print_centered("Best clear: " .. best_turns_label, 120, 44, 7) + print_centered("Least damage taken (win): " .. best_damage_label, 120, 52, 7) + + print_centered("Deaths by enemy", 120, 64, 9) for i = 1, #enemies do local deaths = pmem(PMEM_DEATHS_BASE + i - 1) - print_centered(enemies[i].name .. ": " .. deaths, 120, 56 + i * 9, 6) + print_centered(enemies[i].name .. ": " .. deaths, 120, 64 + i * 8, 6) end - print_centered("Press X or Z to return", 120, 126, 14) + print_centered("Press X or Z to return", 120, 124, 14) if btnp(4) or btnp(5) then phase = "title" phase_time = 0 @@ -1558,14 +1721,20 @@ function phase_choose() -- unselectable past the old fixed limit picked = math.min(#hand, math.max(1, picked)) + if picked ~= hand_lift_last_picked then + hand_lift_last_picked = picked + hand_lift_progress = 0 + end + hand_lift_progress = hand_lift_progress + (1 - hand_lift_progress) * 0.35 + -- Show hand fanned out along the bottom of the screen, centered for -- however many cards are actually in it local x0 = hand_x0(#hand) for i, card in ipairs(hand) do local x = x0 + (i - 1) * HAND_SPACING local is_selected = i == picked - local y = is_selected and (HAND_Y - HAND_LIFT) or HAND_Y - draw_card(card, x, y, CARD_W, CARD_H, is_selected, can_afford_card(card), true) + local y = is_selected and (HAND_Y - HAND_LIFT * hand_lift_progress) or HAND_Y + draw_card(card, x, math.floor(y), CARD_W, CARD_H, is_selected, can_afford_card(card), true) end local selected_card = hand[picked] @@ -1578,7 +1747,9 @@ function phase_choose() if btnp(4) then playing_card = selected_card play_from_x = x0 + (picked - 1) * HAND_SPACING - play_from_y = HAND_Y - HAND_LIFT + -- Matches whatever height the lift-in easing has actually reached, + -- so the play-out animation starts from where the card visually is + play_from_y = HAND_Y - HAND_LIFT * hand_lift_progress phase = "play_card" phase_time = 0 end @@ -1586,7 +1757,7 @@ end function phase_play_card() phase_time = phase_time + 1 - local t = math.min(1, phase_time / PLAY_DURATION) + local t = ease_out_cubic(math.min(1, phase_time / PLAY_DURATION)) local x = lerp(play_from_x, PLAY_TARGET_X, t) local y = lerp(play_from_y, PLAY_TARGET_Y, t) draw_card(playing_card, x, y, CARD_W, CARD_H, true, true, true) @@ -1627,7 +1798,7 @@ function phase_card_reward() add_card_to_deck(card_choice_options[card_pick_selection]) -- Advance to next level level = math.min(level + 1, #enemies) - player = reset_player(starting_energy()) + player = reset_player(max_energy_for_level()) enemy = get_enemy(level) fight_turn = 0 -- Clear card displays for new level @@ -1791,6 +1962,14 @@ function phase_game_over() -- the last fight forever (level was already clamped at #enemies) phase = "victory" phase_time = 0 + if pmem(PMEM_RUNS_WON) == 0 then + -- first ever clear - nothing to compare against yet + pmem(PMEM_BEST_TURNS, run_turns) + pmem(PMEM_BEST_DAMAGE, run_damage_taken) + else + stat_min(PMEM_BEST_TURNS, run_turns) + stat_min(PMEM_BEST_DAMAGE, run_damage_taken) + end stat_incr(PMEM_RUNS_WON) else -- Victory - go to card selection @@ -1801,7 +1980,7 @@ function phase_game_over() else -- No more cards to unlock, just advance level level = math.min(level + 1, #enemies) - player = reset_player(starting_energy()) + player = reset_player(max_energy_for_level()) enemy = get_enemy(level) fight_turn = 0 -- Clear card displays for new level @@ -1846,6 +2025,7 @@ local phase_handlers = { -- === 11. MAIN LOOP === function TIC() cls(0) + anim_tick = anim_tick + 1 if shake > 0 then poke(0x3FF9, math.random(-d, d)) @@ -1866,9 +2046,17 @@ function TIC() return end - -- draw player and enemy portraits - spr(32, 5, 70, 0, 1, 0, 0, 2, 2) -- player - spr(34, 220, 70, 0, 1, 0, 0, 2, 2) -- enemy + if phase == "howto" then + phase_howto() + return + end + + -- draw player and enemy portraits - a gentle idle bob (opposite phase so + -- they don't move in lockstep) so the screen doesn't feel static between + -- turns + local portrait_bob = math.floor(math.sin(anim_tick * 0.05) * 2) + spr(32, 5, 70 + portrait_bob, 0, 1, 0, 0, 2, 2) -- player + spr(34, 220, 70 - portrait_bob, 0, 1, 0, 0, 2, 2) -- enemy -- Draw HUD and health bars if phase ~= "card_reward" then