[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