From a9269504e2de3f416879818a13fe3933df58a2a2 Mon Sep 17 00:00:00 2001 From: Nighthawk42 <6307495+Nighthawk42@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:49:19 -0400 Subject: [PATCH] feat: add session-bound gameplay HUD --- Bejeweled/Bejeweled_Mainline.toc | 1 + Bejeweled/Core/Init.lua | 2 + Bejeweled/UI/HUD.lua | 528 +++++++++++++++++++++++++++++++ README.md | 6 +- docs/api-baseline.md | 2 + docs/architecture.md | 4 +- tools/test-runtime.lua | 172 +++++++++- tools/verify-runtime.ps1 | 5 +- 8 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 Bejeweled/UI/HUD.lua diff --git a/Bejeweled/Bejeweled_Mainline.toc b/Bejeweled/Bejeweled_Mainline.toc index 78fd874..5d031f3 100644 --- a/Bejeweled/Bejeweled_Mainline.toc +++ b/Bejeweled/Bejeweled_Mainline.toc @@ -20,3 +20,4 @@ Engine\Session.lua UI\Backdrops.lua UI\GemPool.lua UI\Animations.lua +UI\HUD.lua diff --git a/Bejeweled/Core/Init.lua b/Bejeweled/Core/Init.lua index dee4719..4196f62 100644 --- a/Bejeweled/Core/Init.lua +++ b/Bejeweled/Core/Init.lua @@ -18,6 +18,7 @@ function addon:Initialize(accountData, profileData) assert(self.Backdrops, "Backdrops module is not loaded") assert(self.GemPool, "GemPool module is not loaded") assert(self.Animations, "Animations module is not loaded") + assert(self.HUD, "HUD module is not loaded") assert(self.Input, "Input module is not loaded") assert(self.Session, "Session module is not loaded") @@ -27,6 +28,7 @@ function addon:Initialize(accountData, profileData) self.backdrops = self.Backdrops self.gemPoolFactory = self.GemPool self.animationFactory = self.Animations + self.hudFactory = self.HUD self.inputFactory = self.Input self.sessionFactory = self.Session self.initialized = true diff --git a/Bejeweled/UI/HUD.lua b/Bejeweled/UI/HUD.lua new file mode 100644 index 0000000..90e63f7 --- /dev/null +++ b/Bejeweled/UI/HUD.lua @@ -0,0 +1,528 @@ +local _, addon = ... + +local Constants = assert(addon.Constants, "Constants module is not loaded") +local Backdrops = assert(addon.Backdrops, "Backdrops module is not loaded") + +local HUD = {} +HUD.__index = HUD + +local DEFAULT_WIDTH = Constants.GRID_WIDTH * Constants.GEM_WIDTH +local DEFAULT_STATUS_DURATION = 2.5 +local DEFAULT_ACHIEVEMENT_DURATION = 4 +local FONT_PATH = Constants.IMAGE_ROOT .. "Contb___.ttf" + +local SESSION_CALLBACKS = { + onPauseChanged = "OnPauseChanged", + onRestored = "OnRestored", + onLevelTransitionStarted = "OnLevelTransitionStarted", + onLevelTransitionComplete = "OnLevelTransitionComplete", + onGameOverStarted = "OnGameOverStarted", + onGameOverComplete = "OnGameOverComplete", +} + +local INPUT_CALLBACKS = { + onSelectionChanged = "OnSelectionChanged", + onMoveStarted = "OnMoveStarted", + onCascadeResolved = "OnCascadeResolved", + onMoveComplete = "OnMoveComplete", +} + +local function CopyTable(source) + local copy = {} + for key, value in pairs(source or {}) do + copy[key] = value + end + return copy +end + +local function Clamp(value, minimum, maximum) + if value < minimum then + return minimum + elseif value > maximum then + return maximum + end + return value +end + +local function FormatInteger(value) + local text = tostring(math.floor(value or 0)) + local sign = "" + if string.sub(text, 1, 1) == "-" then + sign = "-" + text = string.sub(text, 2) + end + local formatted = text + while true do + local nextText, substitutions = string.gsub(formatted, "^(%d+)(%d%d%d)", "%1,%2") + formatted = nextText + if substitutions == 0 then + break + end + end + return sign .. formatted +end + +local function FormatDuration(seconds) + seconds = math.max(0, math.ceil(seconds or 0)) + local minutes = math.floor(seconds / 60) + local remainder = seconds - minutes * 60 + return string.format("%d:%02d", minutes, remainder) +end + +local function FormatMultiplier(multiplier) + multiplier = multiplier or 1 + if multiplier == math.floor(multiplier) then + return string.format("%dx", multiplier) + end + return string.format("%.1fx", multiplier) +end + +local function SetFrameSize(frame, width, height) + frame:SetWidth(width) + frame:SetHeight(height) +end + +local function SetTextureSize(texture, width, height) + texture:SetWidth(width) + texture:SetHeight(height) +end + +local function ResolveFrameLevel(parent, offset) + if parent and type(parent.GetFrameLevel) == "function" then + return parent:GetFrameLevel() + offset + end + return nil +end + +local function CreateFontString(frame, size, text, color, justify) + assert(type(frame.CreateFontString) == "function", "HUD frame cannot create font strings") + local fontString = frame:CreateFontString(nil, "OVERLAY") + assert(fontString:SetFont(FONT_PATH, size, "OUTLINE"), "bundled HUD font could not be loaded") + fontString:SetText(text or "") + fontString:SetTextColor(color[1], color[2], color[3], color[4] or 1) + if justify and type(fontString.SetJustifyH) == "function" then + fontString:SetJustifyH(justify) + end + return fontString +end + +local function ChainCallback(target, callbackName, callback) + local existing = target[callbackName] + assert(existing == nil or type(existing) == "function", callbackName .. " must be a function") + target[callbackName] = function(result, owner) + callback(result, owner) + if existing then + return existing(result, owner) + end + end +end + +function HUD:CreateBackdropFrame(parent, preset, width, height, levelOffset) + local frame = Backdrops:CreateFrame({ + parent = parent, + preset = preset, + createFrame = self.createFrame, + backgroundColor = { 0.05, 0.05, 0.05, 0.9 }, + }) + SetFrameSize(frame, width, height) + local frameLevel = ResolveFrameLevel(parent, levelOffset or 1) + if frameLevel then + frame:SetFrameLevel(frameLevel) + end + return frame +end + +function HUD:CreateStatusBar() + local statusBar = self:CreateBackdropFrame(self.parent, "slider", self.width, 32, 2) + statusBar:SetPoint("TOPLEFT", self.parent, "BOTTOMLEFT", 0, 0) + + local levelPanel = self:CreateBackdropFrame(statusBar, "level", 90, 32, 1) + levelPanel:SetPoint("LEFT", statusBar, "LEFT", 0, 0) + levelPanel.caption = CreateFontString(levelPanel, 10, "LVL", { 0.07, 0.67, 1, 1 }, "LEFT") + levelPanel.caption:SetPoint("LEFT", levelPanel, "LEFT", 8, 1) + levelPanel.value = CreateFontString(levelPanel, 15, "1", { 1, 1, 1, 1 }, "RIGHT") + levelPanel.value:SetPoint("RIGHT", levelPanel, "RIGHT", -8, 1) + + local dataPanel = self:CreateBackdropFrame(statusBar, "level", 110, 32, 1) + dataPanel:SetPoint("LEFT", levelPanel, "RIGHT", -4, 0) + dataPanel.value = CreateFontString(dataPanel, 12, "0", { 1, 1, 1, 1 }, "CENTER") + dataPanel.value:SetPoint("CENTER", dataPanel, "CENTER", 0, 1) + + local progressWidth = self.width - 90 - 110 + 8 + local progress = self:CreateBackdropFrame(statusBar, "slider", progressWidth, 32, 1) + progress:SetPoint("LEFT", dataPanel, "RIGHT", -4, 0) + progress:SetPoint("RIGHT", statusBar, "RIGHT", 0, 0) + progress.fillWidth = progressWidth - 4 + progress.fill = progress:CreateTexture(nil, "ARTWORK") + progress.fill:SetTexture(Constants.IMAGE_ROOT .. "barArt") + progress.fill:SetPoint("LEFT", progress, "LEFT", 2, -2) + SetTextureSize(progress.fill, 0.01, 22) + progress.text = CreateFontString(progress, 12, "", { 1, 1, 1, 1 }, "CENTER") + progress.text:SetPoint("CENTER", progress, "CENTER", 0, 1) + + self.statusBar = statusBar + self.levelPanel = levelPanel + self.dataPanel = dataPanel + self.progress = progress +end + +function HUD:CreateOverlay(width, height, yOffset, fontSize, color) + local frame = self:CreateBackdropFrame(self.parent, "panel", width, height, 8) + frame:SetPoint("CENTER", self.parent, "CENTER", 0, yOffset or 0) + frame.text = CreateFontString(frame, fontSize, "", color, "CENTER") + frame.text:SetPoint("CENTER", frame, "CENTER", 0, 0) + frame.text:SetWidth(width - 20) + frame.text:SetHeight(height - 12) + frame:Hide() + return frame +end + +function HUD:New(parent, animations, options) + assert(parent ~= nil, "HUD parent is required") + assert( + type(animations) == "table" + and type(animations.ShowHint) == "function" + and type(animations.HideHint) == "function" + and type(animations.PlayFloatingText) == "function", + "HUD requires animation presentation services" + ) + options = options or {} + assert(type(options) == "table", "HUD options must be a table") + local instance = setmetatable({ + parent = parent, + animations = animations, + createFrame = options.createFrame or CreateFrame, + width = options.width or DEFAULT_WIDTH, + statusDuration = options.statusDuration or DEFAULT_STATUS_DURATION, + achievementDuration = options.achievementDuration or DEFAULT_ACHIEVEMENT_DURATION, + hintsEnabled = options.hintsEnabled, + statusRemaining = nil, + achievementRemaining = nil, + statusVisible = false, + presentedSkillEvents = {}, + session = nil, + }, self) + assert(type(instance.createFrame) == "function", "CreateFrame is unavailable for HUD") + assert(type(instance.width) == "number" and instance.width >= 300, "HUD width must be at least 300") + assert(type(instance.statusDuration) == "number" and instance.statusDuration > 0, "HUD status duration must be positive") + assert(type(instance.achievementDuration) == "number" and instance.achievementDuration > 0, "HUD achievement duration must be positive") + assert( + instance.hintsEnabled == nil + or type(instance.hintsEnabled) == "boolean" + or type(instance.hintsEnabled) == "function", + "HUD hint setting must be Boolean or a provider" + ) + + instance:CreateStatusBar() + instance.statusFrame = instance:CreateOverlay(instance.width - 40, 72, 25, 28, { 1, 0.85, 0, 1 }) + instance.achievementFrame = instance:CreateOverlay(instance.width - 30, 46, 100, 16, { 1, 0.85, 0, 1 }) + instance.pausedFrame = instance:CreateOverlay(instance.width - 80, 90, 20, 40, { 1, 0.85, 0, 1 }) + instance.pausedFrame.text:SetText("Paused") + instance.summaryFrame = instance:CreateOverlay(instance.width - 40, 245, 10, 16, { 1, 1, 1, 1 }) + return instance +end + +function HUD:AreHintsEnabled() + if type(self.hintsEnabled) == "function" then + return self.hintsEnabled() and true or false + end + return self.hintsEnabled ~= false +end + +function HUD:SetProgress(ratio, red, green, blue) + ratio = Clamp(ratio or 0, 0, 1) + self.progress.fill:SetWidth(math.max(0.01, self.progress.fillWidth * ratio)) + self.progress.fill:SetVertexColor(red, green, blue, 1) + self.progress.ratio = ratio +end + +function HUD:Refresh(session) + session = session or self.session + if not session then + return false + end + local state = session.scoringState + if session.gameMode == Constants.GAME_MODE_CLASSIC then + self.levelPanel.caption:SetText("LVL") + self.levelPanel.caption:SetTextColor(0.07, 0.67, 1, 1) + self.levelPanel.value:SetText(tostring(state.level)) + self.dataPanel.value:SetText(FormatInteger(state.score)) + self.progress.text:SetText("") + local ratio = state.pointsToLevelUp > 0 and state.score / state.pointsToLevelUp or 0 + if state.levelPending then + ratio = 1 + end + self:SetProgress(ratio, 0, 0.5, 1) + else + self.levelPanel.caption:SetText("PPS") + self.levelPanel.caption:SetTextColor(0, 1, 0, 1) + local pps = session.timerElapsed > 0 and state.score / session.timerElapsed or 0 + self.levelPanel.value:SetText(string.format("%.2f", pps)) + self.dataPanel.value:SetText(FormatMultiplier(state.pointMultiplier)) + if session.timeLimit then + local remaining = math.max(0, session.timeLimit - session.timerElapsed) + self.progress.text:SetText(FormatDuration(remaining)) + self:SetProgress(remaining / session.timeLimit, 0, 1, 0) + else + self.progress.text:SetText("Timing") + self:SetProgress(1, 0, 1, 0) + end + end + return true +end + +function HUD:ShowStatus(text, duration, color) + assert(type(text) == "string" and text ~= "", "HUD status text is required") + self.statusFrame.text:SetText(text) + if color then + self.statusFrame.text:SetTextColor(color[1], color[2], color[3], color[4] or 1) + else + self.statusFrame.text:SetTextColor(1, 0.85, 0, 1) + end + self.statusRemaining = duration + self.statusVisible = true + self.statusFrame:Show() + return text +end + +function HUD:HideStatus() + local shown = self.statusVisible + self.statusRemaining = nil + self.statusVisible = false + self.statusFrame:Hide() + return shown and true or false +end + +function HUD:ShowAchievement(text, duration) + assert(type(text) == "string" and text ~= "", "HUD achievement text is required") + self.achievementFrame.text:SetText(text) + self.achievementRemaining = duration or self.achievementDuration + self.achievementFrame:Show() + return text +end + +function HUD:DescribeSkillEvent(event) + if type(event) ~= "table" or (event.gained or 0) <= 0 then + return nil + end + if event.rankUp then + return "Rank up: " .. tostring(event.rankAfter) + elseif event.completed and event.type == Constants.SKILL_TYPE_ACHIEVEMENT then + return "Achievement unlocked #" .. tostring(event.index) + elseif event.completed and event.type == Constants.SKILL_TYPE_FUN then + return "Feat unlocked #" .. tostring(event.index) + end + return "Skill +" .. tostring(event.gained) +end + +function HUD:PresentSkillEvents(events) + local presented = 0 + for index = 1, #(events or {}) do + local event = events[index] + local message = self:DescribeSkillEvent(event) + local eventKey = message and table.concat({ + tostring(event.type), + tostring(event.index), + tostring(event.pointsAfter), + tostring(event.gained), + tostring(event.completed), + }, ":") or nil + if message and not self.presentedSkillEvents[eventKey] then + self.presentedSkillEvents[eventKey] = true + presented = presented + 1 + self:ShowAchievement(message) + self.animations:PlayFloatingText(105, 250, message, Constants.HYPER_CONTENTS, true) + end + end + return presented +end + +function HUD:ScheduleHint() + local session = self.session + self.animations:HideHint() + if not session + or not session.active + or session.paused + or session:IsLocked() + or not self:AreHintsEnabled() then + return false + end + local first = session.grid:FindLegalMove() + if not first then + return false + end + self.animations:ShowHint(first.gridX, first.gridY) + return true +end + +function HUD:ShowSummary(result) + assert(type(result) == "table", "HUD summary requires a game-over result") + local metricLabel = result.metricName == "points-per-second" and "Points/sec" or "Score" + local metric = result.metricName == "points-per-second" + and string.format("%.2f", result.metric or 0) + or FormatInteger(result.metric or result.score) + local best = result.personalBest and result.personalBest.best + local lines = { + "Game Over", + metricLabel .. ": " .. metric, + "Time: " .. FormatDuration(result.elapsed), + "Level: " .. tostring(result.level), + "Largest cascade: " .. tostring(result.largestCascade), + "Largest combo: " .. tostring(result.largestCombo), + "Moves: " .. tostring(result.moves), + } + if best ~= nil then + lines[#lines + 1] = "Personal best: " .. (result.metricName == "points-per-second" + and string.format("%.2f", best) + or FormatInteger(best)) + end + self.summaryFrame.text:SetText(table.concat(lines, "\n")) + self.summaryFrame:Show() + return lines +end + +function HUD:OnPauseChanged(result, session) + self.session = session or self.session + if result.status == "paused" then + self.pausedFrame:Show() + self.animations:HideHint() + else + self.pausedFrame:Hide() + self:ScheduleHint() + end + self:Refresh() +end + +function HUD:OnRestored(result, session) + self.session = session or self.session + self.summaryFrame:Hide() + self:Refresh() + self:ScheduleHint() +end + +function HUD:OnSelectionChanged(result) + self.animations:HideHint() + if result.status == "cleared" and result.reason ~= "swap" then + self:ScheduleHint() + end +end + +function HUD:OnMoveStarted(result) + self.animations:HideHint() + self:PresentSkillEvents(result.moveResult and result.moveResult.skillEvents) +end + +function HUD:OnCascadeResolved(result) + local scoring = result.scoringResult + if scoring then + if scoring.points > 0 then + local firstEvent = scoring.scoreEvents[1] + local contents = firstEvent and firstEvent.contents or Constants.HYPER_CONTENTS + self.animations:PlayFloatingText(175, 200, "+" .. tostring(scoring.points), contents, false) + end + self:PresentSkillEvents(scoring.skillEvents) + end + self:Refresh() +end + +function HUD:OnMoveComplete(result) + self:Refresh() + if result.status == "complete" then + self:ScheduleHint() + end +end + +function HUD:OnLevelTransitionStarted(result, session) + self.session = session or self.session + self.animations:HideHint() + self:ShowStatus(result.kind == "level-up" and "Level up" or "Multiplier up", self.statusDuration) + self:PresentSkillEvents(result.skillEvents) + self:Refresh() +end + +function HUD:OnLevelTransitionComplete(result, session) + self.session = session or self.session + self:ShowStatus(result.kind == "level-up" and "Level " .. tostring(result.level) + or "Multiplier " .. FormatMultiplier(result.pointMultiplier), self.statusDuration) + self:PresentSkillEvents(result.skillEvents) + self:Refresh() + self:ScheduleHint() +end + +function HUD:OnGameOverStarted(result, session) + self.session = session or self.session + self.animations:HideHint() + self:ShowStatus(result.kind == "no-more-moves" and "No More Moves" or "Time Up", nil, { 1, 0.2, 0.2, 1 }) + self:PresentSkillEvents(result.skillEvents) + self:Refresh() +end + +function HUD:OnGameOverComplete(result, session) + self.session = session or self.session + self:HideStatus() + self:PresentSkillEvents(result.skillEvents) + self:ShowSummary(result) + self:Refresh() +end + +function HUD:CreateSessionOptions(options) + local prepared = CopyTable(options) + prepared.inputOptions = CopyTable(options and options.inputOptions) + for callbackName, methodName in pairs(SESSION_CALLBACKS) do + ChainCallback(prepared, callbackName, function(result, session) + self[methodName](self, result, session) + end) + end + for callbackName, methodName in pairs(INPUT_CALLBACKS) do + ChainCallback(prepared.inputOptions, callbackName, function(result, input) + self[methodName](self, result, input) + end) + end + return prepared +end + +function HUD:AttachSession(session) + assert( + type(session) == "table" + and type(session.AdvanceElapsed) == "function" + and type(session.GetInput) == "function", + "HUD requires a session" + ) + self.session = session + self.summaryFrame:Hide() + if session:IsPaused() then + self.pausedFrame:Show() + else + self.pausedFrame:Hide() + end + self:Refresh(session) + self:ScheduleHint() + return session +end + +function HUD:CreateSession(grid, gemPool, options) + local Session = assert(addon.Session, "Session module is not loaded") + local session = Session:New(grid, gemPool, self.animations, self:CreateSessionOptions(options)) + return self:AttachSession(session) +end + +function HUD:Update(elapsed) + assert(type(elapsed) == "number" and elapsed >= 0, "HUD elapsed time must be nonnegative") + self:Refresh() + if self.statusRemaining then + self.statusRemaining = self.statusRemaining - elapsed + if self.statusRemaining <= 0 then + self:HideStatus() + end + end + if self.achievementRemaining then + self.achievementRemaining = self.achievementRemaining - elapsed + if self.achievementRemaining <= 0 then + self.achievementRemaining = nil + self.achievementFrame:Hide() + end + end + return self.session ~= nil +end + +addon.HUD = HUD diff --git a/README.md b/README.md index f23f45f..792da89 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Bejeweled modernization -This branch contains the analysis-complete, Mainline-first modernization of the legacy World of Warcraft Bejeweled addon. Its Retail TOC and headless runtime foundation are installable for development, but the addon is not yet playable because HUD and menu integration still need to be restored. +This branch contains the analysis-complete, Mainline-first modernization of the legacy World of Warcraft Bejeweled addon. Its Retail TOC and headless runtime foundation are installable for development, but the addon is not yet playable because the main window and menu/session integration still need to be restored. ## Status and phase gate The preserved 8,401-line Mainline source must be analyzed sequentially, in evidence-backed batches, before runtime work begins. All behavior-critical shortened symbols must be resolved and all batches must be complete before any public Lua API, runtime module, modern TOC, packaging, or release work is added. -All 17 batches (lines 1–8,401) are documented, the cross-batch identifier audit has no remaining `working` or `unresolved` declarations, and the retained Retail API contracts are pinned in `docs/api-baseline.md`. The analysis phase gate is closed. Runtime implementation now includes wire-compatible SavedVariables initialization, authenticated Classic restoration and personal-best persistence, deterministic grid/match/cascade/scoring transitions, pause/resume and stable-state autosave coordination, session-owned level/game-over handoffs, no-move and timed-expiry detection, click selection, validated swap/rollback sessions, skills, levels, legacy-compatible audio cue scheduling, BackdropTemplate-safe UI chrome, persistent grid-to-gem texture projection, cancellable cascade sequencing, power/hyper presentation, and pooled explosion, lightning, shard, lightwave, hint, and floating-text effects. +All 17 batches (lines 1–8,401) are documented, the cross-batch identifier audit has no remaining `working` or `unresolved` declarations, and the retained Retail API contracts are pinned in `docs/api-baseline.md`. The analysis phase gate is closed. Runtime implementation now includes wire-compatible SavedVariables initialization, authenticated Classic restoration and personal-best persistence, deterministic grid/match/cascade/scoring transitions, pause/resume and stable-state autosave coordination, session-owned level/game-over handoffs, no-move and timed-expiry detection, click selection, validated swap/rollback sessions, skills, levels, legacy-compatible audio cue scheduling, BackdropTemplate-safe UI chrome, persistent grid-to-gem texture projection, cancellable cascade sequencing, pooled board effects, and a session-bound Classic/Timed HUD with status, hints, achievements, and final summaries. ## Goal @@ -19,7 +19,7 @@ The eventual addon will target current Retail/Mainline World of Warcraft while p - `Bejeweled/sounds/` — immutable legacy sounds. - `Bejeweled/Core/` — private addon initialization, constants, supported audio playback, non-destructive SavedVariables defaulting, legacy-authenticated Classic save encoding, terminal save clearing, account game counts, and authenticated personal bests. - `Bejeweled/Engine/` — deterministic gameplay state; currently the 8×8 grid, selection/swap and pause/resume sessions, authenticated restoration, session-locked level/game-over transitions, legal moves, legacy cell encoding, stable cascade resolution, legacy score formulas, move accounting, final summary metrics, statistics, skill gains, achievements, and level thresholds. -- `Bejeweled/UI/` — Retail-safe frame/rendering boundaries; currently shared backdrop construction, board tiles, persistent gem-frame projection, recorded cascade-transition playback, and pooled legacy-cadence board effects. +- `Bejeweled/UI/` — Retail-safe frame/rendering boundaries; currently shared backdrop construction, board tiles, persistent gem-frame projection, recorded cascade-transition playback, pooled legacy-cadence board effects, and the session-bound score/timer/status HUD. - `docs/analysis/` — sequential batch reports, identifier ledger, and exact coverage schedule. - `docs/architecture.md` — module ownership and authoritative runtime data flow. - `docs/api-baseline.md` — verified Retail API constraints for implementation. diff --git a/docs/api-baseline.md b/docs/api-baseline.md index c845e5d..5780d08 100644 --- a/docs/api-baseline.md +++ b/docs/api-baseline.md @@ -10,6 +10,7 @@ The modernization baseline is the authoritative WoW UI/API source snapshot for l | Metadata | Addon metadata access is provided by `C_AddOns`. | Route future metadata queries through `C_AddOns`. | | Animation | Frames expose `CreateAnimationGroup`; groups expose `CreateAnimation`, `Play`, `Pause`, `IsPaused`, `Stop`, and `SetScript`; animations expose `SetOrder`; translation animations expose `SetOffset`; alpha animations expose `SetFromAlpha` and `SetToAlpha`. | `UI/Animations.lua` reuses per-frame groups for cascades, pauses/resumes active groups with the session, and uses ordered translation pairs for legacy invalid-swap rollback. Completion scripts are removed before cancellation. | | Texture rendering | `SimpleFrame:CreateTexture` returns a texture region. `SimpleTextureBase` exposes `SetTexture`, four-coordinate `SetTexCoord`, `SetBlendMode`, and `SetRotation(radians, optionalPoint)` in all environments. | `UI/GemPool.lua` projects addon-owned textures and proven legacy UV coordinates. `UI/Animations.lua` uses supported texture rotation instead of the legacy eight-coordinate rotation helper. | +| HUD text | `SimpleFrame:CreateFontString` returns a non-nil `SimpleFontString`. Its current all-environment contract exposes `SetFont`, `SetText`, `SetFormattedText`, `SetTextColor`, and horizontal justification; `SetFont` reports whether the asset and height were accepted. | `UI/HUD.lua` uses the immutable bundled font for score, timer, status, achievement, pause, and summary text, and treats a failed font load as a construction error. | | Line rendering | `SimpleFrame:CreateLine` returns `SimpleLine`; its current all-environment contract exposes `ClearAllPoints`, `SetStartPoint`, `SetEndPoint`, and `SetThickness`. Line regions inherit texture-region operations used here for addon texture/color, additive blending, alpha, and visibility. | `UI/Animations.lua` replaces the removed legacy `DrawRouteLine` call with pooled, board-relative lightning lines and preserves the 15-tick alternating-highlight lifetime. | | Addon compartment | Click uses `func(addonName, buttonName, menuButtonFrame)`; hover uses `funcOnEnter(addonName, menuButtonFrame)` and `funcOnLeave(addonName, menuButtonFrame)`. | `UI/Compartment.lua` will implement those distinct current contracts without assuming one shared parameter list. | | Addon lifecycle | `ADDON_LOADED` is synchronous and supplies `addOnName` followed by `containsBindings`. Frames expose `RegisterEvent(eventName)` and `UnregisterEvent(eventName)` in all environments. | `Core/Init.lua` filters the event by addon name, unregisters its one-shot listener, and performs idempotent initialization after every TOC module is loaded. | @@ -26,6 +27,7 @@ API existence alone does not prove behavioral equivalence. Each future substitut - Addon lifecycle and frame-event contracts: [`AddOnsDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/AddOnsDocumentation.lua) and [`SimpleFrameAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleFrameAPIDocumentation.lua) - Supported SoundKit identifiers and current `PlaySound(SOUNDKIT.*)` usage: [`SoundKitConstants.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_SharedXML/Mainline/SoundKitConstants.lua) and [`LootFrame.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_UIPanels_Game/Mainline/LootFrame.lua) - Frame texture construction and rendering methods: [`SimpleFrameAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleFrameAPIDocumentation.lua) and [`SimpleTextureBaseAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleTextureBaseAPIDocumentation.lua) +- HUD font-string creation and text/font operations: [`SimpleFrameAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleFrameAPIDocumentation.lua) and [`SimpleFontStringAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleFontStringAPIDocumentation.lua) - Line construction and endpoint/thickness contracts: [`SimpleFrameAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleFrameAPIDocumentation.lua), [`SimpleLineAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleLineAPIDocumentation.lua), and [`SimpleTextureBaseAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleTextureBaseAPIDocumentation.lua) - Animation-group lifecycle, translation, and alpha contracts: [`SimpleAnimGroupAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleAnimGroupAPIDocumentation.lua), [`SimpleAnimTranslationAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleAnimTranslationAPIDocumentation.lua), and [`SimpleAnimAlphaAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleAnimAlphaAPIDocumentation.lua) - Animation ordering: [`SimpleAnimAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleAnimAPIDocumentation.lua) diff --git a/docs/architecture.md b/docs/architecture.md index d896769..8e79b24 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Target architecture (post-analysis) -The analysis phase gate is satisfied. This ownership map now governs runtime implementation; currently implemented modules are `Core/Init.lua`, `Core/Constants.lua`, `Core/Audio.lua`, `Core/SavedVariables.lua`, `Engine/Grid.lua`, `Engine/Matches.lua`, `Engine/Cascade.lua`, `Engine/Scoring.lua`, `Engine/Input.lua`, `Engine/Session.lua`, `UI/Backdrops.lua`, `UI/GemPool.lua`, and `UI/Animations.lua`. +The analysis phase gate is satisfied. This ownership map now governs runtime implementation; currently implemented modules are `Core/Init.lua`, `Core/Constants.lua`, `Core/Audio.lua`, `Core/SavedVariables.lua`, `Engine/Grid.lua`, `Engine/Matches.lua`, `Engine/Cascade.lua`, `Engine/Scoring.lua`, `Engine/Input.lua`, `Engine/Session.lua`, `UI/Backdrops.lua`, `UI/GemPool.lua`, `UI/Animations.lua`, and `UI/HUD.lua`. ## Load order and ownership @@ -17,7 +17,7 @@ The analysis phase gate is satisfied. This ownership map now governs runtime imp 11. `UI/Backdrops.lua` — backdrop-compatible frame construction and fresh-copy presets for tooltip, window, panel, slider, and level-border chrome. Every constructed frame explicitly inherits `BackdropTemplate`. 12. `UI/GemPool.lua` — fixed allocation and reuse of the 64 interactive gem frames, the sixteen board-art tiles, input-handler attachment, selection projection, and change-aware projection from authoritative grid cells into normal/hyper texture layers. Power-gem overlays remain `UI/Animations.lua` ownership. 13. `UI/Animations.lua` — deterministic swap/rollback and clear/gravity/refill plans, reusable animation groups, session-controlled pause/resume, interaction locking, cancellation, and final-grid normalization. It also owns the legacy-cadence 40-frame hyper atlas, counter-rotating/cross-faded power layers, pooled 16-frame explosion atlas, and pooled 15-tick lightning lines that gate settling. Nonblocking effects share the same pausable 25 ms clock: every cleared gem emits a ten-shard burst, ambient lightwaves propagate across active boards, hints delay and bounce above a selected cell, and reusable floating text supports score and status presentation. Current fall timings are explicit modernization defaults pending in-game tuning. -14. `UI/HUD.lua` — score, timer, level, status, hint, and achievement presentation. +14. `UI/HUD.lua` — BackdropTemplate-safe Classic score/level/progress and Timed points-per-second/multiplier/countdown presentation. It consumes copied input/session callbacks, schedules idle hints, emits score and skill floating text through `UI/Animations.lua`, owns temporary status/achievement and pause overlays, and renders terminal summaries. Its `Update` method only refreshes presentation and expiry clocks; `Engine/Session.lua` remains the sole owner of elapsed time and gameplay transitions. 15. `UI/Compartment.lua` — addon-compartment click and hover callbacks. ## Data flow diff --git a/tools/test-runtime.lua b/tools/test-runtime.lua index 0681830..e687d2a 100644 --- a/tools/test-runtime.lua +++ b/tools/test-runtime.lua @@ -52,6 +52,7 @@ LoadAddonFile("Bejeweled/Engine/Session.lua", addon) LoadAddonFile("Bejeweled/UI/Backdrops.lua", addon) LoadAddonFile("Bejeweled/UI/GemPool.lua", addon) LoadAddonFile("Bejeweled/UI/Animations.lua", addon) +LoadAddonFile("Bejeweled/UI/HUD.lua", addon) AssertEqual(eventFrame.registeredEvent, "ADDON_LOADED", "initializer event registration") AssertEqual(addon.Constants.GRID_WIDTH, 8, "grid width") @@ -172,6 +173,9 @@ local function CreateMockFontString(layer) self.font = { path, size, flags } return true end + function fontString:SetJustifyH(justify) + self.justifyH = justify + end return fontString end @@ -302,6 +306,15 @@ local function CreateGemPoolFrame(frameType, name, parent, template) function frame:SetAlpha(alpha) self.alpha = alpha end + function frame:SetBackdrop(descriptor) + self.descriptor = descriptor + end + function frame:SetBackdropColor(red, green, blue, alpha) + self.backgroundColor = { red, green, blue, alpha } + end + function frame:SetBackdropBorderColor(red, green, blue, alpha) + self.borderColor = { red, green, blue, alpha } + end function frame:SetFrameLevel(frameLevel) self.frameLevel = frameLevel end @@ -1489,8 +1502,162 @@ timedPool:SetInteractive(true) timedSession:GetInput():SetSessionLocked(true, "terminal-refresh") assert(not timedPool:GetFrame(1, 1).mouseEnabled, "idempotent terminal lock did not reassert interaction state") end + +function addon:TestHUDPresentationForTest() +local hudGrid = addon.Grid:New() +FillStablePattern(hudGrid) +hudGrid:Set(2, 8, 1) +hudGrid:Set(3, 8, 1) +hudGrid:Set(1, 7, 1) +assert(hudGrid:FindLegalMove(), "HUD fixture has no hintable move") +local hudPool = addon.GemPool:New(gemPoolParent, { + createFrame = CreateGemPoolFrame, + createBoardTiles = false, +}) +hudPool:Project(hudGrid, true) +local hudAnimations = addon.Animations:New(hudPool, { + createFrame = CreateGemPoolFrame, + hintDelay = 0.05, +}) +local hudProfile = addon.SavedVariables:CreateDefaultProfile() +local hudState = addon.Scoring:NewState(addon.Constants.GAME_MODE_CLASSIC, { + score = 12345, + level = 3, + pointsToLevelUp = 16000, + pointMultiplier = 2, + largestCascade = 7, + largestCombo = 4, + moves = 20, +}) +local userPauseEvents = 0 +local userLevelEvents = 0 +local hud = addon.HUD:New(gemPoolParent, hudAnimations, { + createFrame = CreateGemPoolFrame, + statusDuration = 0.1, + achievementDuration = 0.1, +}) +local hudSession = hud:CreateSession(hudGrid, hudPool, { + profile = hudProfile, + playerName = "Nighthawk", + scoringState = hudState, + autoSave = false, + detectGameOver = false, + deferLevelTransitions = true, + deferGameOverTransitions = true, + inputOptions = { + random = MakeRandom(9201), + requireLegalMove = false, + }, + onPauseChanged = function() + userPauseEvents = userPauseEvents + 1 + end, + onLevelTransitionComplete = function() + userLevelEvents = userLevelEvents + 1 + end, +}) +assert(hud.session == hudSession, "HUD did not attach its created session") +AssertEqual(hud.levelPanel.caption.text, "LVL", "classic HUD level caption") +AssertEqual(hud.levelPanel.value.text, "3", "classic HUD level value") +AssertEqual(hud.dataPanel.value.text, "12,345", "classic HUD formatted score") +AssertEqual(hud.progress.ratio, 12345 / 16000, "classic HUD progress ratio") +assert(hudAnimations.hint and hudAnimations.hint.active, "HUD did not schedule an idle hint") + +hudSession:Pause("hud-test") +assert(hud.pausedFrame.shown, "paused HUD overlay remained hidden") +assert(not hudAnimations.hint.active, "paused HUD retained its hint") +hudSession:Resume("hud-test") +assert(not hud.pausedFrame.shown, "resumed HUD retained its pause overlay") +assert(hudAnimations.hint.active, "resumed HUD did not reschedule its hint") +AssertEqual(userPauseEvents, 2, "HUD callback wiring replaced user pause callbacks") + +local skillEvent = { + type = addon.Constants.SKILL_TYPE_ACHIEVEMENT, + index = addon.Constants.ACHIEVEMENT_POWER100, + gained = 5, + completed = true, + pointsAfter = 80, +} +local floatingBefore = #hudAnimations.activeFloatingText +hud:OnCascadeResolved({ + scoringResult = { + points = 75, + scoreEvents = { { contents = 3 } }, + skillEvents = { skillEvent }, + }, +}) +AssertEqual(#hudAnimations.activeFloatingText, floatingBefore + 2, "HUD omitted score or achievement floating text") +AssertEqual(hud.achievementFrame.text.text, "Achievement unlocked #4", "HUD achievement message") +AssertEqual(hud:PresentSkillEvents({ skillEvent }), 0, "HUD repeated an already-presented skill event") +hud:Update(0.11) +assert(not hud.achievementFrame.shown, "HUD achievement notice did not expire") +hudAnimations:ClearTransientEffects() + +hudState.score = hudState.pointsToLevelUp +hudState.levelPending = true +hudSession:HandleMoveComplete({ status = "complete" }) +assert(hudSession:IsLevelTransitionPending(), "HUD level fixture did not defer its transition") +AssertEqual(hud.statusFrame.text.text, "Level up", "HUD level-start status") +assert(hud.statusFrame.shown, "HUD level-start status remained hidden") +local hudLevelResult = hudSession:CompleteLevelTransition() +AssertEqual(hudLevelResult.level, 4, "HUD level transition result") +AssertEqual(hud.levelPanel.value.text, "4", "HUD did not refresh the completed level") +AssertEqual(hud.statusFrame.text.text, "Level 4", "HUD level-complete status") +AssertEqual(userLevelEvents, 1, "HUD callback wiring replaced user level callbacks") +hud:Update(0.11) +assert(not hud.statusFrame.shown, "HUD temporary status did not expire") + +local gameOverStarted = hudSession:BeginGameOver("manual-test") +AssertEqual(gameOverStarted.kind, "no-more-moves", "HUD classic game-over kind") +AssertEqual(hud.statusFrame.text.text, "No More Moves", "HUD game-over status") +hud:Update(10) +assert(hud.statusFrame.shown, "persistent HUD game-over status expired") +local hudSummary = hudSession:CompleteGameOver() +assert(hudSummary.personalBest, "HUD game-over fixture omitted personal-best data") +assert(hud.summaryFrame.shown, "HUD final summary remained hidden") +assert(string.find(hud.summaryFrame.text.text, "Score: 16,000", 1, true), "HUD summary omitted the formatted score") +assert(string.find(hud.summaryFrame.text.text, "Largest cascade: 7", 1, true), "HUD summary omitted cascade data") +assert(not hud.statusFrame.shown, "HUD final summary retained the game-over banner") + +local timedGrid = addon.Grid:New() +FillStablePattern(timedGrid) +local timedPool = addon.GemPool:New(gemPoolParent, { + createFrame = CreateGemPoolFrame, + createBoardTiles = false, +}) +timedPool:Project(timedGrid, true) +local timedAnimations = addon.Animations:New(timedPool, { createFrame = CreateGemPoolFrame }) +local timedHUD = addon.HUD:New(gemPoolParent, timedAnimations, { createFrame = CreateGemPoolFrame }) +local timedProfile = addon.SavedVariables:CreateDefaultProfile() +local timedState = addon.Scoring:NewState(addon.Constants.GAME_MODE_TIMED, { + score = 300, + pointMultiplier = 2.5, +}) +local timedSession = timedHUD:CreateSession(timedGrid, timedPool, { + profile = timedProfile, + playerName = "Nighthawk", + gameMode = addon.Constants.GAME_MODE_TIMED, + scoringState = timedState, + timerElapsed = 30, + timeLimit = 60, + detectGameOver = false, + autoSave = false, + inputOptions = { requireLegalMove = false }, +}) +AssertEqual(timedHUD.levelPanel.caption.text, "PPS", "timed HUD PPS caption") +AssertEqual(timedHUD.levelPanel.value.text, "10.00", "timed HUD PPS value") +AssertEqual(timedHUD.dataPanel.value.text, "2.5x", "timed HUD multiplier") +AssertEqual(timedHUD.progress.text.text, "0:30", "timed HUD countdown") +AssertEqual(timedHUD.progress.ratio, 0.5, "timed HUD progress ratio") +timedSession:SetElapsed(60) +timedHUD:Update(0) +AssertEqual(timedHUD.progress.text.text, "0:00", "timed HUD zero countdown") +AssertEqual(timedHUD.progress.ratio, 0, "timed HUD empty progress") +end + TestSessionRestore() TestGameOverTransitions() +addon:TestHUDPresentationForTest() +addon.TestHUDPresentationForTest = nil FillStablePattern(cascadeGrid) for x = 2, 5 do @@ -1847,6 +2014,7 @@ assert(addon.audio, "addon initialization did not create audio") assert(addon.backdrops == addon.Backdrops, "addon initialization did not install backdrops") assert(addon.gemPoolFactory == addon.GemPool, "addon initialization did not install GemPool") assert(addon.animationFactory == addon.Animations, "addon initialization did not install Animations") +assert(addon.hudFactory == addon.HUD, "addon initialization did not install HUD") assert(addon.inputFactory == addon.Input, "addon initialization did not install Input") assert(addon.sessionFactory == addon.Session, "addon initialization did not install Session") assert(eventFrame.registeredEvent == nil, "initializer event was not unregistered") @@ -1855,6 +2023,7 @@ local initializedAudio = addon.audio local initializedBackdrops = addon.backdrops local initializedGemPoolFactory = addon.gemPoolFactory local initializedAnimationFactory = addon.animationFactory +local initializedHUDFactory = addon.hudFactory local initializedInputFactory = addon.inputFactory local initializedSessionFactory = addon.sessionFactory addon:Initialize({}, {}) @@ -1863,7 +2032,8 @@ assert(addon.audio == initializedAudio, "audio initialization is not idempotent" assert(addon.backdrops == initializedBackdrops, "backdrop initialization is not idempotent") assert(addon.gemPoolFactory == initializedGemPoolFactory, "GemPool initialization is not idempotent") assert(addon.animationFactory == initializedAnimationFactory, "Animations initialization is not idempotent") +assert(addon.hudFactory == initializedHUDFactory, "HUD initialization is not idempotent") assert(addon.inputFactory == initializedInputFactory, "Input initialization is not idempotent") assert(addon.sessionFactory == initializedSessionFactory, "Session initialization is not idempotent") -print("Runtime verification passed: pause/restore/level/game-over sessions, input, cascade/effect animation, gem projection, UI backdrops, audio, SavedVariables, and deterministic gameplay engine.") +print("Runtime verification passed: Classic/Timed HUD, pause/restore/level/game-over sessions, input, cascade/effect animation, gem projection, UI backdrops, audio, SavedVariables, and deterministic gameplay engine.") diff --git a/tools/verify-runtime.ps1 b/tools/verify-runtime.ps1 index 22cf818..6ce5dc3 100644 --- a/tools/verify-runtime.ps1 +++ b/tools/verify-runtime.ps1 @@ -31,7 +31,8 @@ try { "Engine\Session.lua", "UI\Backdrops.lua", "UI\GemPool.lua", - "UI\Animations.lua" + "UI\Animations.lua", + "UI\HUD.lua" ) $actualFiles = @($toc | Where-Object { $_ -match "\.lua$" }) if (Compare-Object -ReferenceObject $expectedFiles -DifferenceObject $actualFiles -SyncWindow 0) { @@ -45,7 +46,7 @@ try { } } - Write-Output "Verified: Retail TOC order and Lua 5.1-compatible session/gameplay engine." + Write-Output "Verified: Retail TOC order and Lua 5.1-compatible session/gameplay/HUD runtime." } finally { Pop-Location