Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033

This commit is contained in:
bryanthaboi
2026-08-14 10:05:51 -04:00
parent 2010ba71a6
commit a94fecfec8
48 changed files with 1263 additions and 541 deletions
+7 -6
View File
@@ -33,12 +33,12 @@ local function save_name(game)
return game.save.player.name
end
local function showMessages(game, msgs, onDone)
local function showMessages(game, msgs, onDone, opts)
if not msgs or #msgs == 0 then
if onDone then onDone() end
return
end
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
end
-- run the use-flow for an item on a chosen target. `picker` is the party
@@ -181,9 +181,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
-- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
-- (learn_move.asm), so the jingle rides the box
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) })
game.data.pokemon[target.species].name, mdef.name) }, nil,
TextBox.soundOpts(game, "Get_Item1"))
if result == "learn" then consume(game, id) end
list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items))
@@ -333,10 +335,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local mdef = game.data.moves[moveId]
if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep)
nextStep, TextBox.soundOpts(game, "Get_Item1"))
else
require("src.ui.Screens").push(game, "MoveLearnMenu",
target, moveId, nextStep)
+22 -8
View File
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON")
end
local FLASH_FRAMES = 220
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
local CANCEL_GRACE_FRAMES = 80
-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds
-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all),
-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of
-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all).
local ANIM_LOOP_FRAMES = 288
local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES
-- which pic is on screen t frames into .animLoop
local function evoShowsNew(t)
for b = 1, 8 do
local hold = 18 - 2 * b
if t < hold then return false end
t = t - hold
local swap = b * 6
if t < swap then return t % 6 < 3 end
t = t - swap
end
return true
end
local function frontSprite(game, species, mon)
local path, trueColor = require("src.pokemon.Sprites").path(
@@ -140,14 +158,10 @@ function EvolutionState:draw()
else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
if showNew then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2)
+16 -3
View File
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local IntroMovie = {}
IntroMovie.__index = IntroMovie
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
self:finish()
return
end
-- an op that consumed frames ends ON its last frame; only the instant
-- ops (sfx / pose / frame, which are plain writes between DelayFrames
-- calls in PlayIntroScene) chain into the next op the same frame
local timed = false
if op.sfx then
Sound.play(self.game.data, op.sfx)
elseif op.pose then
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
end
self.opTimer = self.opTimer + 1
if self.opTimer < (op.px or math.abs(op.dx)) then return end
timed = true
elseif op.anim then
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
if self.opTimer % 5 == 0 then
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
end
self.opTimer = self.opTimer + 1
if self.opTimer < #ANIM[op.anim] * 5 then return end
timed = true
elseif op.wait then
self.opTimer = self.opTimer + 1
if self.opTimer < op.wait then return end
timed = true
elseif op.fade then
self.opTimer = self.opTimer + 1
self.fade = self.opTimer / op.fade
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
end
self.opIndex = self.opIndex + 1
self.opTimer = 0
if timed then return end
end
end
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
if input:wasPressed("a") or input:wasPressed("start") then
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
self:exitToTitle()
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else
self:fightStep()
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
-- own frame
if self.timer > Timing.DELAY3 then self:fightStep() end
end
end
+88 -35
View File
@@ -87,6 +87,15 @@ local function refuseUnavailable(self)
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
end
-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of
-- .outOfBattleMovePointers prints this and jumps back to the open submenu
local function refuseBadge(self)
local TextBox = require("src.render.TextBox")
local t = self.game.data and self.game.data.text or {}
self.game.stack:push(TextBox.new(self.game,
t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required.")))
end
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -411,6 +420,23 @@ function PartyMenu:update(dt)
-- flyTo (OverworldController) validates the fly-warp + runs the
-- departure/warp, so we just hand it the chosen mapId (#195).
local ow = self.game.overworld
-- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap
-- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not
-- OVERWORLD alone, #83); both refusals loop back to the submenu
if ow and not ow:partyKnows("FLY") then
refuseBadge(self)
return
end
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotFlyHereText
or Strings("{RAM:wNameBuffer} can't\nFLY here."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop() -- close the party menu
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
if ow then ow:flyTo(mapId) end
@@ -423,9 +449,17 @@ function PartyMenu:update(dt)
-- over the menu, and the cave is lit when the blink hands the
-- screen back, never under the text (#385).
local ow = self.game.overworld
if ow and not ow:partyKnows("FLASH") then
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
self.game.save.flashLit = true
-- .flash prints on any map, but the light it records is this map's:
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
-- a FLASH used in daylight must not carry into Rock Tunnel
local wasDark = ow and ow.dark
if wasDark then self.game.save.flashLit = true end
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
@@ -440,13 +474,13 @@ function PartyMenu:update(dt)
-- seconds of per-pixel atlas baking on a phone -- on screen as a
-- solid white frame with nothing under it, which reads as a
-- lockup (#610).
ow:setDark(false)
if wasDark then ow:setDark(false) end
self.game.stack:push(Transition.whiteFlash(self.game))
end))
return
elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- current refusals, both of which loop back to the submenu), then
-- ItemUseSurfboard: while surfing it tries to dismount instead;
-- otherwise it mounts only if the FACING tile is water, else
@@ -503,8 +537,8 @@ function PartyMenu:update(dt)
return -- .loop: submenu stays open behind the message
elseif action == "cut" then
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
-- submenu when the FACING tile isn't a cuttable tree.
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
-- to the submenu when the FACING tile isn't a cuttable tree.
local ow = self.game.overworld
local reason = ow:useCutFieldMove()
if reason == "ok" then
@@ -522,7 +556,7 @@ function PartyMenu:update(dt)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
elseif action == "strength" then
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time);
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated;
-- predef PrintStrengthText (field_move_messages.asm) sets
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
@@ -532,6 +566,10 @@ function PartyMenu:update(dt)
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map.
local ow = self.game.overworld
if ow and not ow:partyKnows("STRENGTH") then
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
@@ -565,6 +603,33 @@ function PartyMenu:update(dt)
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
-- the exact departure; the fade + warp fire when the spin ends.
local ow = self.game.overworld
if entry.move == "TELEPORT" then
-- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap --
-- OVERWORLD + PLATEAU, #83); dark maps don't block it
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotUseTeleportNowText
or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
elseif ow and not (DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM") then
-- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE):
-- usable in the dungeon tilesets of escape_rope_tilesets.asm minus
-- Agatha's room, even in the dark (Rock Tunnel); anywhere else
-- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a
-- bagged ESCAPE ROPE
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._ItemUseNotTimeText
or Strings("OAK: %s!\nThis isn't the\ntime to use that!",
self.game.save.player.name)))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop()
if ow then ow:beginTeleportOut() end
return
@@ -658,42 +723,30 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
-- four moves against FieldMoveDisplayData and nothing else -- no
-- badge, no map, no tileset test. Every one of those lives in
-- .outOfBattleMovePointers, i.e. on selection, where the refusal
-- prints and .loop returns to this still-open submenu (#1022).
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
if mv.id == "FLY" then
table.insert(items, { label = Strings("FLY"), action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
elseif mv.id == "FLASH" then
table.insert(items, { label = Strings("FLASH"), action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
elseif mv.id == "CUT" then
table.insert(items, { label = Strings("CUT"), action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
elseif mv.id == "SURF" then
table.insert(items, { label = Strings("SURF"), action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
elseif mv.id == "STRENGTH" then
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = Strings("DIG"), action = "escape" })
elseif mv.id == "TELEPORT" then
table.insert(items, { label = Strings("TELEPORT"),
action = "escape", move = "TELEPORT" })
elseif mv.id == "DIG" then
table.insert(items, { label = Strings("DIG"),
action = "escape", move = "DIG" })
end
end
end
+128 -78
View File
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
Kit._navSeen = {}
Kit._navQueue = nil
Kit._activateId = nil
Kit._ringShown = false
-- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring
@@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h)
-- First focusable ever drawn adopts the ring, so keyboard users start
-- somewhere rather than nowhere.
if Kit.focusId == nil then Kit.focusId = id end
return Kit.focusId == id
return Kit._ringShown and Kit.focusId == id
end
function Kit.navigate(dir)
Kit._navQueue = dir
Kit._ringShown = true
end
function Kit.activateFocused()
@@ -396,6 +398,7 @@ end
function Kit.setFocus(id)
Kit.focusId = id
Kit._ringShown = id ~= nil
end
-- Pick the nearest focusable in `dir` from the current one. Candidates must
@@ -530,13 +533,10 @@ Kit._audit = audit
function Kit.tapMin() return math.floor(30 * Kit.scale) end
-- ---------------------------------------------------------------- surfaces
function Kit.card(x, y, w, h, emphasis)
Theme.card(x, y, w, h, emphasis)
function Kit.card(x, y, w, h, variant)
Theme.card(x, y, w, h, variant)
end
-- A list row. `id` opts it into the focus ring; pass nil for decorative
-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the
-- caller must print with the returned ink or it will draw white on white.
function Kit.row(x, y, w, h, selected, id)
audit("row", x, y, w, h, id or "row")
local focused = id and Kit.focusable(id, x, y, w, h) or false
@@ -560,7 +560,7 @@ end
-- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message)
if not G then return end
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius())
Theme.card(x, y, w, h, "empty")
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end
@@ -598,61 +598,130 @@ local KINDS = {
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
}
Kit.KINDS = KINDS
local NO_OPTS = {}
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
-- id -- opts into the focus ring (give every real control one)
-- glow -- a pulsing outline for "something is waiting for you" (the
-- update button). No blend-mode change: the alpha of the
-- existing outline is animated instead.
-- fill/ink -- override the kind's colours. The ONE caller is the
-- launcher's Play button, which wears its cartridge colour
-- (red/blue/gold) rather than a semantic one: on that screen
-- "which game am I launching" outranks "what kind of verb is
-- this", and the colour is already the tab's identity.
-- Returns true when activated, by click OR by the focus ring's Enter/A.
function Kit.button(x, y, w, h, label, opts)
opts = opts or {}
opts = opts or NO_OPTS
local enabled = opts.enabled ~= false
-- Disabled buttons audit too: they stay visible, so they still must not
-- paint over a neighbour.
audit("control", x, y, w, h, label)
local focused = enabled and opts.id
and Kit.focusable(opts.id, x, y, w, h) or false
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
if enabled and opts.fill then
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
end
local hot = enabled and Kit.hover(x, y, w, h)
if G then
-- The fill IS the control: a rounded, embossed, colour-coded key. A
-- disabled button keeps its shape in a dead grey rather than
-- disappearing, so a layout never reflows on state.
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
if hot or focused then
-- White ring outside the fill: legible on green, blue, yellow, red and
-- white alike, which one darker/lighter shade per colour would not be.
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
elseif opts.glow and enabled then
-- "Something is waiting for you" (the update button): a pulsing ring.
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
-- mode change.
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
Theme.radius() + 2)
local face = opts.face or "fill"
local B = Theme.BUTTON
local radius = opts.radius or B.radius
local active = opts.active or opts.on
local invert = false
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
if face == "invert" then
invert = hot
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
stroke = opts.stroke or PAL.line
strokeA = invert and Theme.A.focus or Theme.A.hairline
doRing = focused and not hot
elseif face == "tab" then
invert = active or focused or hot
local tint = opts.color or opts.fill or PAL.ink
fill = invert and tint or PAL.surface
ink = invert and PAL.inverse or (opts.color or PAL.text)
if not invert then
stroke = tint
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
end
local fname = opts.font or "button"
local ink = enabled and kind.ink or PAL.inverse
local ty = y + (h - Kit.textHeight(fname)) / 2
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
-- Button labels are bold: they are the shortest, most-scanned text on
-- screen and sit on a saturated fill.
if opts.align == "left" then
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
elseif face == "chip" then
local c = opts.color or PAL.line
invert = active and true or false
if active then
fill = c
ink = PAL.inverse
doEmboss = true
else
Kit.textCenterBold(fname, shown, x, ty, w, ink)
fill = PAL.bg
ink = c
stroke = c
strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover
end
doRing = focused or hot
else
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
fill = (enabled and opts.fill) or kind.fill
ink = (enabled and opts.ink) or kind.ink
doEmboss = true
doRing = hot or focused
if opts.glow and enabled and not doRing then
glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz))
end
end
if opts.emboss ~= nil then doEmboss = opts.emboss end
if opts.ring ~= nil then doRing = opts.ring end
if G then
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius)
if doEmboss then
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest)
or B.embossDisabled
Theme.emboss(x, y, w, h, es)
end
if strokeA then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
end
if doRing then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
Theme.A.focus, B.ringWidth, radius + B.ringPad)
elseif glowA then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
glowA, B.ringWidth, radius + B.ringPad)
end
local fname = opts.font or ((face == "chip") and "micro" or "button")
local ty = y + (h - Kit.textHeight(fname)) / 2
local image = opts.image
local drawFn = opts.drawFn
local letter = opts.letter
local hasLabel = label and label ~= ""
local bold = opts.bold
if bold == nil then bold = face ~= "tab" end
if image then
local box = h
local boxX, boxY = x, y
if not hasLabel then
box = math.min(w, h)
boxX = x + (w - box) / 2
boxY = y + (h - box) / 2
end
local iw, ih = image:getDimensions()
local pad = math.floor(box * (opts.iconPad or B.iconPad))
local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih)
if invert then Theme.col(PAL.inverse, 1)
else Theme.col(PAL.ink, B.iconRestA) end
G.draw(image, Theme.snap(boxX + (box - iw * s) / 2),
Theme.snap(boxY + (box - ih * s) / 2), 0, s, s)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif drawFn then
drawFn(x, y, w, h, invert or hot or focused)
elseif letter then
Kit.textCenter(fname, letter, x, ty, h, ink)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif hasLabel then
local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale)
if opts.align == "left" then
local lx = x + B.labelPad * Kit.scale
if bold then Kit.textBold(fname, shown, lx, ty, ink)
else Kit.text(fname, shown, lx, ty, ink) end
elseif bold then
Kit.textCenterBold(fname, shown, x, ty, w, ink)
else
Kit.textCenter(fname, shown, x, ty, w, ink)
end
end
end
if not enabled then return false end
@@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts)
and Kit._activateId == opts.id)
end
-- A small square control: +/- steppers, arrow cyclers, the row X.
function Kit.stepper(x, y, w, h, glyph, opts)
opts = opts or {}
opts.kind = opts.kind or "ghost"
@@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
return Kit.button(x, y, w, h, glyph, opts)
end
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it.
local CHIP_OPTS = { face = "chip", font = "micro" }
function Kit.chip(x, y, w, h, label, on, color, id)
audit("control", x, y, w, h, label)
local focused = id and Kit.focusable(id, x, y, w, h) or false
local c = color or PAL.line
if G then
local hot = focused or Kit.hover(x, y, w, h)
if on then
Theme.fillRounded(x, y, w, h, c, 1)
Theme.emboss(x, y, w, h, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse)
else
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
Theme.strokeRounded(x, y, w, h, c,
hot and Theme.A.focus or Theme.A.hover, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, c)
end
if hot then
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
end
end
return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
CHIP_OPTS.active = on
CHIP_OPTS.color = color
CHIP_OPTS.id = id
return Kit.button(x, y, w, h, label, CHIP_OPTS)
end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
+95 -20
View File
@@ -35,8 +35,8 @@ local PAL = {
-- it in the same hue, so a card reads as a raised object rather than as an
-- outline drawn on the page. These are still flat fills -- the depth comes
-- from the value step plus Theme.shadow, not from a gradient.
field = { 16, 8, 10 }, -- the page BEHIND the cards
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
field = { 20, 20, 20 }, -- the page BEHIND the cards
bg = { 20, 20, 20 }, -- light black
surface = { 28, 21, 24 }, -- card interiors
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
raised = { 44, 34, 38 }, -- hover feedback
@@ -82,6 +82,67 @@ Theme.A = {
disabled = 0.30,
}
Theme.BUTTON = {
radius = 8,
ringPad = 2,
ringWidth = 2,
labelInset = 16,
labelPad = 10,
iconPad = 0.24,
letterGap = 4,
disabledA = 0.45,
embossHot = 1.3,
embossRest = 1,
embossDisabled = 0.4,
glowHz = 3,
glowBase = 0.25,
glowAmp = 0.75,
iconRestA = 0.85,
}
Theme.CARD = {
radius = 14,
shadow = true,
fill = PAL.surface,
fillA = 1,
stroke = PAL.line,
strokeA = Theme.A.hairline,
}
Theme.CARD_VARIANT = {
emphasis = { strokeA = Theme.A.focus },
muted = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false,
},
mutedHot = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false,
},
empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" },
warn = {
fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
row = {
fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
rowHover = {
fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
rowSelected = {
fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl",
},
hairline = {
fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
badge = {
fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = 0,
},
}
local G = love and love.graphics or nil
local has = {}
@@ -123,11 +184,11 @@ end
-- Controls get the smaller one, containers the larger, so a button never
-- looks like a card and a card never looks like a button.
function Theme.radius()
return 8
return Theme.BUTTON.radius
end
function Theme.cardRadius()
return 14
return Theme.CARD.radius
end
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
@@ -206,28 +267,42 @@ end
-- The design's only container: a rounded surface a few values above the
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
-- outline to full white (used for the focused/active card).
function Theme.card(x, y, w, h, emphasis)
local r = Theme.cardRadius()
Theme.shadow(x, y, w, h, r)
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
function Theme.card(x, y, w, h, variant)
local spec = Theme.CARD
local v
if variant == true then
v = Theme.CARD_VARIANT.emphasis
elseif type(variant) == "string" then
v = Theme.CARD_VARIANT[variant]
elseif type(variant) == "table" then
v = variant
end
local radius = (v and v.radius) or spec.radius
if radius == "ctl" then radius = Theme.BUTTON.radius end
local fill = (v and v.fill) or spec.fill
local fillA = spec.fillA
if v and v.fillA ~= nil then fillA = v.fillA end
local stroke = (v and v.stroke) or spec.stroke
local strokeA = spec.strokeA
if v and v.strokeA ~= nil then strokeA = v.strokeA end
local shadow = spec.shadow
if v and v.shadow ~= nil then shadow = v.shadow end
local strokeW = (v and v.strokeW) or 1
if fillA > 0 and fill then
if shadow then Theme.shadow(x, y, w, h, radius) end
Theme.fillRounded(x, y, w, h, fill, fillA, radius)
end
if strokeA > 0 and stroke then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius)
end
end
-- A list row. Three states, each one rect plus one outline:
-- normal one value below the card it sits in, hairline
-- hover lifted fill, brighter hairline
-- selected WHITE fill (callers print ink = PAL.inverse over it)
function Theme.row(x, y, w, h, state)
local r = Theme.radius()
if state == "selected" then
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
Theme.card(x, y, w, h, "rowSelected")
return PAL.inverse
end
Theme.fillRounded(x, y, w, h,
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
return PAL.text
end