feat: add session-bound gameplay HUD

This commit is contained in:
Nighthawk42
2026-08-24 09:49:19 -04:00
parent 677b34e0c9
commit a9269504e2
8 changed files with 712 additions and 8 deletions
+1
View File
@@ -20,3 +20,4 @@ Engine\Session.lua
UI\Backdrops.lua
UI\GemPool.lua
UI\Animations.lua
UI\HUD.lua
+2
View File
@@ -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
+528
View File
@@ -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
+3 -3
View File
@@ -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 18,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 18,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.
+2
View File
@@ -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)
+2 -2
View File
@@ -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
+171 -1
View File
@@ -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.")
+3 -2
View File
@@ -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