[Scummvm-git-logs] scummvm master -> 3261a4399e0cabf98940daaeedcc423cd3c30194

bluegr noreply at scummvm.org
Mon Jun 22 20:55:21 UTC 2026


This automated email contains information about 5 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .

Summary:
3fdbd96149 NANCY: Implement CardGamePuzzle for Nancy11
f37cb82ba1 NANCY: Fix multiple NotebookPopup issues for Nancy 10+
6e2829654c NANCY: Make call button green in the Nancy10+ phone directory screen
659a24f7f8 Fix cellphone indicators while placing calls in Nancy10+
3261a4399e NANCY: Implement completion animation functionality in OneBuildPuzzle


Commit: 3fdbd96149de35b5f76cab6f56292499563b44ce
    https://github.com/scummvm/scummvm/commit/3fdbd96149de35b5f76cab6f56292499563b44ce
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T23:55:04+03:00

Commit Message:
NANCY: Implement CardGamePuzzle for Nancy11

Two-player card game versus an AI opponent. Cards are dealt into a
shared grid; each player stacks up to three cards per column, and a
full column scores a "set"

Changed paths:
  A engines/nancy/action/puzzle/cardgamepuzzle.cpp
  A engines/nancy/action/puzzle/cardgamepuzzle.h
    engines/nancy/action/arfactory.cpp
    engines/nancy/module.mk


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index d8fd412d6ae..3d2b434f847 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -39,6 +39,7 @@
 #include "engines/nancy/action/puzzle/beadpuzzle.h"
 #include "engines/nancy/action/puzzle/bulpuzzle.h"
 #include "engines/nancy/action/puzzle/bombpuzzle.h"
+#include "engines/nancy/action/puzzle/cardgamepuzzle.h"
 #include "engines/nancy/action/puzzle/collisionpuzzle.h"
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
@@ -505,8 +506,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 245:
 		return new TypingQuizPuzzle();
 	case 246:
-		warning("CardGamePuzzle");	// TODO
-		return nullptr;
+		return new CardGamePuzzle();
 	default:
 		warning("Unknown action record type %d", type);
 		return nullptr;
