feat: assemble playable window session shell

This commit is contained in:
Nighthawk42
2026-08-24 10:44:56 -04:00
parent a9269504e2
commit 19fefa5fc8
9 changed files with 687 additions and 8 deletions
+1
View File
@@ -21,3 +21,4 @@ UI\Backdrops.lua
UI\GemPool.lua
UI\Animations.lua
UI\HUD.lua
UI\MainWindow.lua
+32
View File
@@ -19,6 +19,7 @@ function addon:Initialize(accountData, profileData)
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.MainWindow, "MainWindow module is not loaded")
assert(self.Input, "Input module is not loaded")
assert(self.Session, "Session module is not loaded")
@@ -29,13 +30,44 @@ function addon:Initialize(accountData, profileData)
self.gemPoolFactory = self.GemPool
self.animationFactory = self.Animations
self.hudFactory = self.HUD
self.mainWindowFactory = self.MainWindow
self.inputFactory = self.Input
self.sessionFactory = self.Session
self.initialized = true
if UIParent ~= nil and type(UnitName) == "function" and not self.runtime then
self:StartRuntime()
end
return self
end
function addon:StartRuntime(options)
assert(self.initialized, "addon must be initialized before starting the runtime")
if self.runtime then
return self.runtime
end
options = options or {}
assert(type(options) == "table", "runtime options must be a table")
local playerName = options.playerName or function()
local name = UnitName("player")
assert(type(name) == "string" and name ~= "", "player name is unavailable")
return name
end
self.runtime = self.MainWindow:New(options.uiParent or UIParent, {
createFrame = options.createFrame,
profile = self.profileData,
accountData = self.accountData,
audio = self.audio,
playerName = playerName,
grid = self.grid,
random = options.random,
timedDuration = options.timedDuration,
hintsEnabled = options.hintsEnabled,
})
self.runtime:Show()
return self.runtime
end
if type(CreateFrame) == "function" then
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("ADDON_LOADED")
+23
View File
@@ -596,4 +596,27 @@ function Session:CreateGemHandlers()
return self.input:CreateGemHandlers()
end
function Session:Deactivate(reason)
reason = reason or "session-replaced"
assert(type(reason) == "string" and reason ~= "", "session deactivation reason is required")
local changed = self.active or self.input.pendingMove ~= nil or self.levelTransition ~= nil
self.active = false
self.pendingGameOverCause = nil
self.levelTransition = nil
self.gameOverTransition = nil
self.input:SetSessionLocked(true, reason)
self.animations:Cancel(reason)
if type(self.animations.ClearTransientEffects) == "function" then
self.animations:ClearTransientEffects()
end
if type(self.animations.SetAmbientLightwaves) == "function" then
self.animations:SetAmbientLightwaves(false)
end
return {
status = "inactive",
changed = changed and true or false,
reason = reason,
}
end
addon.Session = Session
+462
View File
@@ -0,0 +1,462 @@
local _, addon = ...
local Constants = assert(addon.Constants, "Constants module is not loaded")
local SavedVariables = assert(addon.SavedVariables, "SavedVariables module is not loaded")
local Grid = assert(addon.Grid, "Grid module is not loaded")
local Backdrops = assert(addon.Backdrops, "Backdrops module is not loaded")
local GemPool = assert(addon.GemPool, "GemPool module is not loaded")
local Animations = assert(addon.Animations, "Animations module is not loaded")
local HUD = assert(addon.HUD, "HUD module is not loaded")
local MainWindow = {}
MainWindow.__index = MainWindow
local WINDOW_WIDTH = 448
local WINDOW_HEIGHT = 510
local BOARD_BORDER_WIDTH = 414
local BOARD_BORDER_HEIGHT = 412
local BOARD_WIDTH = Constants.GRID_WIDTH * Constants.GEM_WIDTH
local BOARD_HEIGHT = Constants.GRID_HEIGHT * Constants.GEM_HEIGHT
local DEFAULT_TIMED_DURATION = 5 * 60
local FONT_PATH = Constants.IMAGE_ROOT .. "Contb___.ttf"
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 CreateFontString(frame, size, text, color)
local fontString = frame:CreateFontString(nil, "OVERLAY")
assert(fontString:SetFont(FONT_PATH, size, "OUTLINE"), "bundled window font could not be loaded")
fontString:SetText(text or "")
fontString:SetTextColor(color[1], color[2], color[3], color[4] or 1)
return fontString
end
local function ValidateCallback(callback, name)
assert(callback == nil or type(callback) == "function", name .. " must be a function")
return callback
end
function MainWindow:CreateBackdropFrame(parent, preset, width, height, levelOffset, frameType)
local frame = Backdrops:CreateFrame({
frameType = frameType,
parent = parent,
preset = preset,
createFrame = self.createFrame,
backgroundColor = { 0.08, 0.08, 0.08, 0.96 },
borderColor = { 1, 0.8, 0.45, 1 },
})
SetFrameSize(frame, width, height)
if parent and type(parent.GetFrameLevel) == "function" then
frame:SetFrameLevel(parent:GetFrameLevel() + (levelOffset or 1))
end
return frame
end
function MainWindow:CreateButton(parent, text, width, height, onClick)
assert(type(onClick) == "function", "window button requires a click callback")
local button = self:CreateBackdropFrame(parent, "tooltip", width, height, 2, "Button")
button:EnableMouse(true)
button.label = CreateFontString(button, 13, text, { 1, 0.85, 0, 1 })
button.label:SetPoint("CENTER", button, "CENTER", 0, 1)
button:SetScript("OnClick", function()
onClick()
end)
button:SetScript("OnEnter", function(frame)
frame:SetBackdropColor(0.25, 0.18, 0.04, 1)
end)
button:SetScript("OnLeave", function(frame)
frame:SetBackdropColor(0.08, 0.08, 0.08, 0.96)
end)
return button
end
function MainWindow:CreateWindowFrame()
local frame = self:CreateBackdropFrame(self.uiParent, "window", WINDOW_WIDTH, WINDOW_HEIGHT, 5)
frame:SetPoint("CENTER", self.uiParent, "CENTER", 0, 0)
frame:EnableMouse(true)
frame:SetMovable(true)
frame:RegisterForDrag("LeftButton")
if type(frame.SetClampedToScreen) == "function" then
frame:SetClampedToScreen(true)
end
frame:SetAlpha(self.profile.settings.gameAlpha or 1)
frame:SetScript("OnDragStart", function(window)
if not self.profile.settings.lockWindow then
window:StartMoving()
end
end)
frame:SetScript("OnDragStop", function(window)
window:StopMovingOrSizing()
end)
frame:SetScript("OnShow", function()
if not self.suppressWindowScript then
self:HandleWindowShown()
end
end)
frame:SetScript("OnHide", function()
if not self.suppressWindowScript then
self:HandleWindowHidden()
end
end)
frame:SetScript("OnUpdate", function(_, elapsed)
self:Update(elapsed)
end)
frame:Hide()
frame.icon = frame:CreateTexture(nil, "ARTWORK")
frame.icon:SetTexture(Constants.IMAGE_ROOT .. "windowIcon")
frame.icon:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, 4)
SetTextureSize(frame.icon, 58, 58)
frame.title = CreateFontString(frame, 24, "Bejeweled", { 1, 0.85, 0, 1 })
frame.title:SetPoint("TOP", frame, "TOP", 0, -18)
frame.closeButton = self:CreateButton(frame, "X", 28, 26, function()
self:Hide()
end)
frame.closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -8, -8)
frame.menuButton = self:CreateButton(frame, "Menu", 56, 26, function()
if self.activeOverlay then
self:ResumeGame()
else
self:ShowMenu()
end
end)
frame.menuButton:SetPoint("TOPRIGHT", frame.closeButton, "TOPLEFT", -4, 0)
self.frame = frame
end
function MainWindow:CreateBoard()
local boardFrame = self:CreateBackdropFrame(self.frame, "panel", BOARD_BORDER_WIDTH, BOARD_BORDER_HEIGHT, 2)
boardFrame:SetPoint("TOPLEFT", self.frame, "TOPLEFT", 17, -60)
boardFrame:SetBackdropColor(0, 0, 0, 0)
local surface = self.createFrame("Frame", nil, boardFrame)
SetFrameSize(surface, BOARD_WIDTH, BOARD_HEIGHT)
surface:SetPoint("TOPLEFT", boardFrame, "TOPLEFT", 7, -4)
if boardFrame and type(boardFrame.GetFrameLevel) == "function" then
surface:SetFrameLevel(boardFrame:GetFrameLevel() + 1)
end
surface:EnableMouse(true)
surface:Show()
self.boardFrame = boardFrame
self.boardSurface = surface
self.gemPool = GemPool:New(surface, {
createFrame = self.createFrame,
})
self.gemPool:Project(self.grid, true)
self.animations = Animations:New(self.gemPool, {
createFrame = self.createFrame,
random = self.random,
})
self.hud = HUD:New(surface, self.animations, {
createFrame = self.createFrame,
width = BOARD_WIDTH,
hintsEnabled = self.hintsEnabled,
})
end
function MainWindow:CreateOverlay(title, height)
local overlay = self:CreateBackdropFrame(self.frame, "panel", 190, height, 35)
overlay:SetPoint("CENTER", self.frame, "CENTER", 0, 8)
overlay.title = CreateFontString(overlay, 17, title, { 1, 1, 1, 1 })
overlay.title:SetPoint("TOP", overlay, "TOP", 0, -10)
overlay:Hide()
return overlay
end
function MainWindow:CreateMenus()
local menu = self:CreateOverlay("Menu", 128)
menu.resume = self:CreateButton(menu, "Resume", 160, 28, function()
self:ResumeGame()
end)
menu.resume:SetPoint("TOP", menu, "TOP", 0, -36)
menu.newGame = self:CreateButton(menu, "New Game", 160, 28, function()
self:ShowModeMenu()
end)
menu.newGame:SetPoint("TOP", menu.resume, "BOTTOM", 0, -8)
local mode = self:CreateOverlay("Game Type", 164)
mode.classic = self:CreateButton(mode, "Classic", 160, 28, function()
self:ChooseClassic()
end)
mode.classic:SetPoint("TOP", mode, "TOP", 0, -36)
mode.timed = self:CreateButton(mode, "Timed (5 minutes)", 160, 28, function()
self:StartTimed()
end)
mode.timed:SetPoint("TOP", mode.classic, "BOTTOM", 0, -8)
mode.back = self:CreateButton(mode, "Back", 160, 28, function()
self:ShowMenu()
end)
mode.back:SetPoint("TOP", mode.timed, "BOTTOM", 0, -8)
local classic = self:CreateOverlay("Classic Mode", 164)
classic.continue = self:CreateButton(classic, "Continue", 160, 28, function()
self:StartClassic(true)
end)
classic.continue:SetPoint("TOP", classic, "TOP", 0, -36)
classic.newGame = self:CreateButton(classic, "New Game", 160, 28, function()
self:StartClassic(false)
end)
classic.newGame:SetPoint("TOP", classic.continue, "BOTTOM", 0, -8)
classic.back = self:CreateButton(classic, "Back", 160, 28, function()
self:ShowModeMenu()
end)
classic.back:SetPoint("TOP", classic.newGame, "BOTTOM", 0, -8)
self.overlays = {
menu = menu,
mode = mode,
classic = classic,
}
end
function MainWindow:New(uiParent, options)
assert(uiParent ~= nil, "main window requires UIParent")
options = options or {}
assert(type(options) == "table", "main-window options must be a table")
assert(type(options.profile) == "table", "main window requires a profile")
assert(type(options.accountData) == "table", "main window requires account data")
assert(
type(options.audio) == "table"
and type(options.audio.Play) == "function"
and type(options.audio.Update) == "function",
"main window requires audio"
)
assert(type(options.playerName) == "string" or type(options.playerName) == "function", "main window requires a player name")
local instance = setmetatable({
uiParent = uiParent,
createFrame = options.createFrame or CreateFrame,
profile = options.profile,
accountData = options.accountData,
audio = options.audio,
playerName = options.playerName,
random = options.random or math.random,
timedDuration = options.timedDuration or DEFAULT_TIMED_DURATION,
hintsEnabled = options.hintsEnabled,
onSessionStarted = ValidateCallback(options.onSessionStarted, "onSessionStarted"),
onSessionStopped = ValidateCallback(options.onSessionStopped, "onSessionStopped"),
grid = options.grid or Grid:New(options.random),
session = nil,
activeOverlay = nil,
menuOwnsPause = false,
windowOwnsPause = false,
suppressWindowScript = false,
visible = false,
}, self)
assert(type(instance.createFrame) == "function", "CreateFrame is unavailable for main window")
assert(type(instance.random) == "function", "main-window random provider must be a function")
assert(type(instance.timedDuration) == "number" and instance.timedDuration > 0, "timed duration must be positive")
instance:CreateWindowFrame()
instance:CreateBoard()
instance:CreateMenus()
return instance
end
function MainWindow:HideOverlays()
for _, overlay in pairs(self.overlays) do
overlay:Hide()
end
self.activeOverlay = nil
end
function MainWindow:ShowOverlay(name)
local overlay = self.overlays[name]
assert(overlay, "unknown main-window overlay")
self:HideOverlays()
overlay:Show()
self.activeOverlay = name
return overlay
end
function MainWindow:PauseForMenu()
local session = self.session
if session and session.active and not session:IsPaused() then
session:Pause("menu")
self.menuOwnsPause = true
end
end
function MainWindow:ShowMenu()
self:PauseForMenu()
local menu = self:ShowOverlay("menu")
if self.session and self.session.active then
menu.resume:Show()
else
menu.resume:Hide()
end
return menu
end
function MainWindow:ShowModeMenu()
self:PauseForMenu()
return self:ShowOverlay("mode")
end
function MainWindow:ChooseClassic()
if SavedVariables:HasClassicGame(self.profile) then
return self:ShowOverlay("classic")
end
return self:StartClassic(false)
end
function MainWindow:ResumeGame()
self:HideOverlays()
if self.menuOwnsPause and self.session and self.session.active and not self.session:IsGameOver() then
self.session:Resume("menu")
end
self.menuOwnsPause = false
return self.session
end
function MainWindow:StopSession(reason)
local session = self.session
if not session then
return nil
end
local result = session:Deactivate(reason or "new-game")
if self.onSessionStopped then
self.onSessionStopped(result, session, self)
end
return result
end
function MainWindow:StartGame(gameMode, restore, duration)
assert(
gameMode == Constants.GAME_MODE_CLASSIC or gameMode == Constants.GAME_MODE_TIMED,
"main window supports Classic or Timed sessions"
)
assert(type(restore) == "boolean", "restore state must be Boolean")
if restore then
assert(gameMode == Constants.GAME_MODE_CLASSIC and SavedVariables:HasClassicGame(self.profile), "no Classic game is available to continue")
end
self:StopSession("new-game")
self:HideOverlays()
self.menuOwnsPause = false
self.windowOwnsPause = false
self.animations:Resume()
self.gemPool:SetInteractive(true)
local fillAttempts
if not restore then
local filled, attempts = self.grid:Fill(self.random)
assert(filled, attempts)
fillAttempts = attempts
if gameMode == Constants.GAME_MODE_CLASSIC then
SavedVariables:ClearClassicGame(self.grid, self.profile)
end
end
self.gemPool:Project(self.grid, true)
local session = self.hud:CreateSession(self.grid, self.gemPool, {
profile = self.profile,
accountData = self.accountData,
playerName = self.playerName,
gameMode = gameMode,
timeLimit = gameMode == Constants.GAME_MODE_TIMED and duration or nil,
autoSave = true,
inputOptions = {
random = self.random,
audio = self.audio,
},
})
self.session = session
self.gemPool:SetHandlers(session:CreateGemHandlers())
if restore then
session:RestoreClassicGame({ paused = false })
else
self.gemPool:Project(self.grid, true)
self.animations:SyncPersistentEffects(false)
if gameMode == Constants.GAME_MODE_CLASSIC then
session:SaveClassicGame("new-game")
end
end
local result = {
status = restore and "restored" or "started",
gameMode = gameMode,
timeLimit = session.timeLimit,
fillAttempts = fillAttempts,
session = session,
}
self.lastStartResult = result
if self.onSessionStarted then
self.onSessionStarted(result, session, self)
end
return session, result
end
function MainWindow:StartClassic(restore)
return self:StartGame(Constants.GAME_MODE_CLASSIC, restore and true or false)
end
function MainWindow:StartTimed(duration)
duration = duration or self.timedDuration
assert(type(duration) == "number" and duration > 0, "timed game duration must be positive")
return self:StartGame(Constants.GAME_MODE_TIMED, false, duration)
end
function MainWindow:HandleWindowShown()
self.visible = true
if self.windowOwnsPause and self.session and self.session.active and not self.activeOverlay then
self.session:Resume("window-shown")
end
self.windowOwnsPause = false
if not self.session and not self.activeOverlay then
self:ShowMenu()
end
end
function MainWindow:HandleWindowHidden()
self.visible = false
if self.session and self.session.active and not self.session:IsPaused() then
self.session:Pause("window-hidden")
self.windowOwnsPause = true
end
end
function MainWindow:Show()
self.suppressWindowScript = true
self.frame:Show()
self.suppressWindowScript = false
self:HandleWindowShown()
return self
end
function MainWindow:Hide()
self.suppressWindowScript = true
self.frame:Hide()
self.suppressWindowScript = false
self:HandleWindowHidden()
return self
end
function MainWindow:IsShown()
return self.visible
end
function MainWindow:Toggle()
if self.visible then
return self:Hide()
end
return self:Show()
end
function MainWindow:Update(elapsed)
assert(type(elapsed) == "number" and elapsed >= 0, "main-window elapsed time must be nonnegative")
if self.session then
self.session:AdvanceElapsed(elapsed)
self.hud:Update(elapsed)
end
self.audio:Update(elapsed)
return self.session and self.session.timerElapsed or 0
end
MainWindow.WINDOW_WIDTH = WINDOW_WIDTH
MainWindow.WINDOW_HEIGHT = WINDOW_HEIGHT
MainWindow.DEFAULT_TIMED_DURATION = DEFAULT_TIMED_DURATION
addon.MainWindow = MainWindow
+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 the main window and menu/session 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 now assembles a headless-verified interactive Classic/Timed gameplay shell, but the modernization is not yet alpha-ready because the remaining screens, Retail event integration, and in-game equivalence testing still need to be completed.
## 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, pooled board effects, and a session-bound Classic/Timed HUD with status, hints, achievements, and final summaries.
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, persistent board rendering and effects, the Classic/Timed HUD, and an automatically assembled movable window with New Game, Continue, mode selection, input wiring, and one authoritative runtime update path.
## 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, pooled legacy-cadence board effects, and the session-bound score/timer/status HUD.
- `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, the session-bound HUD, and the playable main-window/session shell.
- `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
@@ -11,6 +11,7 @@ The modernization baseline is the authoritative WoW UI/API source snapshot for l
| 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. |
| Main window | `SimpleFrame` exposes `RegisterForDrag`, `SetMovable`, protected `StartMoving`, and protected `StopMovingOrSizing` in all environments. `UnitName(unit)` accepts a non-nil unit token and returns non-nil name/server strings, subject to the documented identity restriction. | `UI/MainWindow.lua` begins movement only from its direct left-button drag callback when the profile is unlocked. `Core/Init.lua` supplies `UnitName("player")` lazily as the existing SavedVariables authentication identity. |
| 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. |
@@ -28,6 +29,7 @@ API existence alone does not prove behavioral equivalence. Each future substitut
- 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)
- Main-window movement and player-name identity: [`SimpleFrameAPIDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/SimpleFrameAPIDocumentation.lua) and [`UnitDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/UnitDocumentation.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)
+3 -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`, `UI/Animations.lua`, and `UI/HUD.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`, `UI/HUD.lua`, and `UI/MainWindow.lua`.
## Load order and ownership
@@ -18,7 +18,8 @@ The analysis phase gate is satisfied. This ownership map now governs runtime imp
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` — 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.
15. `UI/MainWindow.lua` — the 448×510 movable runtime shell, 400×400 board construction, Menu/New Game overlays, Classic Continue/New Game and direct default five-minute Timed selection, session replacement, and show/hide pause ownership. Its frame update delegates authoritative elapsed time to `Engine/Session.lua`, HUD expiry to `UI/HUD.lua`, and queued sound flushing to `Core/Audio.lua`. `Core/Init.lua` assembles and shows this shell after `ADDON_LOADED` when live `UIParent` and player identity are available.
16. `UI/Compartment.lua` — addon-compartment click and hover callbacks.
## Data flow
+158 -1
View File
@@ -53,6 +53,7 @@ LoadAddonFile("Bejeweled/UI/Backdrops.lua", addon)
LoadAddonFile("Bejeweled/UI/GemPool.lua", addon)
LoadAddonFile("Bejeweled/UI/Animations.lua", addon)
LoadAddonFile("Bejeweled/UI/HUD.lua", addon)
LoadAddonFile("Bejeweled/UI/MainWindow.lua", addon)
AssertEqual(eventFrame.registeredEvent, "ADDON_LOADED", "initializer event registration")
AssertEqual(addon.Constants.GRID_WIDTH, 8, "grid width")
@@ -318,9 +319,30 @@ local function CreateGemPoolFrame(frameType, name, parent, template)
function frame:SetFrameLevel(frameLevel)
self.frameLevel = frameLevel
end
function frame:GetFrameLevel()
if self.frameLevel then
return self.frameLevel
end
if self.parent and type(self.parent.GetFrameLevel) == "function" then
return self.parent:GetFrameLevel()
end
return 0
end
function frame:EnableMouse(enabled)
self.mouseEnabled = enabled
end
function frame:SetMovable(movable)
self.movable = movable
end
function frame:SetClampedToScreen(clamped)
self.clamped = clamped
end
function frame:StartMoving()
self.moving = true
end
function frame:StopMovingOrSizing()
self.moving = false
end
function frame:RegisterForDrag(button)
self.dragButton = button
end
@@ -1654,10 +1676,134 @@ AssertEqual(timedHUD.progress.text.text, "0:00", "timed HUD zero countdown")
AssertEqual(timedHUD.progress.ratio, 0, "timed HUD empty progress")
end
function addon:TestMainWindowForTest()
local runtimeProfile = addon.SavedVariables:CreateDefaultProfile()
local runtimeAccount = addon.SavedVariables:CreateDefaultAccount()
local runtimeAudio = {
played = {},
elapsed = 0,
}
function runtimeAudio:Play(soundName)
self.played[#self.played + 1] = soundName
return true
end
function runtimeAudio:Update(elapsed)
self.elapsed = self.elapsed + elapsed
return {}
end
local sessionStarts = {}
local sessionStops = {}
local runtime = addon.MainWindow:New(gemPoolParent, {
createFrame = CreateGemPoolFrame,
profile = runtimeProfile,
accountData = runtimeAccount,
audio = runtimeAudio,
playerName = "Nighthawk",
random = MakeRandom(12001),
onSessionStarted = function(result)
sessionStarts[#sessionStarts + 1] = result
end,
onSessionStopped = function(result)
sessionStops[#sessionStops + 1] = result
end,
})
AssertEqual(runtime.frame.width, addon.MainWindow.WINDOW_WIDTH, "runtime window width")
AssertEqual(runtime.frame.height, addon.MainWindow.WINDOW_HEIGHT, "runtime window height")
AssertEqual(runtime.boardSurface.width, 400, "runtime board width")
AssertEqual(runtime.boardSurface.height, 400, "runtime board height")
AssertEqual(#runtime.gemPool.tiles, 16, "runtime board tile count")
runtime:Show()
assert(runtime.frame.shown, "runtime window did not show")
assert(runtime:IsShown(), "runtime visibility state did not follow Show")
AssertEqual(runtime.activeOverlay, "menu", "runtime initial menu")
assert(not runtime.overlays.menu.resume.shown, "runtime initial menu exposed Resume")
runtime.overlays.menu.newGame.scripts.OnClick()
AssertEqual(runtime.activeOverlay, "mode", "runtime New Game did not show mode selection")
runtime.overlays.mode.classic.scripts.OnClick()
local firstClassic = runtime.session
assert(firstClassic and firstClassic.active, "runtime Classic session did not start")
assert(not runtime.animations:IsPaused(), "runtime Classic session inherited the menu pause")
AssertEqual(firstClassic.gameMode, addon.Constants.GAME_MODE_CLASSIC, "runtime Classic mode")
assert(runtime.grid:FindLegalMove(), "runtime Classic board has no legal move")
assert(runtimeProfile.settings.classicInProgress, "new Classic runtime was not resumable")
assert(type(runtime.gemPool:GetFrame(1, 1).scripts.OnMouseDown) == "function", "runtime did not attach gem input")
AssertEqual(sessionStarts[1].status, "started", "runtime Classic start callback")
runtime:Update(1.5)
AssertEqual(firstClassic.timerElapsed, 1.5, "runtime update did not advance session time")
AssertEqual(runtimeAudio.elapsed, 1.5, "runtime update did not flush audio")
runtime:ShowMenu()
assert(firstClassic:IsPaused(), "runtime menu did not pause Classic play")
assert(runtime.overlays.menu.resume.shown, "active runtime menu hid Resume")
runtime.overlays.menu.resume.scripts.OnClick()
assert(not firstClassic:IsPaused(), "runtime Resume left Classic paused")
assert(runtime.activeOverlay == nil, "runtime Resume retained a menu overlay")
firstClassic.scoringState.score = 2468
firstClassic:SaveClassicGame("continue-fixture")
runtime:ShowMenu()
runtime.overlays.menu.newGame.scripts.OnClick()
runtime.overlays.mode.classic.scripts.OnClick()
AssertEqual(runtime.activeOverlay, "classic", "saved Classic did not show Continue/New Game")
runtime.overlays.classic.continue.scripts.OnClick()
local restoredClassic = runtime.session
assert(restoredClassic ~= firstClassic, "runtime Continue reused the abandoned session")
assert(not runtime.animations:IsPaused(), "runtime Continue inherited the menu pause")
assert(not firstClassic.active and firstClassic:IsLocked(), "runtime replacement did not deactivate the old session")
AssertEqual(restoredClassic.scoringState.score, 2468, "runtime Continue score")
AssertEqual(sessionStarts[2].status, "restored", "runtime Continue callback")
AssertEqual(#sessionStops, 1, "runtime Continue omitted session-stop callback")
runtime:ShowMenu()
runtime.overlays.menu.newGame.scripts.OnClick()
runtime.overlays.mode.timed.scripts.OnClick()
local timedRuntime = runtime.session
AssertEqual(timedRuntime.gameMode, addon.Constants.GAME_MODE_TIMED, "runtime Timed mode")
assert(not runtime.animations:IsPaused(), "runtime Timed session inherited the menu pause")
assert(runtime.gemPool:GetFrame(1, 1).mouseEnabled, "runtime Timed replacement left gem input disabled")
AssertEqual(timedRuntime.timeLimit, addon.MainWindow.DEFAULT_TIMED_DURATION, "runtime default Timed duration")
AssertEqual(runtime.hud.levelPanel.caption.text, "PPS", "runtime Timed HUD mode")
AssertEqual(sessionStarts[3].timeLimit, addon.MainWindow.DEFAULT_TIMED_DURATION, "runtime Timed callback duration")
assert(not restoredClassic.active, "runtime Timed start left Classic active")
timedRuntime:SetElapsed(1)
timedRuntime:BeginGameOver("runtime-restart-fixture")
assert(timedRuntime:IsGameOver() and timedRuntime:IsLocked(), "runtime Timed fixture did not reach terminal state")
timedRuntime = runtime:StartTimed(60)
assert(runtime.gemPool:GetFrame(1, 1).mouseEnabled, "runtime terminal replacement left gem input disabled")
AssertEqual(timedRuntime.timeLimit, 60, "runtime custom Timed duration")
runtime:Hide()
assert(timedRuntime:IsPaused(), "hidden runtime window did not pause play")
assert(not runtime:IsShown(), "runtime visibility state did not follow Hide")
runtime:Show()
assert(not timedRuntime:IsPaused(), "shown runtime window did not resume its owned pause")
runtime.frame.scripts.OnDragStart(runtime.frame)
assert(runtime.frame.moving, "unlocked runtime window did not start moving")
runtime.frame.scripts.OnDragStop(runtime.frame)
assert(not runtime.frame.moving, "runtime drag stop left the window moving")
runtimeProfile.settings.lockWindow = true
runtime.frame.scripts.OnDragStart(runtime.frame)
assert(not runtime.frame.moving, "locked runtime window started moving")
runtime:ShowMenu()
runtime.overlays.menu.newGame.scripts.OnClick()
runtime.overlays.mode.classic.scripts.OnClick()
AssertEqual(runtime.activeOverlay, "classic", "runtime Classic save chooser was skipped")
runtime.overlays.classic.newGame.scripts.OnClick()
local freshClassic = runtime.session
AssertEqual(freshClassic.scoringState.score, 0, "runtime fresh Classic retained prior score")
assert(freshClassic ~= restoredClassic, "runtime fresh Classic reused a prior session")
AssertEqual(#sessionStarts, 5, "runtime session-start callback count")
AssertEqual(#sessionStops, 4, "runtime session-stop callback count")
end
TestSessionRestore()
TestGameOverTransitions()
addon:TestHUDPresentationForTest()
addon.TestHUDPresentationForTest = nil
addon:TestMainWindowForTest()
addon.TestMainWindowForTest = nil
FillStablePattern(cascadeGrid)
for x = 2, 5 do
@@ -2015,6 +2161,7 @@ assert(addon.backdrops == addon.Backdrops, "addon initialization did not install
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.mainWindowFactory == addon.MainWindow, "addon initialization did not install MainWindow")
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")
@@ -2033,7 +2180,17 @@ assert(addon.backdrops == initializedBackdrops, "backdrop initialization is not
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.mainWindowFactory == addon.MainWindow, "MainWindow initialization is not idempotent")
assert(addon.inputFactory == initializedInputFactory, "Input initialization is not idempotent")
assert(addon.sessionFactory == initializedSessionFactory, "Session initialization is not idempotent")
addon.runtimeForTest = addon:StartRuntime({
uiParent = gemPoolParent,
createFrame = CreateGemPoolFrame,
playerName = "Nighthawk",
random = MakeRandom(13001),
})
assert(addon.runtimeForTest == addon.runtime, "StartRuntime did not retain the playable shell")
AssertEqual(addon.runtimeForTest.activeOverlay, "menu", "StartRuntime did not open the initial menu")
assert(addon:StartRuntime() == addon.runtimeForTest, "StartRuntime is not idempotent")
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.")
print("Runtime verification passed: playable Classic/Timed window shell, 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
@@ -32,7 +32,8 @@ try {
"UI\Backdrops.lua",
"UI\GemPool.lua",
"UI\Animations.lua",
"UI\HUD.lua"
"UI\HUD.lua",
"UI\MainWindow.lua"
)
$actualFiles = @($toc | Where-Object { $_ -match "\.lua$" })
if (Compare-Object -ReferenceObject $expectedFiles -DifferenceObject $actualFiles -SyncWindow 0) {
@@ -46,7 +47,7 @@ try {
}
}
Write-Output "Verified: Retail TOC order and Lua 5.1-compatible session/gameplay/HUD runtime."
Write-Output "Verified: Retail TOC order and Lua 5.1-compatible playable window/session runtime."
}
finally {
Pop-Location