feat: add input session coordination

This commit is contained in:
Nighthawk42
2026-08-24 07:13:07 -04:00
parent b63a83d533
commit 799cb4ef6b
12 changed files with 553 additions and 12 deletions
+1
View File
@@ -15,6 +15,7 @@ Engine\Grid.lua
Engine\Matches.lua
Engine\Cascade.lua
Engine\Scoring.lua
Engine\Input.lua
UI\Backdrops.lua
UI\GemPool.lua
UI\Animations.lua
+2
View File
@@ -46,9 +46,11 @@ local Constants = {
SKILL_CLEAR25 = 4,
SKILL_SCORE10000 = 1,
SKILL_LEVEL10 = 2,
SKILL_MOVE100 = 3,
SKILL_SCORE25000 = 4,
SKILL_LEVEL15 = 5,
SKILL_SCORE50000 = 6,
SKILL_MOVE250 = 7,
SKILL_SCORE75000 = 8,
ACHIEVEMENT_CLEAR1000 = 2,
ACHIEVEMENT_POWER100 = 4,
+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.Input, "Input module is not loaded")
self.accountData, self.profileData = self.SavedVariables:Initialize(accountData, profileData)
self.grid = self.Grid:New()
@@ -25,6 +26,7 @@ function addon:Initialize(accountData, profileData)
self.backdrops = self.Backdrops
self.gemPoolFactory = self.GemPool
self.animationFactory = self.Animations
self.inputFactory = self.Input
self.initialized = true
return self
+278
View File
@@ -0,0 +1,278 @@
local _, addon = ...
local Constants = assert(addon.Constants, "Constants module is not loaded")
local Matches = assert(addon.Matches, "Matches module is not loaded")
local Cascade = assert(addon.Cascade, "Cascade module is not loaded")
local Scoring = assert(addon.Scoring, "Scoring module is not loaded")
local Input = {}
Input.__index = Input
local CALLBACK_NAMES = {
"onSelectionChanged",
"onMoveStarted",
"onCascadeResolved",
"onMoveComplete",
}
local function ValidateCallbacks(options)
local callbacks = {}
for index = 1, #CALLBACK_NAMES do
local callbackName = CALLBACK_NAMES[index]
local callback = options[callbackName]
assert(callback == nil or type(callback) == "function", callbackName .. " must be a function")
callbacks[callbackName] = callback
end
return callbacks
end
local function CopyCell(cell)
return {
x = cell.gridX,
y = cell.gridY,
contents = cell.contents,
bigStar = cell.bigStar and true or false,
}
end
function Input:New(grid, gemPool, animations, options)
assert(type(grid) == "table" and type(grid.Get) == "function" and type(grid.Swap) == "function", "input requires a grid")
assert(type(gemPool) == "table" and type(gemPool.SetSelection) == "function", "input requires a gem pool")
assert(type(animations) == "table" and type(animations.PlaySwap) == "function" and type(animations.Play) == "function" and type(animations.IsPlaying) == "function", "input requires animations")
options = options or {}
assert(type(options) == "table", "input options must be a table")
assert((options.scoringState == nil) == (options.profile == nil), "scoring state and profile must be supplied together")
assert(options.audio == nil or type(options.audio.Play) == "function", "input audio must expose Play")
local instance = setmetatable({}, self)
instance.grid = grid
instance.gemPool = gemPool
instance.animations = animations
instance.matches = options.matches or Matches
instance.cascade = options.cascade or Cascade
instance.scoring = options.scoring or Scoring
instance.scoringState = options.scoringState
instance.profile = options.profile
instance.audio = options.audio
instance.random = options.random or grid.random or math.random
instance.requireLegalMove = options.requireLegalMove ~= false
instance.maximumRefillAttempts = options.maximumRefillAttempts
instance.maximumCascades = options.maximumCascades
instance.skillLimit = options.skillLimit
instance.callbacks = ValidateCallbacks(options)
instance.selectedX = nil
instance.selectedY = nil
instance.locked = false
instance.pendingMove = nil
instance.lastMove = nil
instance.moves = instance.scoringState and (instance.scoringState.moves or 0) or (options.moves or 0)
return instance
end
function Input:IsLocked()
return self.locked or self.animations:IsPlaying()
end
function Input:GetSelection()
if not self.selectedX then
return nil
end
return {
x = self.selectedX,
y = self.selectedY,
cell = self.grid:Get(self.selectedX, self.selectedY),
}
end
function Input:PlaySound(soundName)
if self.audio then
self.audio:Play(soundName)
end
end
function Input:Notify(callbackName, result)
local callback = self.callbacks[callbackName]
if callback then
callback(result, self)
end
end
function Input:SetSelection(column, row, reason)
self.selectedX = column
self.selectedY = row
self.gemPool:SetSelection(column, row)
local selection = self:GetSelection()
self:Notify("onSelectionChanged", {
status = selection and "selected" or "cleared",
reason = reason,
selection = selection,
})
return selection
end
function Input:ClearSelection(reason)
if not self.selectedX then
return false
end
self:SetSelection(nil, nil, reason or "cleared")
return true
end
function Input:FinishMove(result, status)
if self.pendingMove ~= result then
return
end
result.status = status
self.pendingMove = nil
self.locked = false
self.lastMove = result
self:Notify("onMoveComplete", result)
end
function Input:ResolveAcceptedMove(result)
if self.pendingMove ~= result or result.cascadeStarted then
return
end
result.cascadeStarted = true
self:PlaySound("GemClick")
local cascadeResult = self.cascade:Resolve(self.grid, {
initialMatches = result.matchResult,
random = self.random,
requireLegalMove = self.requireLegalMove,
maximumRefillAttempts = self.maximumRefillAttempts,
maximumCascades = self.maximumCascades,
})
result.cascadeResult = cascadeResult
if self.scoringState then
result.scoringResult = self.scoring:ApplyCascade(
self.scoringState,
cascadeResult,
self.profile,
{ random = self.random, skillLimit = self.skillLimit }
)
end
self:Notify("onCascadeResolved", result)
result.cascadeRun = self.animations:Play(cascadeResult, self.grid, {
onComplete = function()
self:FinishMove(result, "complete")
end,
onCancel = function(reason)
result.cascadePresentationCancelled = reason
self:FinishMove(result, "complete")
end,
})
end
function Input:BeginSwap(firstX, firstY, secondX, secondY)
local firstCell = self.grid:Get(firstX, firstY)
local secondCell = self.grid:Get(secondX, secondY)
assert(firstCell and secondCell and self.grid:AreAdjacent(firstX, firstY, secondX, secondY), "input swap requires adjacent cells")
if firstCell.contents == Constants.HYPER_CONTENTS or secondCell.contents == Constants.HYPER_CONTENTS then
return {
status = "hyper-pending",
first = CopyCell(firstCell),
second = CopyCell(secondCell),
}
end
local result = {
status = "animating",
first = CopyCell(firstCell),
second = CopyCell(secondCell),
valid = false,
}
self.locked = true
self:ClearSelection("swap")
self.grid:Swap(firstX, firstY, secondX, secondY)
local preferredCells = {
self.grid:Get(firstX, firstY),
self.grid:Get(secondX, secondY),
}
local matchResult = self.matches:Find(self.grid, {
preferredCells = preferredCells,
random = self.random,
})
result.matchResult = matchResult
result.valid = matchResult.hasMatches
if not result.valid then
self.grid:Swap(firstX, firstY, secondX, secondY)
self:PlaySound("Invalid")
else
if self.scoringState then
result.moveResult = self.scoring:RecordMove(
self.scoringState,
self.profile,
{ random = self.random, skillLimit = self.skillLimit }
)
self.moves = self.scoringState.moves
else
self.moves = self.moves + 1
result.moveResult = { moves = self.moves, skillEvents = {} }
end
end
self.pendingMove = result
result.swapRun = self.animations:PlaySwap(
firstX,
firstY,
secondX,
secondY,
not result.valid,
self.grid,
{
onComplete = function()
if result.valid then
self:ResolveAcceptedMove(result)
else
self:FinishMove(result, "rejected")
end
end,
onCancel = function(reason)
result.swapPresentationCancelled = reason
if result.valid then
self:ResolveAcceptedMove(result)
else
self:FinishMove(result, "rejected")
end
end,
}
)
self:Notify("onMoveStarted", result)
return result
end
function Input:HandleCell(column, row)
if self:IsLocked() then
return { status = "locked" }
end
local cell = self.grid:Get(column, row)
assert(cell, "input cell is out of bounds")
if cell.contents == Constants.EMPTY_CONTENTS then
return { status = "empty" }
end
if not self.selectedX then
self:SetSelection(column, row, "input")
self:PlaySound("Select")
return { status = "selected", selection = self:GetSelection() }
end
if self.selectedX == column and self.selectedY == row then
self:ClearSelection("toggle")
return { status = "cleared" }
end
if not self.grid:AreAdjacent(self.selectedX, self.selectedY, column, row) then
self:ClearSelection("nonadjacent")
return { status = "cleared", reason = "nonadjacent" }
end
return self:BeginSwap(self.selectedX, self.selectedY, column, row)
end
function Input:CreateGemHandlers()
return {
onMouseDown = function(frame, button)
if button == nil or button == "LeftButton" then
return self:HandleCell(frame.gridX, frame.gridY)
end
end,
}
end
addon.Input = Input
+27
View File
@@ -304,6 +304,33 @@ local function UpdateLargestCascade(state, stats, awards)
return largest
end
function Scoring:RecordMove(state, profile, options)
assert(type(state) == "table", "move recording requires game state")
ValidateMode(state.gameMode)
ValidateProfile(profile)
options = options or {}
state.moves = (state.moves or 0) + 1
if state.gameMode == Constants.GAME_MODE_TIMED then
profile.stats.timed.mostMoves = math.max(profile.stats.timed.mostMoves or 0, state.moves)
end
local result = {
moves = state.moves,
skillEvents = {},
}
local skillOptions = {
random = options.random or math.random,
skillLimit = options.skillLimit,
}
if state.gameMode == Constants.GAME_MODE_CLASSIC then
if state.moves == 100 then
TrySkill(self, result.skillEvents, profile, Constants.SKILL_TYPE_CLASSIC, Constants.SKILL_MOVE100, skillOptions)
elseif state.moves == 250 then
TrySkill(self, result.skillEvents, profile, Constants.SKILL_TYPE_CLASSIC, Constants.SKILL_MOVE250, skillOptions)
end
end
return result
end
function Scoring:ApplyCascade(state, cascadeResult, profile, options)
assert(type(state) == "table", "cascade scoring requires game state")
ValidateMode(state.gameMode)
+81
View File
@@ -9,6 +9,7 @@ local DEFAULT_CLEAR_DURATION = 0.1
local DEFAULT_FALL_PER_CELL = 0.05
local DEFAULT_MINIMUM_FALL_DURATION = 0.1
local DEFAULT_EFFECT_INTERVAL = 0.025
local DEFAULT_SWAP_DURATION = Constants.GEM_WIDTH / 150
local HYPER_FRAME_COUNT = 40
local EXPLOSION_FRAME_COUNT = 16
local POWER_STAR_SIZE = 90
@@ -122,6 +123,24 @@ local function CreateMoveAnimation(frame)
return frame.bejeweledMoveAnimation
end
local function CreateSwapAnimation(frame, rollback)
local group = frame:CreateAnimationGroup()
local outbound = group:CreateAnimation("Translation")
outbound:SetOrder(1)
local animation = {
group = group,
outbound = outbound,
}
if rollback then
animation.returnTranslation = group:CreateAnimation("Translation")
animation.returnTranslation:SetOrder(2)
frame.bejeweledSwapRollbackAnimation = animation
else
frame.bejeweledSwapForwardAnimation = animation
end
return animation
end
local function FinishPending(runner, run, onFinished)
if runner.active ~= run or run.cancelled then
return
@@ -154,10 +173,12 @@ function Animations:New(gemPool, options)
instance.fallPerCell = options.fallPerCell or DEFAULT_FALL_PER_CELL
instance.minimumFallDuration = options.minimumFallDuration or DEFAULT_MINIMUM_FALL_DURATION
instance.effectInterval = options.effectInterval or DEFAULT_EFFECT_INTERVAL
instance.swapDuration = options.swapDuration or DEFAULT_SWAP_DURATION
assert(type(instance.clearDuration) == "number" and instance.clearDuration > 0, "clear duration must be positive")
assert(type(instance.fallPerCell) == "number" and instance.fallPerCell > 0, "fall duration per cell must be positive")
assert(type(instance.minimumFallDuration) == "number" and instance.minimumFallDuration > 0, "minimum fall duration must be positive")
assert(type(instance.effectInterval) == "number" and instance.effectInterval > 0, "effect interval must be positive")
assert(type(instance.swapDuration) == "number" and instance.swapDuration > 0, "swap duration must be positive")
instance.callbacks = CopyCallbacks({}, options)
instance.generation = 0
instance.active = nil
@@ -617,6 +638,66 @@ function Animations:PlayStep(run, stepIndex)
end)
end
function Animations:PlaySwap(firstX, firstY, secondX, secondY, rollback, finalGrid, callbacks)
AssertCoordinate(firstX, Constants.GRID_WIDTH, "first swap column")
AssertCoordinate(firstY, Constants.GRID_HEIGHT, "first swap row")
AssertCoordinate(secondX, Constants.GRID_WIDTH, "second swap column")
AssertCoordinate(secondY, Constants.GRID_HEIGHT, "second swap row")
assert(math.abs(firstX - secondX) + math.abs(firstY - secondY) == 1, "swap animation requires adjacent cells")
assert(type(finalGrid) == "table" and type(finalGrid.Get) == "function", "swap animation requires the final grid")
if self.active then
self:Cancel("superseded")
end
self.generation = self.generation + 1
local run = {
kind = "swap",
generation = self.generation,
finalGrid = finalGrid,
callbacks = CopyCallbacks(self.callbacks, callbacks),
activeGroups = {},
activeExplosions = {},
pending = 0,
cancelled = false,
completed = false,
rollback = rollback and true or false,
}
self.active = run
self.gemPool:SetInteractive(false)
self.gemPool:SetSelection(nil)
if run.callbacks.onPhase then
run.callbacks.onPhase(run.rollback and "swap-rollback" or "swap", 1, nil, run)
end
if self.active ~= run or run.cancelled then
return run
end
local groups = {}
local movements = {
{ frame = self.gemPool:GetFrame(firstX, firstY), offsetX = (secondX - firstX) * Constants.GEM_WIDTH, offsetY = -(secondY - firstY) * Constants.GEM_HEIGHT },
{ frame = self.gemPool:GetFrame(secondX, secondY), offsetX = (firstX - secondX) * Constants.GEM_WIDTH, offsetY = -(firstY - secondY) * Constants.GEM_HEIGHT },
}
for index = 1, #movements do
local movement = movements[index]
local animation
if run.rollback then
animation = movement.frame.bejeweledSwapRollbackAnimation or CreateSwapAnimation(movement.frame, true)
animation.returnTranslation:SetDuration(self.swapDuration)
animation.returnTranslation:SetOffset(-movement.offsetX, -movement.offsetY)
else
animation = movement.frame.bejeweledSwapForwardAnimation or CreateSwapAnimation(movement.frame, false)
end
animation.outbound:SetDuration(self.swapDuration)
animation.outbound:SetOffset(movement.offsetX, movement.offsetY)
groups[#groups + 1] = animation.group
end
self:WaitForPhase(run, groups, {}, function()
self:CompleteRun(run)
end)
return run
end
function Animations:Play(cascadeResult, finalGrid, callbacks)
assert(type(finalGrid) == "table" and type(finalGrid.Get) == "function", "animation playback requires the final grid")
if self.active then
+16
View File
@@ -216,6 +216,7 @@ function GemPool:RenderCell(column, row, cell, force)
end
function GemPool:ResetPresentation(grid)
self.selectedFrame = nil
for row = 1, Constants.GRID_HEIGHT do
for column = 1, Constants.GRID_WIDTH do
local frame = self.frames[row][column]
@@ -247,6 +248,21 @@ function GemPool:ResetPresentation(grid)
return { changedCount = 0, changes = {} }
end
function GemPool:SetSelection(column, row)
if self.selectedFrame then
self.selectedFrame.selector:Hide()
self.selectedFrame = nil
end
if column == nil and row == nil then
return nil
end
assert(column ~= nil and row ~= nil, "gem selection requires both coordinates")
local frame = self:GetFrame(column, row)
frame.selector:Show()
self.selectedFrame = frame
return frame
end
function GemPool:Project(grid, force)
assert(type(grid) == "table" and type(grid.Get) == "function", "gem projection requires a grid")
local changes = {}
+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 UI, animation, and input slices remain 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 hyper activation, remaining session states, HUD, and menu 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, deterministic grid/match/cascade/scoring transitions, 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 playback.
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, deterministic grid/match/cascade/scoring transitions, 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 playback.
## Goal
@@ -18,7 +18,7 @@ The eventual addon will target current Retail/Mainline World of Warcraft while p
- `Bejeweled/images/` — immutable legacy images and bundled font.
- `Bejeweled/sounds/` — immutable legacy sounds.
- `Bejeweled/Core/` — private addon initialization, constants, supported audio playback, and non-destructive SavedVariables defaulting.
- `Bejeweled/Engine/` — deterministic gameplay state; currently the 8×8 grid, swaps, legal moves, legacy cell encoding, stable cascade resolution, legacy score formulas, statistics, skill gains, achievements, and level thresholds.
- `Bejeweled/Engine/` — deterministic gameplay state; currently the 8×8 grid, selection/swap sessions, legal moves, legacy cell encoding, stable cascade resolution, legacy score formulas, move accounting, statistics, skill gains, achievements, and level thresholds.
- `Bejeweled/UI/` — Retail-safe frame/rendering boundaries; currently shared backdrop construction, board tiles, persistent gem-frame projection, and recorded cascade-transition playback.
- `docs/analysis/` — sequential batch reports, identifier ledger, and exact coverage schedule.
- `docs/architecture.md` — module ownership and authoritative runtime data flow.
+2 -1
View File
@@ -8,7 +8,7 @@ The modernization baseline is the authoritative WoW UI/API source snapshot for l
| Timers | `C_Timer.After` and `C_Timer.NewTicker` are the supported timer primitives. | Replace legacy polling/on-update timing only after behavior-equivalence analysis. |
| Addon sounds | `PlaySoundFile` continues to support addon-owned paths. Current Blizzard Mainline code calls `PlaySound` with identifiers from the current `SOUNDKIT` table. Obsolete Blizzard-internal paths are not stable. | `Core/Audio.lua` preserves all addon media paths and replaces the legacy `Sound\\Spells\\LevelUp.wav` call with `SOUNDKIT.UI_SCENARIO_STAGE_END`, falling back to `SOUNDKIT.UI_AUTO_QUEST_COMPLETE` when necessary. |
| Metadata | Addon metadata access is provided by `C_AddOns`. | Route future metadata queries through `C_AddOns`. |
| Animation | Frames expose `CreateAnimationGroup`; groups expose `CreateAnimation`, `Play`, `Stop`, and `SetScript`; translation animations expose `SetOffset`; alpha animations expose `SetFromAlpha` and `SetToAlpha`. | `UI/Animations.lua` reuses per-frame animation groups to replay cascade clear, gravity, and refill records, and removes completion scripts before cancellation. |
| Animation | Frames expose `CreateAnimationGroup`; groups expose `CreateAnimation`, `Play`, `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 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. |
| 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 +26,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)
- 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)
- Friend contract: [`FriendListDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/FriendListDocumentation.lua)
- Combat-log event/access environments: [`CombatLogDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/CombatLogDocumentation.lua), [`CombatLogInternalDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/CombatLogInternalDocumentation.lua), and [`CombatLogSecureDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/CombatLogSecureDocumentation.lua)
- Ready-check contract: [`PartyInfoDocumentation.lua`](https://github.com/Gethe/wow-ui-source/blob/81d15e42f16f3473131880500e7a8c8eb88fa5e6/Interface/AddOns/Blizzard_APIDocumentationGenerated/PartyInfoDocumentation.lua)
+8 -7
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`, `UI/Backdrops.lua`, `UI/GemPool.lua`, and the core transition runner in `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`, `UI/Backdrops.lua`, `UI/GemPool.lua`, and `UI/Animations.lua`.
## Load order and ownership
@@ -12,15 +12,16 @@ The analysis phase gate is satisfied. This ownership map now governs runtime imp
6. `Engine/Matches.lua` — pure legacy-order match detection, axis-overlap reporting, and power/hyper-gem classification; clearing and scoring remain downstream responsibilities.
7. `Engine/Cascade.lua` — transactional clears, matched power-gem expansion, spawned-special preservation, fixed-cell gravity, bounded refill, and repeated transitions to a stable board. It emits logical movement/refill records but owns no animation or scoring.
8. `Engine/Scoring.lua` — legacy score arithmetic, combo/mode/level multipliers, wire-compatible statistics, probabilistic skill gains, one-time achievements, rank advancement, and pending/explicit level transitions. It emits presentation events and owns no frames, text, sound, or chat publishing.
9. `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`.
10. `UI/GemPool.lua` — fixed allocation and reuse of the 64 interactive gem frames, the sixteen board-art tiles, input-handler attachment, and change-aware projection from authoritative grid cells into normal/hyper texture layers. Power-gem overlay animation remains downstream.
11. `UI/Animations.lua` — deterministic clear/gravity/refill plans, reusable animation groups, 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 explosion phase barrier. Shards, lightwaves/lightning, hint bounce, and floating text remain follow-on presentation work; current fall timings are explicit modernization defaults pending in-game tuning.
12. `UI/HUD.lua` — score, timer, level, status, hint, and achievement presentation.
13. `UI/Compartment.lua` — addon-compartment click and hover callbacks.
9. `Engine/Input.lua` — authoritative selection and move-session coordination, optimistic adjacent swaps, match validation, immediate engine rollback for invalid moves, move accounting, cascade/scoring handoff, and presentation locking. Hyper activation, pause/resume, levels, restore, and game-over remain follow-on session work.
10. `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`.
11. `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.
12. `UI/Animations.lua` — deterministic swap/rollback and clear/gravity/refill plans, reusable animation groups, 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 explosion phase barrier. Shards, lightwaves/lightning, hint bounce, and floating text remain follow-on presentation work; current fall timings are explicit modernization defaults pending in-game tuning.
13. `UI/HUD.lua` — score, timer, level, status, hint, and achievement presentation.
14. `UI/Compartment.lua` — addon-compartment click and hover callbacks.
## Data flow
`SavedVariables initialization → engine-owned deterministic grid state → match/cascade/scoring transitions → UI rendering and animation → HUD/audio feedback`
`SavedVariables initialization → engine-owned deterministic grid state → input/match/cascade/scoring transitions → UI rendering and animation → HUD/audio feedback`
The engine will own authoritative game state. UI frames will render state and report input; they will not become the source of gameplay truth. Audio and HUD reactions consume completed transitions.
+132 -1
View File
@@ -47,6 +47,7 @@ LoadAddonFile("Bejeweled/Engine/Grid.lua", addon)
LoadAddonFile("Bejeweled/Engine/Matches.lua", addon)
LoadAddonFile("Bejeweled/Engine/Cascade.lua", addon)
LoadAddonFile("Bejeweled/Engine/Scoring.lua", addon)
LoadAddonFile("Bejeweled/Engine/Input.lua", addon)
LoadAddonFile("Bejeweled/UI/Backdrops.lua", addon)
LoadAddonFile("Bejeweled/UI/GemPool.lua", addon)
LoadAddonFile("Bejeweled/UI/Animations.lua", addon)
@@ -191,6 +192,9 @@ local function CreateGemPoolFrame(frameType, name, parent, template)
function animation:SetDuration(duration)
self.duration = duration
end
function animation:SetOrder(order)
self.order = order
end
function animation:SetOffset(offsetX, offsetY)
self.offsetX = offsetX
self.offsetY = offsetY
@@ -321,6 +325,12 @@ AssertEqual(redGemFrame.glow.alpha, 0, "presentation reset left gem glow visible
AssertEqual(redGemFrame.points[1][1], "TOPLEFT", "presentation reset gem anchor")
AssertEqual(redGemFrame.points[1][2], 50, "presentation reset gem x position")
AssertEqual(redGemFrame.points[1][3], -100, "presentation reset gem y position")
gemPool:SetSelection(2, 3)
assert(gemPool.selectedFrame == redGemFrame, "gem selection frame was not retained")
assert(redGemFrame.selector.shown, "selected gem selector was not shown")
gemPool:SetSelection(nil)
assert(gemPool.selectedFrame == nil, "cleared gem selection was retained")
assert(not redGemFrame.selector.shown, "cleared gem selector remained visible")
local playedFiles = {}
local playedSoundKits = {}
@@ -757,6 +767,114 @@ assert(not cancelledExplosionFrame.shown, "cancelled explosion remained visible"
AssertEqual(#animations.activeExplosions, 0, "cancelled explosion remained active")
AssertEqual(#animations.explosionPool, 1, "cancelled explosion was not pooled")
local inputGrid = addon.Grid:New()
FillStablePattern(inputGrid)
inputGrid:Set(2, 8, 1)
inputGrid:Set(3, 8, 1)
inputGrid:Set(1, 7, 1)
assert(not inputGrid:HasAnyMatch(), "input test board started with a match")
assert(inputGrid:IsLegalSwap(1, 7, 1, 8), "input test swap was not legal")
local inputPool = addon.GemPool:New(gemPoolParent, {
createFrame = CreateGemPoolFrame,
createBoardTiles = false,
})
inputPool:Project(inputGrid, true)
local inputAnimations = addon.Animations:New(inputPool, {
createFrame = CreateGemPoolFrame,
swapDuration = 0.2,
})
local inputProfile = addon.SavedVariables:CreateDefaultProfile()
local inputScoringState = addon.Scoring:NewState(addon.Constants.GAME_MODE_CLASSIC)
local inputRandomValues = { 2, 3, 4 }
local inputRandomIndex = 0
local function InputRandom(minimum, maximum)
inputRandomIndex = inputRandomIndex + 1
local value = inputRandomValues[((inputRandomIndex - 1) % #inputRandomValues) + 1]
return math.max(minimum, math.min(maximum, value))
end
local inputSounds = {}
local inputAudio = {
Play = function(_, soundName)
inputSounds[#inputSounds + 1] = soundName
return true
end,
}
local inputCompletions = {}
local inputCascades = {}
local input = addon.Input:New(inputGrid, inputPool, inputAnimations, {
audio = inputAudio,
scoringState = inputScoringState,
profile = inputProfile,
random = InputRandom,
requireLegalMove = false,
onMoveComplete = function(result)
inputCompletions[#inputCompletions + 1] = result
end,
onCascadeResolved = function(result)
inputCascades[#inputCascades + 1] = result.cascadeResult
end,
})
local inputHandlers = input:CreateGemHandlers()
assert(type(inputHandlers.onMouseDown) == "function", "input gem handler was not created")
local firstSelection = inputHandlers.onMouseDown(inputPool:GetFrame(8, 1), "LeftButton")
AssertEqual(firstSelection.status, "selected", "input first selection status")
assert(inputPool:GetFrame(8, 1).selector.shown, "input selection did not show selector")
local clearedSelection = input:HandleCell(1, 7)
AssertEqual(clearedSelection.reason, "nonadjacent", "nonadjacent selection clear reason")
assert(input:GetSelection() == nil, "nonadjacent click retained selection")
AssertEqual(input:HandleCell(1, 7).status, "selected", "legal swap source selection")
local acceptedMove = input:HandleCell(1, 8)
assert(acceptedMove.valid, "legal input move was rejected")
AssertEqual(acceptedMove.status, "animating", "accepted input move status")
assert(input:IsLocked(), "accepted input move did not lock input")
AssertEqual(input:HandleCell(4, 4).status, "locked", "locked input accepted another cell")
AssertEqual(inputScoringState.moves, 1, "accepted move count")
assert(inputGrid:HasAnyMatch(), "accepted swap was not applied before presentation")
AssertEqual(acceptedMove.swapRun.kind, "swap", "accepted swap animation kind")
AssertEqual(inputPool:GetFrame(1, 7).bejeweledSwapForwardAnimation.outbound.duration, 0.2, "accepted swap duration")
FinishAllGemAnimations()
assert(not input:IsLocked(), "cascade completion left input locked")
AssertEqual(acceptedMove.status, "complete", "accepted move completion status")
AssertEqual(#inputCascades, 1, "accepted move cascade callback count")
AssertEqual(#inputCompletions, 1, "accepted move completion callback count")
assert(inputCascades[1].stable, "accepted move cascade did not stabilize")
assert(not inputGrid:HasAnyMatch(), "accepted move cascade left a match")
assert(inputPool:GetFrame(1, 1).mouseEnabled, "accepted move completion left gem input disabled")
local invalidFirstX, invalidFirstY, invalidSecondX, invalidSecondY
for y = 1, addon.Constants.GRID_HEIGHT do
for x = 1, addon.Constants.GRID_WIDTH - 1 do
if not inputGrid:IsLegalSwap(x, y, x + 1, y) then
invalidFirstX, invalidFirstY = x, y
invalidSecondX, invalidSecondY = x + 1, y
break
end
end
if invalidFirstX then
break
end
end
assert(invalidFirstX, "input test could not find an invalid adjacent swap")
local invalidBefore = inputGrid:ExportLegacyBoard()
input:HandleCell(invalidFirstX, invalidFirstY)
local rejectedMove = input:HandleCell(invalidSecondX, invalidSecondY)
assert(not rejectedMove.valid, "invalid input move was accepted")
assert(input:IsLocked(), "invalid rollback did not lock input")
for y = 1, addon.Constants.GRID_HEIGHT do
for x = 1, addon.Constants.GRID_WIDTH do
AssertEqual(inputGrid:EncodeLegacyValue(inputGrid:Get(x, y)), invalidBefore[y][x], "invalid swap changed authoritative grid")
end
end
local rollbackAnimation = inputPool:GetFrame(invalidFirstX, invalidFirstY).bejeweledSwapRollbackAnimation
AssertEqual(rollbackAnimation.outbound.order, 1, "rollback outbound animation order")
AssertEqual(rollbackAnimation.returnTranslation.order, 2, "rollback return animation order")
FinishAllGemAnimations()
AssertEqual(rejectedMove.status, "rejected", "invalid move completion status")
assert(not input:IsLocked(), "invalid rollback left input locked")
AssertEqual(inputScoringState.moves, 1, "invalid move changed move count")
AssertEqual(inputSounds[#inputSounds], "Invalid", "invalid move sound")
FillStablePattern(cascadeGrid)
for x = 2, 5 do
cascadeGrid:Set(x, 8, 7)
@@ -850,6 +968,16 @@ AssertEqual(scoringProfile.stats.totalGemsMatched, 3, "legacy refill-backed gem
AssertEqual(scoringProfile.stats.gemMatch[1], 1, "per-color match statistic")
AssertEqual(scoringProfile.skill.skillPoints, 1, "match-three skill gain")
local moveProfile = addon.SavedVariables:CreateDefaultProfile()
local moveState = addon.Scoring:NewState(addon.Constants.GAME_MODE_CLASSIC, { moves = 99 })
local recordedMove = addon.Scoring:RecordMove(moveState, moveProfile, { random = function() return 1 end })
AssertEqual(recordedMove.moves, 100, "classic recorded move count")
AssertEqual(recordedMove.skillEvents[1].index, addon.Constants.SKILL_MOVE100, "classic move-100 skill index")
local timedMoveProfile = addon.SavedVariables:CreateDefaultProfile()
local timedMoveState = addon.Scoring:NewState(addon.Constants.GAME_MODE_TIMED, { moves = 4 })
addon.Scoring:RecordMove(timedMoveState, timedMoveProfile)
AssertEqual(timedMoveProfile.stats.timed.mostMoves, 5, "timed most-moves statistic")
local powerScoringProfile = addon.SavedVariables:CreateDefaultProfile()
local powerScoringState = addon.Scoring:NewState(addon.Constants.GAME_MODE_CLASSIC)
local powerScoring = addon.Scoring:ApplyCascade(powerScoringState, { steps = { powerCascade } }, powerScoringProfile, {
@@ -962,17 +1090,20 @@ 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.inputFactory == addon.Input, "addon initialization did not install Input")
assert(eventFrame.registeredEvent == nil, "initializer event was not unregistered")
local initializedGrid = addon.grid
local initializedAudio = addon.audio
local initializedBackdrops = addon.backdrops
local initializedGemPoolFactory = addon.gemPoolFactory
local initializedAnimationFactory = addon.animationFactory
local initializedInputFactory = addon.inputFactory
addon:Initialize({}, {})
assert(addon.grid == initializedGrid, "addon initialization is not idempotent")
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.inputFactory == initializedInputFactory, "Input initialization is not idempotent")
print("Runtime verification passed: cascade animation, gem projection, UI backdrops, audio, SavedVariables, and deterministic gameplay engine.")
print("Runtime verification passed: input sessions, cascade animation, gem projection, UI backdrops, audio, SavedVariables, and deterministic gameplay engine.")
+1
View File
@@ -27,6 +27,7 @@ try {
"Engine\Matches.lua",
"Engine\Cascade.lua",
"Engine\Scoring.lua",
"Engine\Input.lua",
"UI\Backdrops.lua",
"UI\GemPool.lua",
"UI\Animations.lua"