diff --git a/engines/nancy/action/puzzle/cardgamepuzzle.cpp b/engines/nancy/action/puzzle/cardgamepuzzle.cpp
new file mode 100644
index 00000000000..d1c3ab927fb
--- /dev/null
+++ b/engines/nancy/action/puzzle/cardgamepuzzle.cpp
@@ -0,0 +1,522 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/cardgamepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void CardGamePuzzle::readData(Common::SeekableReadStream &stream) {
+	// The whole record is 0x1346 bytes. The loader copies it verbatim and the engine reads fields
+	// at fixed offsets, so the layout is identical across scenes; the comments give those offsets.
+	readFilename(stream, _imageName);  // 0x000
+
+	_unknown21      = stream.readByte();      // 0x021
+	_switchTurnRule = stream.readByte();      // 0x022
+	_startPlayer    = stream.readByte();      // 0x023
+	_dealMode       = stream.readByte();      // 0x024
+	_numCols        = stream.readUint16LE();  // 0x025
+	_numRows        = stream.readUint16LE();  // 0x027
+	_dealRounds     = stream.readUint16LE();  // 0x029
+
+	readRect(stream, _turnHighlightSrc[0]);   // 0x02b
+	readRect(stream, _turnHighlightSrc[1]);
+	readRect(stream, _turnHighlightDest[0]);  // 0x04b
+	readRect(stream, _turnHighlightDest[1]);
+
+	_faceUpSrc.resize(4 * kMaxCols);          // 0x06b: 4 rows of 13 card src rects
+	for (uint i = 0; i < _faceUpSrc.size(); ++i)
+		readRect(stream, _faceUpSrc[i]);
+
+	stream.skip(0x47b - 0x3ab);               // 0x3ab: score-digit src rects (10) + padding
+	readRect(stream, _suitScoreSrc);          // 0x47b
+
+	_scoreDest.resize(2 * kMaxCols);          // 0x48b: 2 sides of 13 score-tile positions
+	for (uint i = 0; i < _scoreDest.size(); ++i)
+		readRect(stream, _scoreDest[i]);
+
+	_moveAnimSteps = stream.readUint16LE();   // 0x62b
+	_moveAnimDelta = stream.readUint16LE();   // 0x62d
+	_moveAnimDelay = stream.readUint32LE();   // 0x62f
+
+	readRect(stream, _cardDisplayDest[0]);    // 0x633
+	readRect(stream, _cardDisplayDest[1]);
+
+	_columnButtons.resize(kMaxCols);          // 0x653: one play button under each column
+	for (uint i = 0; i < _columnButtons.size(); ++i)
+		readRect(stream, _columnButtons[i]);
+
+	_faceDownSrc.resize(3 * kMaxCols);        // 0x723: 3 rows of 13 card-back src rects
+	for (uint i = 0; i < _faceDownSrc.size(); ++i)
+		readRect(stream, _faceDownSrc[i]);
+
+	// 0x993: deal animation params + frames, then the voiced-line block (0xba3..0x1304, 33-byte
+	// name slots, per-side blocks 0x33d apart). Pull out the few lines we play; skip the rest.
+	stream.skip(0xbc4 - 0x993);                    // deal animation params + frames
+	readFilename(stream, _moveVoiceName);          // 0xbc4
+	readFilename(stream, _dealVoiceName);          // 0xbe5
+	stream.skip(0xc2b - 0xc06);                    // 0xc06 deal SFX (alt)
+	for (int i = 0; i < kMaxCols; ++i)             // 0xc2b AI per-column match table (-> 0xdd8)
+		readFilename(stream, _matchVoice[0][i]);
+	stream.skip(0xee0 - 0xdd8);                    // side-0 no-move / made-move lines
+	readFilename(stream, _enemyScoredVoiceName);   // 0xee0
+	stream.skip(0xf22 - 0xf01);                    // alt variant
+	readFilename(stream, _endVoiceName[0]);        // 0xf22 (AI wins)
+	stream.skip(0xf68 - 0xf43);                    // remaining side-0 lines
+	for (int i = 0; i < kMaxCols; ++i)             // 0xf68 player per-column match table (-> 0x1115)
+		readFilename(stream, _matchVoice[1][i]);
+	stream.skip(0x121d - 0x1115);                  // side-1 no-move / made-move lines
+	readFilename(stream, _playerScoredVoiceName);  // 0x121d (= 0xee0 + 0x33d)
+	stream.skip(0x125f - 0x123e);                  // alt variant
+	readFilename(stream, _endVoiceName[1]);        // 0x125f (player wins, = 0xf22 + 0x33d)
+	stream.skip(0x1304 - 0x1280);                  // remaining voice block
+
+	_winSceneStartPlayer = stream.readUint16LE();        // 0x1304
+	_winSceneStartEnemy  = stream.readUint16LE();        // 0x1306
+	_winScene.frameID          = stream.readUint16LE();  // 0x1308
+	_winScene.verticalOffset   = stream.readUint16LE();  // 0x130a
+	_winScene.continueSceneSound = stream.readUint16LE();// 0x130c
+	stream.skip(0x131c - 0x130e);                        // listener vector + frame id
+	_winFlagPlayer = stream.readSint16LE();              // 0x131c
+	_winFlagEnemy  = stream.readSint16LE();              // 0x131e
+	_exitScene     = stream.readUint16LE();              // 0x1320
+	_exitSceneChange.frameID          = stream.readUint16LE(); // 0x1322
+	_exitSceneChange.verticalOffset   = stream.readUint16LE(); // 0x1324
+	_exitSceneChange.continueSceneSound = stream.readUint16LE();// 0x1326
+	stream.skip(0x1336 - 0x1328);                        // listener vector + flag
+
+	readRect(stream, _exitHotspot);                      // 0x1336 (ends at 0x1346)
+}
+
+// Deal a single card to the given side: pick a random still-available cell from the shared deck
+// and place it in that side's column, capping the column. While more than a row's worth of cards
+// remain on the table, columns are kept below 3 so the opening deal stays spread out.
+bool CardGamePuzzle::dealOne(int player) {
+	if (_deckRemaining < 1) {
+		return false;
+	}
+
+	bool allowFull = _deckRemaining <= _numCols;
+
+	for (int attempts = 0; attempts < 10000; ++attempts) {
+		int row = g_nancy->_randomSource->getRandomNumber(_numRows - 1);
+		int col = g_nancy->_randomSource->getRandomNumber(_numCols - 1);
+
+		if (_availMap[row][col] && (allowFull || _board[player].colCount[col] + 1 < 3)) {
+			_board[player].grid[row][col] = 1;
+			_board[player].colCount[col]++;
+			if (_board[player].colCount[col] >= 3 && !_board[player].colComplete[col]) {
+				_board[player].colComplete[col] = 1;
+				_board[player].score++;
+				playVoice(player == 1 ? _playerScoredVoiceName : _enemyScoredVoiceName);
+			}
+			_availMap[row][col] = 0;
+			--_deckRemaining;
+			return true;
+		}
+	}
+
+	return false;
+}
+
+void CardGamePuzzle::drawBoard() {
+	_drawSurface.clear(_drawSurface.getTransparentColor());
+
+	// The visible tableau is side 1's grid; a present card is drawn face up, an empty cell shows
+	// the scene background (which carries the card backs). Cards mid-slide are drawn offset.
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			if (_board[1].grid[row][col] == 1) {
+				const Common::Rect &src = _faceUpSrc[row * kMaxCols + col];
+				const Common::Rect &dest = _faceDownSrc[row * kMaxCols + col];
+				int yOff = (_animating && _appearing[row][col]) ? _animStep * kSlidePerStep : 0;
+				_drawSurface.blitFrom(_image, src, Common::Point(dest.left, dest.top + yOff));
+			}
+		}
+	}
+
+	// Cards that just left side 1's grid slide downward out of their cell.
+	if (_animating) {
+		for (int row = 0; row < _numRows; ++row) {
+			for (int col = 0; col < _numCols; ++col) {
+				if (_leaving[row][col]) {
+					const Common::Rect &src = _faceUpSrc[row * kMaxCols + col];
+					const Common::Rect &dest = _faceDownSrc[row * kMaxCols + col];
+					int yOff = (_moveAnimSteps - _animStep) * kSlidePerStep;
+					_drawSurface.blitFrom(_image, src, Common::Point(dest.left, dest.top + yOff));
+				}
+			}
+		}
+	}
+
+	// A completed column shows a suit-score tile on the owning side.
+	for (int side = 0; side < 2; ++side) {
+		for (int col = 0; col < _numCols; ++col) {
+			if (_board[side].colComplete[col]) {
+				const Common::Rect &dest = _scoreDest[side * kMaxCols + col];
+				_drawSurface.blitFrom(_image, _suitScoreSrc, Common::Point(dest.left, dest.top));
+			}
+		}
+	}
+
+	// Current-turn indicator
+	if (_currentTurn == 0 || _currentTurn == 1) {
+		_drawSurface.blitFrom(_image, _turnHighlightSrc[_currentTurn],
+			Common::Point(_turnHighlightDest[_currentTurn].left, _turnHighlightDest[_currentTurn].top));
+	}
+
+	_needsRedraw = true;
+}
+
+// The current side takes every card the opponent still holds in this column (each transfers at its
+// own grid cell). Owning all three cells of a column scores a set.
+bool CardGamePuzzle::playColumn(int col) {
+	int mover = _currentTurn;
+	int opponent = mover ^ 1;
+	bool moved = false;
+
+	for (int row = 0; row < _numRows; ++row) {
+		if (_board[opponent].grid[row][col] == 1) {
+			_board[opponent].grid[row][col] = 0;
+			_board[opponent].colCount[col]--;
+			_board[mover].grid[row][col] = 1;
+			_board[mover].colCount[col]++;
+			moved = true;
+		}
+	}
+
+	if (moved && _board[mover].colCount[col] >= 3 && !_board[mover].colComplete[col]) {
+		_board[mover].colComplete[col] = 1;
+		_board[mover].score++;
+		playVoice(mover == 1 ? _playerScoredVoiceName : _enemyScoredVoiceName);
+	} else if (moved && col < kMaxCols) {
+		// A card-specific line for the played column (silent in scenes that don't use them)
+		playVoice(_matchVoice[mover][col]);
+	}
+
+	return moved;
+}
+
+// The AI (the side to move) looks for a column where the opponent still has a card to take,
+// preferring columns it is already building (>=2, then >=1 of its own), and avoiding repeating its
+// last pick. Returns -1 if it has no productive steal available.
+int CardGamePuzzle::aiPickColumn() {
+	int mover = _currentTurn;
+	int opponent = mover ^ 1;
+
+	Common::Array<int> strong, ownSome, any;
+	for (int col = 0; col < (int)_numCols; ++col) {
+		bool opponentHas = false;
+		for (int row = 0; row < _numRows; ++row) {
+			if (_board[opponent].grid[row][col] == 1) {
+				opponentHas = true;
+				break;
+			}
+		}
+		if (!opponentHas || col == _lastAiColumn) {
+			continue;
+		}
+
+		any.push_back(col);
+		if (_board[mover].colCount[col] >= 1) {
+			ownSome.push_back(col);
+		}
+		if (_board[mover].colCount[col] >= 2) {
+			strong.push_back(col);
+		}
+	}
+
+	const Common::Array<int> &pool = !strong.empty() ? strong : (!ownSome.empty() ? ownSome : any);
+	if (pool.empty()) {
+		return -1;
+	}
+
+	return pool[g_nancy->_randomSource->getRandomNumber(pool.size() - 1)];
+}
+
+// After a side plays a column it draws a card from the deck, then the turn passes. When the player
+// has moved, the AI immediately takes its turn. The game ends once the deck is exhausted.
+void CardGamePuzzle::finishMove() {
+	dealOne(_currentTurn);
+	drawBoard();
+
+	if (_deckRemaining < 1) {
+		endGame();
+		return;
+	}
+
+	_currentTurn ^= 1;
+
+	if (_currentTurn == 0) {
+		// AI turn
+		int col = aiPickColumn();
+		if (col != -1) {
+			_lastAiColumn = col;
+			playColumn(col);
+		}
+
+		dealOne(_currentTurn);
+		drawBoard();
+
+		if (_deckRemaining < 1) {
+			endGame();
+			return;
+		}
+
+		_currentTurn = 1;
+	}
+}
+
+void CardGamePuzzle::endGame() {
+	_gameOver = true;
+
+	// Play the winner's end-of-game line, then hold briefly before the result scene (updateGraphics
+	// fires the transition once the wait elapses).
+	int winner = (_board[1].score >= _board[0].score) ? 1 : 0;
+	playVoice(_endVoiceName[winner]);
+	bool hasVoice = !_endVoiceName[winner].empty() && _endVoiceName[winner] != "NO SOUND";
+	_awaitingEnd = true;
+	_endWaitUntil = g_nancy->getTotalPlayTime() + (hasVoice ? 3000 : 500);
+
+	drawBoard();
+}
+
+void CardGamePuzzle::startMoveAnimation(const bool beforeGrid[kMaxRows][kMaxCols]) {
+	_animating = false;
+	for (int row = 0; row < kMaxRows; ++row) {
+		for (int col = 0; col < kMaxCols; ++col) {
+			bool now = (_board[1].grid[row][col] == 1);
+			_appearing[row][col] = (!beforeGrid[row][col] && now);
+			_leaving[row][col] = (beforeGrid[row][col] && !now);
+			if (_appearing[row][col] || _leaving[row][col]) {
+				_animating = true;
+			}
+		}
+	}
+
+	if (_animating) {
+		_animStep = MAX<int>(_moveAnimSteps, 1);
+		_animNextStep = g_nancy->getTotalPlayTime() + _moveAnimDelay;
+	}
+
+	drawBoard();
+}
+
+void CardGamePuzzle::updateGraphics() {
+	if (_animating) {
+		if (g_nancy->getTotalPlayTime() < _animNextStep) {
+			return;
+		}
+
+		--_animStep;
+		_animNextStep = g_nancy->getTotalPlayTime() + _moveAnimDelay;
+
+		if (_animStep <= 0) {
+			_animating = false;
+		}
+
+		drawBoard();
+		return;
+	}
+
+	// Once the final move has settled and the winner's line has had its moment, transition out.
+	if (_gameOver && _awaitingEnd && g_nancy->getTotalPlayTime() >= _endWaitUntil) {
+		_awaitingEnd = false;
+		_state = kActionTrigger;
+	}
+}
+
+void CardGamePuzzle::playVoice(const Common::String &name) {
+	if (name.empty() || name == "NO SOUND") {
+		return;
+	}
+
+	g_nancy->_sound->stopSound(_voiceSound);
+	_voiceSound.name = name;
+	_voiceSound.channelID = 8;
+	_voiceSound.numLoops = 1;
+	_voiceSound.volume = 85;
+	g_nancy->_sound->loadSound(_voiceSound);
+	g_nancy->_sound->playSound(_voiceSound);
+}
+
+void CardGamePuzzle::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+	_image.setTransparentColor(_drawSurface.getTransparentColor());
+
+	// Reset board state and the shared deck (every table cell starts available)
+	for (int side = 0; side < 2; ++side) {
+		_board[side] = PlayerBoard();
+	}
+	for (int row = 0; row < kMaxRows; ++row)
+		for (int col = 0; col < kMaxCols; ++col)
+			_availMap[row][col] = (row < _numRows && col < _numCols) ? 1 : 0;
+
+	_deckRemaining = _numCols * _numRows;
+	_currentTurn = _startPlayer;
+	_lastAiColumn = -1;
+	_gameOver = false;
+	_gaveUp = false;
+	_animating = false;
+	_animStep = 0;
+	_awaitingEnd = false;
+
+	// Opening deal: _dealRounds cards to each side, alternating
+	for (int round = 0; round < _dealRounds; ++round) {
+		dealOne(0);
+		dealOne(1);
+	}
+
+	RenderObject::init();
+}
+
+void CardGamePuzzle::execute() {
+	if (_state == kBegin) {
+		init();
+		registerGraphics();
+		drawBoard();
+		_state = kRun;
+	} else if (_state == kActionTrigger) {
+		g_nancy->_sound->stopSound(_voiceSound);
+
+		SceneChangeDescription sceneChange;
+
+		if (_gaveUp) {
+			sceneChange = _exitSceneChange;
+			sceneChange.sceneID = _exitScene;
+		} else {
+			sceneChange = _winScene;
+
+			const int playerScore = _board[1].score;
+			const int aiScore = _board[0].score;
+			const bool tie = (playerScore == aiScore);
+
+			// The result normally uses the first outcome scene; the second is the tie/alternate
+			// path the 0xff deal mode takes. 9999 marks an absent scene.
+			if (tie && _dealMode == 0xff && _winSceneStartEnemy != 9999) {
+				sceneChange.sceneID = _winSceneStartEnemy;
+			} else {
+				sceneChange.sceneID = (_winSceneStartPlayer != 9999) ? _winSceneStartPlayer : _winSceneStartEnemy;
+			}
+
+			// Record the outcome: the player-win flag when ahead, else the alternate flag on a tie.
+			if (playerScore > aiScore && _winFlagPlayer != -1) {
+				FlagDescription flag;
+				flag.label = _winFlagPlayer;
+				flag.flag = g_nancy->_true;
+				NancySceneState.setEventFlag(flag);
+			} else if (tie && _winFlagEnemy != -1) {
+				FlagDescription flag;
+				flag.label = _winFlagEnemy;
+				flag.flag = g_nancy->_true;
+				NancySceneState.setEventFlag(flag);
+			}
+		}
+
+		NancySceneState.changeScene(sceneChange);
+		finishExecution();
+	}
+
+	// The deal/match slide animations and the voiced lines come in a later stage.
+}
+
+void CardGamePuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _awaitingEnd) {
+		return;
+	}
+
+	// Exit hotspot is always available; leaving this way is "giving up" (goes to the exit scene)
+	if (NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_gaveUp = true;
+			_state = kActionTrigger;
+		}
+		return;
+	}
+
+	if (_gameOver || _animating || _currentTurn != 1) {
+		return;
+	}
+
+	int opponent = _currentTurn ^ 1;
+
+	for (int col = 0; col < (int)_numCols; ++col) {
+		Common::Rect button = NancySceneState.getViewport().convertViewportToScreen(_columnButtons[col]);
+		if (!button.contains(input.mousePos)) {
+			continue;
+		}
+
+		// A column is playable only while the opponent still holds a card in it
+		bool hasTarget = false;
+		for (int row = 0; row < _numRows; ++row) {
+			if (_board[opponent].grid[row][col] == 1) {
+				hasTarget = true;
+				break;
+			}
+		}
+
+		if (!hasTarget) {
+			return;
+		}
+
+		g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			bool before[kMaxRows][kMaxCols];
+			for (int r = 0; r < kMaxRows; ++r)
+				for (int c = 0; c < kMaxCols; ++c)
+					before[r][c] = (_board[1].grid[r][c] == 1);
+
+			playVoice(_moveVoiceName);
+			playColumn(col);
+			finishMove();
+
+			// finishMove may have ended the game (kActionTrigger); only animate if still playing
+			if (_state == kRun) {
+				startMoveAnimation(before);
+			}
+		}
+
+		return;
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/cardgamepuzzle.h b/engines/nancy/action/puzzle/cardgamepuzzle.h
new file mode 100644
index 00000000000..a251884481c
--- /dev/null
+++ b/engines/nancy/action/puzzle/cardgamepuzzle.h
@@ -0,0 +1,148 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_CARDGAMEPUZZLE_H
+#define NANCY_ACTION_CARDGAMEPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Two-player card game versus an AI opponent, new in Nancy 11 (Curse of Blackmoor Manor, AR 246).
+// Cards are dealt into a shared grid; each player stacks up to three cards per column, and a full
+// column scores a "set". Implementation is staged: this currently parses the data chunk and sets up
+// the board; the deal/match/AI gameplay is being filled in incrementally.
+class CardGamePuzzle : public RenderActionRecord {
+public:
+	CardGamePuzzle() : RenderActionRecord(7) {}
+	virtual ~CardGamePuzzle() {}
+
+	void init() override;
+	void updateGraphics() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	// Up to 13 columns / 4 rows are addressable in the data (per-row stride is 13 card rects).
+	static const int kMaxCols = 13;
+	static const int kMaxRows = 4;
+
+	// Per-side board: a grid of dealt cards, per-column counts (capped at 3), per-column "complete"
+	// flags (a full column of 3 scores a set) and the running score.
+	struct PlayerBoard {
+		int grid[kMaxRows][kMaxCols];
+		int colCount[kMaxCols];
+		int colComplete[kMaxCols];
+		int score;
+	};
+
+	Common::String getRecordTypeName() const override { return "CardGamePuzzle"; }
+
+	bool dealOne(int player); // draw a card from the deck to a side; false if the deck is empty
+	void drawBoard();
+	// Current side takes every opponent card in the given column; returns true if a card moved.
+	bool playColumn(int col);
+	int aiPickColumn();       // the AI's column choice, or -1 if it has no productive move
+	void finishMove();        // draw a card, pass the turn, run the AI, detect the end of the game
+	void endGame();
+	// Compare side 1's grid against a pre-move snapshot and start sliding the changed cards.
+	void startMoveAnimation(const bool beforeGrid[kMaxRows][kMaxCols]);
+	void playVoice(const Common::String &name); // play a voiced line / SFX on the card-game channel
+
+	Common::Path _imageName;
+
+	// Header flags / dimensions
+	byte _unknown21 = 0;
+	byte _switchTurnRule = 0; // data+0x22: how the turn passes after a play
+	byte _startPlayer = 0;    // data+0x23: which side plays first (also the human side)
+	byte _dealMode = 0;       // data+0x24: deal/scoring variant (0, 2 or 0xff)
+	uint16 _numCols = 0;      // data+0x25
+	uint16 _numRows = 0;      // data+0x27
+	uint16 _dealRounds = 0;   // data+0x29
+
+	// Source/destination rects (all addressed [row * kMaxCols + col] or [side * kMaxCols + col])
+	Common::Rect _turnHighlightSrc[2];  // data+0x2b
+	Common::Rect _turnHighlightDest[2]; // data+0x4b
+	Common::Array<Common::Rect> _faceUpSrc;   // data+0x6b, 4 rows
+	Common::Rect _suitScoreSrc;               // data+0x47b
+	Common::Array<Common::Rect> _scoreDest;   // data+0x48b, 2 sides
+	Common::Rect _cardDisplayDest[2];         // data+0x633
+	Common::Array<Common::Rect> _columnButtons; // data+0x653, one per column
+	Common::Array<Common::Rect> _faceDownSrc; // data+0x723, 3 rows
+	Common::Rect _exitHotspot;                // data+0x1336
+
+	// Animation timing (data+0x62b)
+	uint16 _moveAnimSteps = 0;
+	uint16 _moveAnimDelta = 0;
+	uint32 _moveAnimDelay = 0;
+
+	// Outcome scenes (data+0x1304 / 0x1320). The win block has two scene ids that share one set of
+	// transition params (frame/scroll/sound), held in _winScene; the id is chosen by the result.
+	uint16 _winSceneStartPlayer = 0; // data+0x1304
+	uint16 _winSceneStartEnemy = 0;  // data+0x1306
+	SceneChangeDescription _winScene;
+	int16 _winFlagPlayer = -1;       // data+0x131c
+	int16 _winFlagEnemy = -1;        // data+0x131e
+	uint16 _exitScene = 0;           // data+0x1320
+	SceneChangeDescription _exitSceneChange;
+	bool _gaveUp = false;            // left via the exit hotspot rather than playing out
+
+	// Voiced lines / SFX (all on the card-game channel). Read selectively from the 0xba3..0x1304 block.
+	Common::String _moveVoiceName;         // data+0xbc4 (card-move SFX)
+	Common::String _dealVoiceName;         // data+0xbe5 (card-deal SFX)
+	Common::String _matchVoice[2][kMaxCols]; // data+0xc2b (AI) / 0xf68 (player), keyed by column
+	Common::String _enemyScoredVoiceName;  // data+0xee0 (a column completed for the AI)
+	Common::String _playerScoredVoiceName; // data+0x121d (a column completed for the player)
+	Common::String _endVoiceName[2];       // data+0xf22 (AI wins) / 0x125f (player wins)
+	SoundDescription _voiceSound;
+
+	// At game over, the winner's line plays before the result scene transition.
+	bool _awaitingEnd = false;
+	uint32 _endWaitUntil = 0;
+
+	// Runtime board state
+	Graphics::ManagedSurface _image;
+	PlayerBoard _board[2];
+	byte _availMap[kMaxRows][kMaxCols]; // shared deck: 1 = card still on the table
+	int _deckRemaining = 0;
+	int _currentTurn = 0;               // which side is to play (the player's side draws the grid)
+	int _lastAiColumn = -1;             // the AI avoids immediately repeating its previous column
+	bool _gameOver = false;
+
+	// Slide animation for cards that changed hands on the last move (visual only; the board state
+	// is already up to date). Appeared cards slide into place, departed cards slide away.
+	static const int kSlidePerStep = 12;
+	bool _appearing[kMaxRows][kMaxCols] = {};
+	bool _leaving[kMaxRows][kMaxCols] = {};
+	int _animStep = 0;
+	uint32 _animNextStep = 0;
+	bool _animating = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_CARDGAMEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index d148fc80388..0d37c38de54 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -22,6 +22,7 @@ MODULE_OBJS = \
   action/puzzle/beadpuzzle.o \
   action/puzzle/bulpuzzle.o \
   action/puzzle/bombpuzzle.o \
+  action/puzzle/cardgamepuzzle.o \
   action/puzzle/collisionpuzzle.o \
   action/puzzle/cubepuzzle.o \
   action/puzzle/cuttingpuzzle.o \


Commit: f37cb82ba1ca6a4799829c4fa48cc5562450960f
    https://github.com/scummvm/scummvm/commit/f37cb82ba1ca6a4799829c4fa48cc5562450960f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T23:55:06+03:00

Commit Message:
NANCY: Fix multiple NotebookPopup issues for Nancy 10+

- Fix tasks and journal having each other's content
- Drop redundant paper tiling. Fixes having the edges scroll with text
- Fix top and bottom padding
- Increase the notebook's scrollable text height
- Fix checkboxes in tasks
- Fix checkbox alignment

Changed paths:
    engines/nancy/misc/hypertext.cpp
    engines/nancy/ui/notebookpopup.cpp
    engines/nancy/ui/notebookpopup.h


diff --git a/engines/nancy/misc/hypertext.cpp b/engines/nancy/misc/hypertext.cpp
index 99ad71b1d98..56f7073eea1 100644
--- a/engines/nancy/misc/hypertext.cpp
+++ b/engines/nancy/misc/hypertext.cpp
@@ -323,7 +323,9 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 						auto *mark = GetEngineData(MARK);
 						assert(mark);
 
-						if (lineNumber == 0) {
+						const bool useLineAlignedMark = g_nancy->getGameType() >= kGameTypeNancy10;
+
+						if (!useLineAlignedMark && lineNumber == 0) {
 							// A mark on the first line pushes up all text
 							if (textBounds.top - _imageVerticalOffset > 3) {
 								_imageVerticalOffset -= 3;
@@ -334,10 +336,17 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 
 						Common::Rect markSrc = mark->_markSrcs[change.index];
 						Common::Rect markDest = markSrc;
-						markDest.moveTo(textBounds.left + horizontalOffset + (newLineStart ? 0 : leftOffsetNonNewline) + 1,
-							lineNumber == 0 ?
-								textBounds.top - ((font->getFontHeight() + 1) / 2) + _imageVerticalOffset + 4 :
-								textBounds.top + _numDrawnLines * lineStep(font) + _imageVerticalOffset - 4);
+						if (useLineAlignedMark) {
+							// Nancy 10+: align the mark with the current
+							// line top, matching the original engine.
+							markDest.moveTo(textBounds.left + horizontalOffset + (newLineStart ? 0 : leftOffsetNonNewline) + 1,
+								textBounds.top + _numDrawnLines * lineStep(font) + _imageVerticalOffset);
+						} else {
+							markDest.moveTo(textBounds.left + horizontalOffset + (newLineStart ? 0 : leftOffsetNonNewline) + 1,
+								lineNumber == 0 ?
+									textBounds.top - ((font->getFontHeight() + 1) / 2) + _imageVerticalOffset + 4 :
+									textBounds.top + _numDrawnLines * lineStep(font) + _imageVerticalOffset - 4);
+						}
 
 						// For now we do not check if we need to go to new line; neither does the original
 						_fullSurface.blitFrom(g_nancy->_graphics->_object0, markSrc, markDest);
diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/notebookpopup.cpp
index 9e46c12d915..555fbba55ac 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/notebookpopup.cpp
@@ -44,10 +44,9 @@ NotebookPopup::NotebookPopup() :
 		_uinbData(nullptr),
 		_activeTab(0) {}
 
-// Cap on how tall HypertextParser's working surface can grow. Notebook
-// journals on Nancy 10+ rarely exceed a few hundred wrapped lines; this
-// gives plenty of headroom while keeping the allocation bounded.
-static const uint16 kHypertextSurfaceHeight = 4096;
+// Working-surface height for HypertextParser. Matches the original
+// engine's allocation so long journals don't get clipped.
+static const uint16 kHypertextSurfaceHeight = 16000;
 
 void NotebookPopup::init() {
 	_uinbData = GetEngineData(UINB);
@@ -74,15 +73,14 @@ void NotebookPopup::init() {
 	bounds.moveTo(0, 0);
 	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getInputPixelFormat());
 
-	// Set up HypertextParser's scratch surfaces. Width matches the
-	// chunk's text rect; height is generously oversized so journal
-	// content for any plausible save state fits without truncation
-	// (overflow is handled by scrolling, not by growing the surface).
-	// Background color is irrelevant — paintPaperIntoFullSurface()
-	// re-tiles the popup's paper texture after every clear() so the
-	// text always sits on real notebook paper.
+	// Transparent-keyed scratch surfaces so text blits over the paper
+	// painted by drawBackground() — paper stays stationary while text
+	// scrolls. Color 0 would clip real font pixels in Nancy fonts.
+	const uint32 trans = g_nancy->_graphics->getTransColor();
 	initSurfaces(_uinbData->textRect.width(), kHypertextSurfaceHeight,
-		g_nancy->_graphics->getInputPixelFormat(), 0, 0);
+		g_nancy->_graphics->getInputPixelFormat(), trans, trans);
+	_fullSurface.setTransparentColor(trans);
+	_textHighlightSurface.setTransparentColor(trans);
 
 	// Pick the first enabled tab as the initially active one
 	_activeTab = 0;
@@ -401,31 +399,6 @@ void NotebookPopup::refreshContent() {
 	drawForeground();
 }
 
-void NotebookPopup::paintPaperIntoFullSurface() {
-	const Common::Rect &normSrc = _uinbData->header.normalSrcRect;
-	const Common::Rect &normDest = _uinbData->header.normalDestRect;
-	const Common::Rect &chunkTextRect = _uinbData->textRect;
-
-	const int16 paperLeft = normSrc.left + (chunkTextRect.left - normDest.left);
-	const int16 paperTop  = normSrc.top  + (chunkTextRect.top  - normDest.top);
-	const Common::Rect paperSrc(paperLeft, paperTop,
-								paperLeft + chunkTextRect.width(),
-								paperTop  + chunkTextRect.height());
-
-	const int stripH = paperSrc.height();
-	if (stripH <= 0)
-		return;
-
-	int y = 0;
-	while (y < (int)_fullSurface.h) {
-		const int rowH = MIN<int>(stripH, (int)_fullSurface.h - y);
-		Common::Rect src = paperSrc;
-		src.bottom = src.top + rowH;
-		_fullSurface.blitFrom(_overlayImage, src, Common::Point(0, y));
-		y += rowH;
-	}
-}
-
 void NotebookPopup::buildTextLines() {
 	if (!_uinbData)
 		return;
@@ -434,7 +407,8 @@ void NotebookPopup::buildTextLines() {
 	if (!tab.enabled)
 		return;
 
-	const uint16 surfaceID = (uint16)tab.id + 2;
+	// tab.id 1 (top/book) → Journal; tab.id 2 (bottom/clipboard) → Tasks.
+	const uint16 surfaceID = (tab.id == 1) ? kNotebookTabJournal : kNotebookTabTasks;
 
 	JournalData *journalData = (JournalData *)NancySceneState.getPuzzleData(JournalData::getTag());
 	if (!journalData)
@@ -455,24 +429,36 @@ void NotebookPopup::buildTextLines() {
 	if (!journalData->journalEntries.contains(surfaceID))
 		return;
 
+	// Newest-first. All entries go into one addTextLine — separate
+	// calls would put every mark on its own "first line" and stack
+	// them at the textbox top.
 	const Common::Array<JournalData::Entry> &entries = journalData->journalEntries[surfaceID];
-	for (uint i = 0; i < entries.size(); ++i) {
+	Common::String combined;
+	for (int i = (int)entries.size() - 1; i >= 0; --i) {
 		Common::String stringID = entries[i].stringID;
 		Common::String body = getTextFromCaseInsensitiveKey(autotext->texts, stringID);
 
-		// Task rows get a `<N>` prefix that turns into a MARK sprite;
-		// the "complete" sentinel (8) maps to secondaryFontAttr.
 		if (surfaceID == kNotebookTabTasks && entries[i].mark != 0) {
 			uint16 markValue = entries[i].mark;
 			if (markValue == 8) {
 				markValue = _uinbData->secondaryFontAttr;
+			} else if (markValue == 7) {
+				// Engine maps <7> -> sprite index 0, same as <1>.
+				markValue = 1;
 			}
 			if (markValue >= 1 && markValue <= 5) {
 				body = Common::String::format("<%u>", markValue) + body;
 			}
 		}
 
-		addTextLine(body);
+		if (i > 0) {
+			body += "<n>";
+		}
+		combined += body;
+	}
+
+	if (!combined.empty()) {
+		addTextLine(combined);
 	}
 }
 
@@ -487,23 +473,29 @@ void NotebookPopup::drawContent() {
 	localTextRect.translate(-_uinbData->header.normalDestRect.left,
 							-_uinbData->header.normalDestRect.top);
 
-	// Reset HypertextParser state, repaint the paper background under
-	// the text layer, then route content through the shared pipeline.
 	HypertextParser::clear();
-	paintPaperIntoFullSurface();
 	buildTextLines();
 
+	// Chunk's textRect already provides top padding from the chrome.
+	// A small left inset gives breathing room; the bottom strip is
+	// reserved so the last line clears the inner bevel.
 	const uint16 fontID = _uinbData->primaryFontID;
-	Common::Rect hypertextBounds(0, 0, _fullSurface.w, _fullSurface.h);
+	const Font *font = g_nancy->_graphics->getFont(fontID);
+	const int oW = font ? font->getCharWidth('o') : 0;
+	const int leftInset   = oW;
+	const int bottomInset = oW;
+
+	Common::Rect hypertextBounds(leftInset, 0, _fullSurface.w, _fullSurface.h);
 	drawAllText(hypertextBounds, 0, fontID, fontID);
 
-	// Blit the scrolled vertical slice of the rendered hypertext onto
-	// the popup surface. _drawnTextHeight is the inclusive height of
-	// the rendered content; anything past localTextRect.height() is
-	// reachable via the slider.
-	const int visibleH = localTextRect.height();
+	const int visibleH = MAX<int>(0, localTextRect.height() - bottomInset);
 	const int maxScroll = MAX<int>(0, (int)_drawnTextHeight - visibleH);
-	const int scrollY = (int)(_scrollPos * maxScroll);
+	const int safeMax = MAX<int>(0, (int)_fullSurface.h - visibleH);
+	int scrollY = (int)(_scrollPos * maxScroll);
+	if (scrollY > safeMax) {
+		scrollY = safeMax;
+	}
+
 	Common::Rect srcSlice(0, scrollY,
 							_fullSurface.w, scrollY + visibleH);
 	_drawSurface.blitFrom(_fullSurface, srcSlice,
diff --git a/engines/nancy/ui/notebookpopup.h b/engines/nancy/ui/notebookpopup.h
index c142e8341c1..922e5fb6ab5 100644
--- a/engines/nancy/ui/notebookpopup.h
+++ b/engines/nancy/ui/notebookpopup.h
@@ -84,8 +84,6 @@ private:
 	// entries.
 	void buildTextLines();
 
-	void paintPaperIntoFullSurface();
-
 	const UINB *_uinbData;
 
 	Graphics::ManagedSurface _overlayImage;     // popup background image
@@ -101,9 +99,11 @@ private:
 	bool _scrollbarHovered = false;
 	int _scrollbarGrabOffset = 0;
 
+	// journalEntries HashMap keys: _surfaceID = 3 holds task entries,
+	// _surfaceID = 4 holds journal entries.
 	enum NotebookTab {
-		kNotebookTabJournal = 3,
-		kNotebookTabTasks = 4
+		kNotebookTabTasks   = 3,
+		kNotebookTabJournal = 4
 	};
 };
 


Commit: 6e2829654c0950a48accfae108ccef9a4f4743bb
    https://github.com/scummvm/scummvm/commit/6e2829654c0950a48accfae108ccef9a4f4743bb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T23:55:07+03:00

Commit Message:
NANCY: Make call button green in the Nancy10+ phone directory screen

Changed paths:
    engines/nancy/ui/cellphonepopup.cpp


diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 98545399992..9def91a2cc4 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -441,6 +441,7 @@ void CellPhonePopup::drawScreenContent() {
 		drawDialLabel();
 		drawTypeMessage();
 		drawDialedNumber();
+		drawHeading(_uiclData->dialHilite);
 		break;
 
 	case kWaitPickup:
@@ -458,6 +459,7 @@ void CellPhonePopup::drawScreenContent() {
 		drawHeading(_uiclData->dirHeading);
 		drawDirectoryList();
 		drawDirectoryArrows();
+		drawHeading(_uiclData->dialHilite);
 		break;
 
 	case kOnlineHub: {


Commit: 659a24f7f8ec39bc78864342cd6db8fb163c41a3
    https://github.com/scummvm/scummvm/commit/659a24f7f8ec39bc78864342cd6db8fb163c41a3
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T23:55:09+03:00

Commit Message:
Fix cellphone indicators while placing calls in Nancy10+

Changed paths:
    engines/nancy/ui/cellphonepopup.cpp


diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 9def91a2cc4..53c3b2d74c8 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -419,7 +419,10 @@ void CellPhonePopup::drawChrome() {
 void CellPhonePopup::drawScreenContent() {
 	drawChrome();
 
-	if (_screenState != kConnected && !isSubScreenState()) {
+	// Original only draws the signal/battery indicators on the welcome
+	// screen — every other state (dialing, ringing, connected, lists,
+	// browser, etc.) hides them.
+	if (_screenState == kWelcome) {
 		drawStatusIcons();
 	}
 
@@ -438,10 +441,19 @@ void CellPhonePopup::drawScreenContent() {
 	case kWaitOutgoingRing:
 	case kLookupContact:
 		drawWebDirLabels();
-		drawDialLabel();
-		drawTypeMessage();
-		drawDialedNumber();
-		drawHeading(_uiclData->dialHilite);
+		if (!_dialedNumber.empty()) {
+			// User is manually dialing — show the dial header,
+			// "please dial a number" hint, the typed digits and
+			// the Talk highlight.
+			drawDialLabel();
+			drawTypeMessage();
+			drawDialedNumber();
+			drawHeading(_uiclData->dialHilite);
+		} else {
+			// Call placed from the directory / incoming call —
+			// no digits to display, just the connecting animation.
+			drawConnectingSprite();
+		}
 		break;
 
 	case kWaitPickup:


Commit: 3261a4399e0cabf98940daaeedcc423cd3c30194
    https://github.com/scummvm/scummvm/commit/3261a4399e0cabf98940daaeedcc423cd3c30194
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T23:55:09+03:00

Commit Message:
NANCY: Implement completion animation functionality in OneBuildPuzzle

Fixes using the crank in the tuning fork puzzle in Nancy10

Changed paths:
    engines/nancy/action/puzzle/onebuildpuzzle.cpp
    engines/nancy/action/puzzle/onebuildpuzzle.h
    engines/nancy/cursor.cpp
    engines/nancy/cursor.h


diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.cpp b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
index 222b6b64c86..312dbb5bab1 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -40,6 +40,28 @@ void OneBuildPuzzle::init() {
 	g_nancy->_resource->loadImage(_imageName, _image);
 	_image.setTransparentColor(_drawSurface.getTransparentColor());
 
+	// Post-placement animation atlas (e.g. music-box handle "GHO_SlnMBoxHandle_OVL"
+	// in scene 3637). Loaded only when the puzzle defines _animRectA;
+	// positioning of the overlay is deferred to startFinalAnimation() so the
+	// viewport offset is known.
+	if (_hasFinalAnim && !_extraSoundName.empty() && _extraSoundName != "NO_FILE") {
+		g_nancy->_resource->loadImage(Common::Path(_extraSoundName), _animImage);
+
+		// Use the engine's canonical transparent color so blitFrom and the
+		// renderer agree on which pixels are see-through. (The puzzle's own
+		// _drawSurface is never explicitly made transparent in init().)
+		const uint32 transColor = g_nancy->_graphics->getTransColor();
+		_animImage.setTransparentColor(transColor);
+
+		const int w = _animRectA.width();
+		const int h = _animRectA.height();
+		_finalAnimOverlay._drawSurface.create(w, h, _animImage.format);
+		_finalAnimOverlay._drawSurface.setTransparentColor(transColor);
+		_finalAnimOverlay._drawSurface.clear(transColor);
+		_finalAnimOverlay.setTransparent(true);
+		_finalAnimOverlay.setVisible(false);
+	}
+
 	for (uint i = 0; i < _pieces.size(); ++i) {
 		Piece &p = _pieces[i];
 		int w = p.srcRect.width();
@@ -98,6 +120,9 @@ void OneBuildPuzzle::registerGraphics() {
 
 	for (uint i = 0; i < _pieces.size(); ++i)
 		_pieces[i].registerGraphics();
+
+	if (_hasFinalAnim)
+		_finalAnimOverlay.registerGraphics();
 }
 
 void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
@@ -164,6 +189,7 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
 			_animLayout[i] = stream.readSint16LE();
 		_animSound1.readNormal(stream);
 		_animSound2.readNormal(stream);
+		_hasFinalAnim = !_animRectA.isEmpty();
 	}
 
 	_pickupSound.readNormal(stream);
@@ -279,6 +305,11 @@ void OneBuildPuzzle::execute() {
 			}
 			_solveState = kWaitCompletion;
 			break;
+		case kAnimateFinal:
+			// 100ms per frame, matches original case 3/4 tick rate
+			if (g_system->getMillis() >= _timerEnd)
+				stepFinalAnimation();
+			break;
 		}
 		break;
 	case kActionTrigger:
@@ -306,6 +337,19 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 	Common::Point mouseVP(input.mousePos.x - vpScreen.left,
 						  input.mousePos.y - vpScreen.top);
 
+	// Post-placement final-animation stage: once all pieces are placed on a
+	// puzzle that defines _animRectA, the puzzle waits here until the user
+	// clicks the hotspot (e.g. winding a music-box crank, throwing a lever).
+	if (_waitingForFinalAnim && _solveState == kIdle) {
+		if (_animRectA.contains(mouseVP)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kPuzzleArrow);
+			if (input.input & NancyInput::kLeftMouseButtonUp)
+				startFinalAnimation();
+			return;
+		}
+		// Fall through so the exit hotspot still works while waiting.
+	}
+
 	if (_isDragging) {
 		// Always update drag position while carrying a piece
 		updateDragPosition(mouseVP);
@@ -350,7 +394,7 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
 				++_piecesPlaced;
 
 				// Skip pre-placed pieces
-				if (_piecesPlaced < _placementOrder.size() && _placementOrder[_piecesPlaced] - 1 < _pieces.size())
+				if (_piecesPlaced < _placementOrder.size() && (uint)(_placementOrder[_piecesPlaced] - 1) < _pieces.size())
 					if (_pieces[_placementOrder[_piecesPlaced] - 1].isPreRotated)
 						++_piecesPlaced;
 			} else {
@@ -542,6 +586,16 @@ void OneBuildPuzzle::checkAllPlaced() {
 
 		return;
 	}
+
+	// Puzzles with a post-placement animation (e.g. scene 3637's music-box
+	// crank) require the player to click _animRectA before the puzzle solves;
+	// the original signals this in state 1 by skipping FUN_0047fadd when
+	// +0xc27 (anim B non-empty) is set.
+	if (_hasFinalAnim && !_finalAnimDone) {
+		_waitingForFinalAnim = true;
+		return;
+	}
+
 	_isSolved = true;
 	_solveState = kTriggerCompletion;
 }
@@ -615,6 +669,83 @@ void OneBuildPuzzle::playBadPlacementSound() {
 	_timerEnd = g_system->getMillis() + 1000;
 }
 
+void OneBuildPuzzle::startFinalAnimation() {
+	_finalAnimDone = true;       // one-shot guard
+	_waitingForFinalAnim = false;
+	_animFrameCounter = 0;
+	_animRowCounter = 0;
+
+	// Without an animation image to step through, fall straight into completion.
+	if (_animImage.w == 0) {
+		if (_animSound1.name != "NO SOUND" && !_animSound1.name.empty()) {
+			g_nancy->_sound->loadSound(_animSound1);
+			g_nancy->_sound->playSound(_animSound1);
+		}
+		_isSolved = true;
+		_solveState = kTriggerCompletion;
+		return;
+	}
+
+	// Position the overlay at _animRectA, translated into screen coords.
+	const VIEW *viewData = GetEngineData(VIEW);
+	Common::Rect dst = _animRectA;
+	if (viewData)
+		dst.translate(viewData->screenPosition.left, viewData->screenPosition.top);
+	_finalAnimOverlay.moveTo(dst);
+	_finalAnimOverlay.setVisible(true);
+
+	if (_animSound1.name != "NO SOUND" && !_animSound1.name.empty()) {
+		g_nancy->_sound->loadSound(_animSound1);
+		g_nancy->_sound->playSound(_animSound1);
+	}
+
+	_solveState = kAnimateFinal;
+	_timerEnd = g_system->getMillis();   // fire immediately on first tick
+}
+
+void OneBuildPuzzle::stepFinalAnimation() {
+	// animLayout = {cols, framesPerStep, baseX, baseY, spacing, totalRows}.
+	// Matches `case 3` in OneBuildPuzzle @ 0x0047fb75: counter wraps to next
+	// row when (counter / framesPerStep) >= cols.
+	const int16 cols          = _animLayout[0];
+	const int16 framesPerStep = _animLayout[1] ? _animLayout[1] : 1;
+	const int16 baseX         = _animLayout[2];
+	const int16 baseY         = _animLayout[3];
+	const int16 spacing       = _animLayout[4];
+	const int16 totalRows     = _animLayout[5];
+
+	if (_animFrameCounter / framesPerStep >= cols) {
+		_animFrameCounter = 0;
+		++_animRowCounter;
+	}
+
+	if (_animRowCounter < totalRows) {
+		// Source rect on the atlas. Original engine uses inclusive width/height
+		// (right_raw - left_raw), so width()-1 / height()-1 in our convention.
+		const int cellW = _animRectA.width()  - 1;
+		const int cellH = _animRectA.height() - 1;
+		const int srcLeft = baseX + (cellW + spacing) * (_animFrameCounter % framesPerStep);
+		const int srcTop  = baseY + (cellH + spacing) * (_animFrameCounter / framesPerStep);
+		Common::Rect src(srcLeft, srcTop, srcLeft + _animRectA.width(), srcTop + _animRectA.height());
+
+		// Clear to transparent first so any pixels not covered by the source
+		// (or skipped by source-transparency) stay see-through, not garbage.
+		_finalAnimOverlay._drawSurface.clear(g_nancy->_graphics->getTransColor());
+		_finalAnimOverlay._drawSurface.blitFrom(_animImage, src, Common::Point(0, 0));
+		_finalAnimOverlay.setVisible(true);
+		_finalAnimOverlay.setNeedsRedraw(true);
+
+		++_animFrameCounter;
+		_timerEnd = g_system->getMillis() + 100;
+		return;
+	}
+
+	// Animation finished: hide overlay and run the standard completion flow.
+	_finalAnimOverlay.setVisible(false);
+	_isSolved = true;
+	_solveState = kTriggerCompletion;
+}
+
 // static
 void OneBuildPuzzle::rotateSurface90CW(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dst) {
 	int srcW = src.w;
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.h b/engines/nancy/action/puzzle/onebuildpuzzle.h
index 99acf6dafcc..7a26fb2d671 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.h
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.h
@@ -35,7 +35,7 @@ namespace Action {
 // Otherwise it returns to its previous position (or home in free placement mode).
 class OneBuildPuzzle : public RenderActionRecord {
 public:
-	OneBuildPuzzle() : RenderActionRecord(7) {}
+	OneBuildPuzzle() : RenderActionRecord(7), _finalAnimOverlay(99) {}
 	virtual ~OneBuildPuzzle() {}
 
 	void init() override;
@@ -97,12 +97,16 @@ protected:
 	// Filename only (no SoundDescription metadata).
 	Common::String _extraSoundName;
 
-	// Completion sprite-sheet animation; TODO: not wired up.
+	// Post-placement sprite-sheet animation. _animRectA is the on-screen
+	// rect where the animation plays AND the click hotspot the user must
+	// activate after placing all pieces (e.g. the music-box crank in scene
+	// 3637; could be any handle/lever/switch in other puzzles).
 	Common::Rect _animRectA;
 	Common::Rect _animRectB;
 	int16 _animLayout[6] = {}; // cols, framesPerStep, baseX, baseY, spacing, totalRows
 	SoundDescription _animSound1;
 	SoundDescription _animSound2;
+	bool _hasFinalAnim = false;   // true when _animRectA is non-empty
 
 	Common::Array<Piece> _pieces;
 
@@ -142,7 +146,8 @@ protected:
 		kWaitTimer        = 1, // 300ms delay after pickup/drop before evaluating outcome
 		kWaitPlaceSound   = 2, // waiting for good/bad placement sound (or 1s timer) to finish
 		kWaitCompletion   = 3, // waiting for completion sound to finish before scene change
-		kTriggerCompletion = 4  // play completion sound/text, then transition to kWaitCompletion
+		kTriggerCompletion = 4, // play completion sound/text, then transition to kWaitCompletion
+		kAnimateFinal      = 5  // step the post-placement animation, then trigger completion
 	};
 	SolveState _solveState = kIdle;
 	bool _isDropSound = false;       // True if last sound played was a drop sound
@@ -150,6 +155,18 @@ protected:
 	uint16 _piecesPlaced = 0;    // Number of pieces correctly placed so far
 	uint32 _timerEnd = 0;        // Millisecond timestamp when the current timer expires
 
+	// Final-animation gating: after all pieces are placed on a puzzle that
+	// has _animRectA defined, _waitingForFinalAnim is set and the puzzle
+	// stalls in kIdle until the user clicks _animRectA.
+	bool _waitingForFinalAnim = false;
+	bool _finalAnimDone = false;
+
+	// Final-animation runtime state (matches original `+0xc35`/`+0xc33` per-tick counters).
+	Graphics::ManagedSurface _animImage; // Source atlas loaded from _extraSoundName.
+	RenderObject _finalAnimOverlay;      // Single-frame overlay rendered at _animRectA, z above pieces.
+	int16 _animFrameCounter = 0;         // 0..framesPerStep-1, the X index within the current row.
+	int16 _animRowCounter = 0;           // 0..totalRows-1, how many cycles have completed.
+
 	// Previous drag position (for freePlacement restore on wrong drop)
 	Common::Rect _prevDragGameRect;
 
@@ -180,6 +197,10 @@ protected:
 	static void rotateSurface90CW(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dst);
 	// Clamp rect to viewport bounds while preserving dimensions - FUN_004713b8
 	void clampRectToViewport(Common::Rect &rect);
+
+	// Final-animation helpers.
+	void startFinalAnimation();
+	void stepFinalAnimation();
 };
 
 } // End of namespace Action
diff --git a/engines/nancy/cursor.cpp b/engines/nancy/cursor.cpp
index 68c3e3c098f..a5f18b48d40 100644
--- a/engines/nancy/cursor.cpp
+++ b/engines/nancy/cursor.cpp
@@ -165,6 +165,7 @@ uint CursorManager::resolveNancy10CursorID(CursorType type, int16 itemID, bool s
 	case kHotspotTalk:          return kNewHotspotTalk;
 	case kDragHand:             return kNewDragHand;
 	case kDropHand:             return kNewDropHand;
+	case kPuzzleArrow:          return kNewPuzzleArrow;
 	case kNormalArrow:          return kNewNormalArrow;
 	case kHotspotArrow:         return kNewHotspotArrow;
 	case kExit:                 return kNewExit;
diff --git a/engines/nancy/cursor.h b/engines/nancy/cursor.h
index f5529ea98c1..b91b08bca1f 100644
--- a/engines/nancy/cursor.h
+++ b/engines/nancy/cursor.h
@@ -58,6 +58,7 @@ public:
 		kHotspotTalk			= 22,	// Speech-bubble hover cursor (Nancy 10+)
 		kDragHand				= 23,	// Hand cursor used when dragging an item (Nancy 10+)
 		kDropHand				= 24,	// Drop-hand cursor used while a piece is held over a target (Nancy 10+)
+		kPuzzleArrow			= 25,	// Puzzle arrow cursor shown when hovering a clickable puzzle hotspot (Nancy 10+)
 
 		// Cursors in Nancy10 and newer games. The CURS chunk holds 37 system
 		// cursor types in pairs; type T's idle slot is (T*2) and its hotspot
@@ -88,6 +89,7 @@ public:
 		kNewInvertedRotateRight = 32,	// Type 16 — Inverted 360 rotation
 		kNewInvertedRotateLeft	= 34,	// Type 17 — Inverted 360 rotation
 		kNewDragHand			= 38,	// Type 19 — Hand used while dragging puzzle pieces (e.g. SortPuzzle pickup action sets this)
+		kNewPuzzleArrow			= 45,	// Type 22 hotspot — Arrow cursor shown when hovering a clickable puzzle hotspot
 		kNewDropHand			= 64,	// Type 32 — Hand shown when a held piece is dropped (briefly set on the drop action)
 	};
 




More information about the Scummvm-git-logs mailing list