[Scummvm-git-logs] scummvm master -> 6111dfcbe36ebdd71e342b24f3a5e1f8913d1bd6

bluegr noreply at scummvm.org
Sat Jul 4 19:11:03 UTC 2026


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

Summary:
75440595c5 NANCY: NANCY11: Fix event flag checks in conditional dialogs
36027ee3ad NANCY: NANCY11: Fixes for CardGamePuzzle
74a5afecd3 NANCY: NANCY10: Wait for Menu/Help button clicks before changing scene
95d9160a98 NANCY: NANCY10: Persist taskbar button overrides set by ControlUIItems
61dbe1c8e8 NANCY: NANCY10: Fix display of the cellphone help screen
e2dde4229c NANCY: NANCY10: Implement new functionality for the Autotext AR
63264543bd NANCY: Use the correct top/left padding for ScrollTextBox
b1d03b80bd NANCY: NANCY12: Read win scene and detector zone from ActionZone data
6111dfcbe3 NANCY: NANCY12: Implement MinigolfPuzzle


Commit: 75440595c52866a09c80de3f39c7018535ebc2d2
    https://github.com/scummvm/scummvm/commit/75440595c52866a09c80de3f39c7018535ebc2d2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:47+03:00

Commit Message:
NANCY: NANCY11: Fix event flag checks in conditional dialogs

Nancy11 has over 1000 event flags, so compensate for that fact when
performing conditional dialog checks with hardcoded dialog data.

Fixes asking Jane for the computer password

Changed paths:
    engines/nancy/action/conversation.cpp


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index d49e175d45d..3b4c45ff4ef 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -442,13 +442,17 @@ void ConversationSound::execute() {
 }
 
 void ConversationSound::addConditionalDialogue() {
+	// We adjust the base label for Nancy 11 event flags, which can be
+	// larger than 1000.
+	int16 baseLabel = g_nancy->getGameType() >= kGameTypeNancy11 ? 1000 : 0;
+
 	for (const auto &res : g_nancy->getStaticData().conditionalDialogue[_conditionalResponseCharacterID]) {
 		bool isSatisfied = true;
 
 		for (const auto &cond : res.conditions) {
 			switch (cond.type) {
 			case (byte)StaticDataConditionType::kEvent :
-				if (!NancySceneState.getEventFlag(cond.label, cond.flag)) {
+				if (!NancySceneState.getEventFlag(baseLabel + cond.label, cond.flag)) {
 					isSatisfied = false;
 				}
 
@@ -508,6 +512,10 @@ void ConversationSound::addConditionalDialogue() {
 }
 
 void ConversationSound::addGoodbye() {
+	// We adjust the base label for Nancy 11 event flags, which can be
+	// larger than 1000.
+	int16 baseLabel = g_nancy->getGameType() >= kGameTypeNancy11 ? 1000 : 0;
+
 	auto &res = g_nancy->getStaticData().goodbyes[_goodbyeResponseCharacterID];
 	_responses.push_back(ResponseStruct());
 	ResponseStruct &newResponse = _responses.back();
@@ -538,7 +546,7 @@ void ConversationSound::addGoodbye() {
 			for (const auto &cond : sc.conditions) {
 				switch (cond.type) {
 				case (byte)StaticDataConditionType::kEvent :
-					if (!NancySceneState.getEventFlag(cond.label, cond.flag)) {
+					if (!NancySceneState.getEventFlag(baseLabel + cond.label, cond.flag)) {
 						isSatisfied = false;
 					}
 


Commit: 36027ee3adfe3fa1f2ec644972ea85945a791789
    https://github.com/scummvm/scummvm/commit/36027ee3adfe3fa1f2ec644972ea85945a791789
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:48+03:00

Commit Message:
NANCY: NANCY11: Fixes for CardGamePuzzle

Fix playing the "Go Fish!" game with Jane

Changed paths:
    engines/nancy/action/puzzle/cardgamepuzzle.cpp
    engines/nancy/action/puzzle/cardgamepuzzle.h


diff --git a/engines/nancy/action/puzzle/cardgamepuzzle.cpp b/engines/nancy/action/puzzle/cardgamepuzzle.cpp
index c536ae4e185..53620380544 100644
--- a/engines/nancy/action/puzzle/cardgamepuzzle.cpp
+++ b/engines/nancy/action/puzzle/cardgamepuzzle.cpp
@@ -120,9 +120,9 @@ void CardGamePuzzle::readData(Common::SeekableReadStream &stream) {
 // 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) {
+int CardGamePuzzle::dealOne(int player) {
 	if (_deckRemaining < 1) {
-		return false;
+		return -1;
 	}
 
 	bool allowFull = _deckRemaining <= _numCols;
@@ -141,11 +141,11 @@ bool CardGamePuzzle::dealOne(int player) {
 			}
 			_availMap[row][col] = 0;
 			--_deckRemaining;
-			return true;
+			return col;
 		}
 	}
 
-	return false;
+	return -1;
 }
 
 void CardGamePuzzle::drawBoard() {
@@ -197,102 +197,109 @@ void CardGamePuzzle::drawBoard() {
 	_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;
+bool CardGamePuzzle::hasPlayableColumn(int side) const {
+	for (int col = 0; col < (int)_numCols; ++col) {
+		if (_board[side].colCount[col] > 0 && _board[side].colCount[col] < 3) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+// One Go Fish "ask": the side asks the opponent for the given rank. If the opponent holds any cards
+// of it, they all transfer (owning three scores a set) and the side asks again. Otherwise the side
+// draws a card from the deck; the turn then passes, unless the draw-again rule applies and it drew
+// the rank it asked for. Ends the game if the deck runs out on a draw.
+bool CardGamePuzzle::askForColumn(int side, int askedCol) {
+	int opponent = side ^ 1;
+	bool took = 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 (_board[opponent].grid[row][askedCol] == 1) {
+			_board[opponent].grid[row][askedCol] = 0;
+			_board[opponent].colCount[askedCol]--;
+			_board[side].grid[row][askedCol] = 1;
+			_board[side].colCount[askedCol]++;
+			took = 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]);
+	if (took) {
+		if (_board[side].colCount[askedCol] >= 3 && !_board[side].colComplete[askedCol]) {
+			_board[side].colComplete[askedCol] = 1;
+			_board[side].score++;
+			playVoice(side == 1 ? _playerScoredVoiceName : _enemyScoredVoiceName);
+		} else if (askedCol < kMaxCols) {
+			// A rank-specific line for the taken cards (silent in scenes that don't use them)
+			playVoice(_matchVoice[side][askedCol]);
+		}
+
+		return true; // the opponent had the rank: ask again
 	}
 
-	return moved;
+	// Go fish
+	int drawnCol = dealOne(side);
+	if (drawnCol == -1) {
+		endGame();
+		return false;
+	}
+
+	return (_switchTurnRule != 0 && drawnCol == askedCol);
 }
 
-// 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.
+// The AI asks for a rank it already holds one or two of, preferring ranks where it holds two (so a
+// take completes the set) and avoiding immediately repeating its previous rank. Returns -1 if it
+// holds no incomplete rank to ask for.
 int CardGamePuzzle::aiPickColumn() {
-	int mover = _currentTurn;
-	int opponent = mover ^ 1;
-
-	Common::Array<int> strong, ownSome, any;
+	Common::Array<int> strong, 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) {
+		int count = _board[0].colCount[col];
+		if (count <= 0 || count >= 3 || col == _lastAiColumn) {
 			continue;
 		}
 
 		any.push_back(col);
-		if (_board[mover].colCount[col] >= 1) {
-			ownSome.push_back(col);
-		}
-		if (_board[mover].colCount[col] >= 2) {
+		if (count >= 2) {
 			strong.push_back(col);
 		}
 	}
 
-	const Common::Array<int> &pool = !strong.empty() ? strong : (!ownSome.empty() ? ownSome : any);
-	if (pool.empty()) {
-		return -1;
+	const Common::Array<int> &pool = !strong.empty() ? strong : any;
+	if (!pool.empty()) {
+		return pool[g_nancy->_randomSource->getRandomNumber(pool.size() - 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;
+	// The last-asked rank is the only one left: allow repeating it.
+	for (int col = 0; col < (int)_numCols; ++col) {
+		if (_board[0].colCount[col] > 0 && _board[0].colCount[col] < 3) {
+			return col;
+		}
 	}
 
-	_currentTurn ^= 1;
+	return -1;
+}
+
+// The AI (side 0) takes its whole turn at once: it keeps asking as long as each ask grants another
+// turn, then control returns to the player. The game ends once a draw finds the deck exhausted.
+void CardGamePuzzle::runAiTurn() {
+	_lastAiColumn = -1;
 
-	if (_currentTurn == 0) {
-		// AI turn
+	for (int guard = 0; !_gameOver && guard < 1000; ++guard) {
 		int col = aiPickColumn();
-		if (col != -1) {
-			_lastAiColumn = col;
-			playColumn(col);
+		if (col == -1) {
+			// Nothing to ask for: go fish and end the turn.
+			if (dealOne(0) == -1) {
+				endGame();
+			}
+			break;
 		}
 
-		dealOne(_currentTurn);
-		drawBoard();
-
-		if (_deckRemaining < 1) {
-			endGame();
-			return;
+		bool goAgain = askForColumn(0, col);
+		_lastAiColumn = col;
+		if (!goAgain) {
+			break;
 		}
-
-		_currentTurn = 1;
 	}
 }
 
@@ -352,6 +359,19 @@ void CardGamePuzzle::updateGraphics() {
 	if (_gameOver && _awaitingEnd && g_nancy->getTotalPlayTime() >= _endWaitUntil) {
 		_awaitingEnd = false;
 		_state = kActionTrigger;
+		return;
+	}
+
+	// If it becomes the player's turn but they hold no rank they can ask for, they must go fish and
+	// pass, so the AI takes over. This resolves a turn the player could otherwise never act on.
+	if (_state == kRun && !_gameOver && !_awaitingEnd && _currentTurn == 1 && !hasPlayableColumn(1)) {
+		if (dealOne(1) == -1) {
+			endGame();
+		} else if (!_gameOver) {
+			runAiTurn();
+		}
+
+		drawBoard();
 	}
 }
 
@@ -410,6 +430,13 @@ void CardGamePuzzle::execute() {
 	if (_state == kBegin) {
 		init();
 		registerGraphics();
+
+		// When the AI is dealt the opening move, it plays out its turn before the player's first ask.
+		if (_startPlayer == 0 && !_gameOver) {
+			runAiTurn();
+			_currentTurn = 1;
+		}
+
 		drawBoard();
 		_state = kRun;
 	} else if (_state == kActionTrigger) {
@@ -522,12 +549,18 @@ void CardGamePuzzle::handleInput(NancyInput &input) {
 				before[r][c] = (_board[1].grid[r][c] == 1);
 
 		playVoice(_moveVoiceName);
-		playColumn(col);
-		finishMove();
+		bool goAgain = askForColumn(1, col);
 
-		// finishMove may have ended the game (kActionTrigger); only animate if still playing
-		if (_state == kRun) {
+		// A failed ask (go fish) ends the player's turn, so the AI plays out its whole turn before
+		// control returns. A successful ask keeps the turn: the player just asks again.
+		if (_state == kRun && !_gameOver && !goAgain) {
+			runAiTurn();
+		}
+
+		if (_state == kRun && !_gameOver) {
 			startMoveAnimation(before);
+		} else {
+			drawBoard();
 		}
 	}
 }
diff --git a/engines/nancy/action/puzzle/cardgamepuzzle.h b/engines/nancy/action/puzzle/cardgamepuzzle.h
index 98f758d1ece..50904061582 100644
--- a/engines/nancy/action/puzzle/cardgamepuzzle.h
+++ b/engines/nancy/action/puzzle/cardgamepuzzle.h
@@ -61,17 +61,20 @@ protected:
 
 	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
+	int dealOne(int player);  // draw a card from the deck to a side; returns its column, or -1 if the deck is empty
 	void drawBoard();
 	// True when the scene has a bottom button row (the player clicks those); false when it doesn't,
 	// in which case the player clicks their own cards in the tableau directly.
 	bool usesColumnButtons() const { return _columnButtons[0].top != _columnButtons[0].bottom; }
 	// The column the player is pointing at (owning 1-2 cards in it), or -1 if none is under the mouse.
 	int columnUnderMouse(const Common::Point &mousePos) const;
-	// 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
+	bool hasPlayableColumn(int side) const; // whether a side holds any incomplete rank it can ask for
+	// One "ask" (Go Fish): the side asks the opponent for a rank. If the opponent holds it, take every
+	// card of that rank (scoring a set at three) and ask again; otherwise draw a card ("go fish").
+	// Returns true if the same side should ask again.
+	bool askForColumn(int side, int askedCol);
+	int aiPickColumn();       // the AI's chosen rank to ask for, or -1 if it holds no incomplete rank
+	void runAiTurn();         // the AI asks repeatedly until its turn ends, then detects game over
 	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]);


Commit: 74a5afecd3bf023b3d10598e52df95757f79310a
    https://github.com/scummvm/scummvm/commit/74a5afecd3bf023b3d10598e52df95757f79310a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:49+03:00

Commit Message:
NANCY: NANCY10: Wait for Menu/Help button clicks before changing scene

Fix #16896

Changed paths:
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index fe498a1b219..54846765b3f 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -125,6 +125,7 @@ Scene::Scene() :
 		_menuButton(nullptr),
 		_helpButton(nullptr),
 		_taskbar(nullptr),
+		_pendingTaskbarButton(-1),
 		_viewportOrnaments(nullptr),
 		_textboxOrnaments(nullptr),
 		_inventoryBoxOrnaments(nullptr),
@@ -1412,30 +1413,43 @@ void Scene::handleInput() {
 	// buttons, so they should not receive hover/clicks).
 	if (!_activeMovie) {
 		if (_taskbar && !_textbox.isFullMode()) {
-			_taskbar->handleInput(input);
-
-			int clicked = _taskbar->getClickedButton();
-			switch (clicked) {
-			case kTaskButtonMenu:
-				requestStateChange(NancyState::kMainMenu);
-				break;
-			case kTaskButtonInventory:
-				_inventoryPopup.toggle();
-				break;
-			case kTaskButtonNotebook:
-				_notebookPopup.toggle();
-				break;
-			case kTaskButtonCellphone:
-				_cellPhonePopup.toggle();
-				break;
-			case -1:
-				break;
-			default:
-				// HELP is always the last taskbar button. Its index shifts from
-				// 4 to 5 in Nancy12, where a non-clickable coin purse occupies slot
-				// 4 (and never reports a click), so match it as the fall-through.
-				requestStateChange(NancyState::kHelp);
-				break;
+			// MENU and HELP leave gameplay entirely, which would cut off the
+			// taskbar click sound. The original defers the transition until that
+			// sound finishes, so we hold the click here and only switch state
+			// once the button's click sound has stopped playing.
+			if (_pendingTaskbarButton != -1) {
+				auto *taskData = GetEngineData(TASK);
+				if (!taskData || !g_nancy->_sound->isSoundPlaying(taskData->buttons[_pendingTaskbarButton].button.clickSound)) {
+					NancyState::NancyState target = _pendingTaskbarButton == kTaskButtonMenu ? NancyState::kMainMenu : NancyState::kHelp;
+					_pendingTaskbarButton = -1;
+					requestStateChange(target);
+				}
+			} else {
+				_taskbar->handleInput(input);
+
+				int clicked = _taskbar->getClickedButton();
+				switch (clicked) {
+				case kTaskButtonMenu:
+					_pendingTaskbarButton = kTaskButtonMenu;
+					break;
+				case kTaskButtonInventory:
+					_inventoryPopup.toggle();
+					break;
+				case kTaskButtonNotebook:
+					_notebookPopup.toggle();
+					break;
+				case kTaskButtonCellphone:
+					_cellPhonePopup.toggle();
+					break;
+				case -1:
+					break;
+				default:
+					// HELP is always the last taskbar button. Its index shifts from
+					// 4 to 5 in Nancy12, where a non-clickable coin purse occupies slot
+					// 4 (and never reports a click), so match it as the fall-through.
+					_pendingTaskbarButton = clicked;
+					break;
+				}
 			}
 		}
 
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 54d5b4b291b..da7915b467d 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -322,6 +322,10 @@ private:
 	UI::Taskbar *_taskbar;
 	Time _buttonPressActivationTime;
 
+	// A clicked MENU/HELP taskbar button whose state change is deferred until
+	// its click sound finishes playing (see handleInput). -1 = none pending.
+	int _pendingTaskbarButton;
+
 	UI::ViewportOrnaments *_viewportOrnaments;
 	UI::TextboxOrnaments *_textboxOrnaments;
 	UI::InventoryBoxOrnaments *_inventoryBoxOrnaments;


Commit: 95d9160a9851bb85a49ac0b24b83af8d9c31b62c
    https://github.com/scummvm/scummvm/commit/95d9160a9851bb85a49ac0b24b83af8d9c31b62c
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:50+03:00

Commit Message:
NANCY: NANCY10: Persist taskbar button overrides set by ControlUIItems

This persists the taskbar button states when saving/loading.
Fix #16897

Changed paths:
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h
    engines/nancy/state/scene.cpp
    engines/nancy/ui/taskbar.cpp
    engines/nancy/ui/taskbar.h


diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 1234c412be4..8d290a8b4ca 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -390,6 +390,15 @@ void UIResourceData::synchronize(Common::Serializer &ser) {
 	}
 }
 
+void TaskbarData::synchronize(Common::Serializer &ser) {
+	for (uint i = 0; i < kNumButtons; ++i) {
+		ser.syncAsByte(overrides[i].active);
+		ser.syncAsSint16LE(overrides[i].startScene);
+		ser.syncAsSint16LE(overrides[i].endScene);
+		ser.syncAsUint16LE(overrides[i].clickSoundMode);
+	}
+}
+
 PuzzleData *makePuzzleData(const uint32 tag) {
 	switch(tag) {
 	case SliderPuzzleData::getTag():
@@ -424,6 +433,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
 		return new TimerData();
 	case UIResourceData::getTag():
 		return new UIResourceData();
+	case TaskbarData::getTag():
+		return new TaskbarData();
 	default:
 		return nullptr;
 	}
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 392b394262b..c64aade9d32 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -301,6 +301,29 @@ struct UIResourceData : public PuzzleData {
 	Common::Array<int32> values;
 };
 
+// Nancy 10+ taskbar button-disable overrides, set by AR 29 (ControlUIItems).
+// A disable can span a range of scenes set from an earlier scene's AR, so the
+// override has to persist across saves for the button to stay disabled after a
+// load into a scene that doesn't itself re-run the AR.
+struct TaskbarData : public PuzzleData {
+	static const uint kNumButtons = 6;
+
+	struct Override {
+		bool active = false;
+		int16 startScene = -1;
+		int16 endScene = -1;
+		uint16 clickSoundMode = 0;
+	};
+
+	TaskbarData() {}
+	virtual ~TaskbarData() {}
+
+	static constexpr uint32 getTag() { return MKTAG('T', 'S', 'K', 'B'); }
+	virtual void synchronize(Common::Serializer &ser);
+
+	Override overrides[kNumButtons];
+};
+
 PuzzleData *makePuzzleData(const uint32 tag);
 
 } // End of namespace Nancy
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 54846765b3f..fd29e0d44f9 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -890,6 +890,14 @@ void Scene::synchronize(Common::Serializer &ser) {
 				pd->synchronize(ser);
 			}
 		}
+
+		// Restore the taskbar disable overrides now that the persisted
+		// TaskbarData is available. A disable can be set from an earlier
+		// scene's AR that won't re-run here, so it has to come from the save.
+		if (_taskbar && g_nancy->getGameType() >= kGameTypeNancy10) {
+			_taskbar->syncFromPuzzleData();
+			_taskbar->updateNotificationStates(_sceneState.currentScene.sceneID);
+		}
 	}
 
 	_isRunningAd = false;
diff --git a/engines/nancy/ui/taskbar.cpp b/engines/nancy/ui/taskbar.cpp
index a7951025af8..7cb2fce445b 100644
--- a/engines/nancy/ui/taskbar.cpp
+++ b/engines/nancy/ui/taskbar.cpp
@@ -26,6 +26,7 @@
 #include "engines/nancy/font.h"
 #include "engines/nancy/graphics.h"
 #include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
 #include "engines/nancy/resource.h"
 #include "engines/nancy/sound.h"
 
@@ -244,6 +245,7 @@ void Taskbar::setDisabledRange(uint buttonIndex, int16 startScene, int16 endScen
 	_overrides[buttonIndex].active = true;
 	_overrides[buttonIndex].startScene = startScene;
 	_overrides[buttonIndex].endScene = endScene;
+	persistOverride(buttonIndex);
 
 	if ((int)buttonIndex != _hoveredButton) {
 		drawButton(buttonIndex, restingState(buttonIndex));
@@ -256,6 +258,7 @@ void Taskbar::clearButtonOverride(uint buttonIndex) {
 	}
 	_overrides[buttonIndex].active = false;
 	_overrides[buttonIndex].clickSoundMode = kClickSoundDefault;
+	persistOverride(buttonIndex);
 
 	if ((int)buttonIndex != _hoveredButton) {
 		drawButton(buttonIndex, restingState(buttonIndex));
@@ -267,6 +270,34 @@ void Taskbar::setClickSoundMode(uint buttonIndex, uint mode) {
 		return;
 	}
 	_overrides[buttonIndex].clickSoundMode = mode;
+	persistOverride(buttonIndex);
+}
+
+void Taskbar::persistOverride(uint index) {
+	if (index >= TASK::kNumButtons || index >= TaskbarData::kNumButtons) {
+		return;
+	}
+	TaskbarData *data = (TaskbarData *)NancySceneState.getPuzzleData(TaskbarData::getTag());
+	if (!data) {
+		return;
+	}
+	data->overrides[index].active = _overrides[index].active;
+	data->overrides[index].startScene = _overrides[index].startScene;
+	data->overrides[index].endScene = _overrides[index].endScene;
+	data->overrides[index].clickSoundMode = (uint16)_overrides[index].clickSoundMode;
+}
+
+void Taskbar::syncFromPuzzleData() {
+	TaskbarData *data = (TaskbarData *)NancySceneState.getPuzzleData(TaskbarData::getTag());
+	if (!data) {
+		return;
+	}
+	for (uint i = 0; i < TASK::kNumButtons && i < TaskbarData::kNumButtons; ++i) {
+		_overrides[i].active = data->overrides[i].active;
+		_overrides[i].startScene = data->overrides[i].startScene;
+		_overrides[i].endScene = data->overrides[i].endScene;
+		_overrides[i].clickSoundMode = data->overrides[i].clickSoundMode;
+	}
 }
 
 void Taskbar::updateNotificationStates(int16 currentSceneID) {
diff --git a/engines/nancy/ui/taskbar.h b/engines/nancy/ui/taskbar.h
index 781995c5363..2ea0310e5bc 100644
--- a/engines/nancy/ui/taskbar.h
+++ b/engines/nancy/ui/taskbar.h
@@ -64,6 +64,11 @@ public:
 	// sprite. Call after a scene change so the range check kicks in.
 	void updateNotificationStates(int16 currentSceneID);
 
+	// Restore the disable overrides from the persisted TaskbarData chunk.
+	// Called after a save is loaded so a scene-ranged disable set by an
+	// earlier scene's AR survives the load.
+	void syncFromPuzzleData();
+
 	// Set a disabled button's rejection-sound mode, from ControlUIItems (AR 29).
 	void setClickSoundMode(uint buttonIndex, uint mode);
 
@@ -106,6 +111,8 @@ private:
 
 	void drawButton(uint index, ButtonState state);
 	ButtonState restingState(uint index) const;
+	// Mirror one button's override into the persisted TaskbarData chunk.
+	void persistOverride(uint index);
 	// True when the button currently accepts hover/click (not disabled).
 	bool isButtonActive(uint index) const;
 


Commit: 61dbe1c8e8f073336f5e2666f022b0da9b079ec5
    https://github.com/scummvm/scummvm/commit/61dbe1c8e8f073336f5e2666f022b0da9b079ec5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:51+03:00

Commit Message:
NANCY: NANCY10: Fix display of the cellphone help screen

Fix #16899

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


diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index bc55160ffbc..6db57cb5804 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -405,7 +405,7 @@ void CellPhonePopup::drawChrome() {
 	// latter; the original swaps to it for browser/list/email-content
 	// modes so the LCD can extend down into the keypad area.
 	const Common::Rect &chromeSrc =
-		isZoomedChromeState() && !_uiclData->fullEmptyScreenSrc.isEmpty()
+		isZoomedChromeState() && !isHelpContentView() && !_uiclData->fullEmptyScreenSrc.isEmpty()
 			? _uiclData->fullEmptyScreenSrc
 			: _uiclData->header.normalSrcRect;
 	_drawSurface.blitFrom(_overlayImage, chromeSrc, Common::Point(0, 0));
@@ -511,7 +511,14 @@ void CellPhonePopup::drawScreenContent() {
 			drawHeading(*_contentHeading);
 		}
 		drawContentView();
-		drawDirectoryArrows();
+		// The help page is a static small-window blurb with no scroll arrows;
+		// browser / email articles keep the big-screen arrows. Help shows the
+		// Back button in the lower ribbon (returns to the main screen).
+		if (!isHelpContentView()) {
+			drawDirectoryArrows();
+		} else {
+			drawBackLabel();
+		}
 		break;
 	}
 
@@ -816,12 +823,12 @@ void CellPhonePopup::drawContentView() {
 		return;
 	}
 
-	// Content view runs under the zoomed-in chrome (drawChrome blits
-	// fullEmptyScreenSrc for kContentView), so the keypad is no longer
-	// visible underneath and we can render into the larger LCD area
-	// that emailListContainer defines.
+	// Browser / email articles run under the zoomed-in chrome (drawChrome
+	// blits fullEmptyScreenSrc), so the keypad is no longer visible underneath
+	// and we render into the larger LCD area that emailListContainer defines.
+	// The help page keeps the regular chrome, so it renders into the small LCD.
 	const Common::Rect &ws =
-		_uiclData->emailListContainer.isEmpty()
+		(isHelpContentView() || _uiclData->emailListContainer.isEmpty())
 			? _uiclData->welcomeScreen.destRect
 			: _uiclData->emailListContainer;
 	const int lcdLeft = ws.left - _screenPosition.left;
@@ -993,7 +1000,9 @@ void CellPhonePopup::drawWelcomeScreen() {
 }
 
 void CellPhonePopup::drawBackLabel() {
-	const UICL::ThreeRectWidget &back = _uiclData->subButtons[2];
+	// subButtons[0] (original CUIButton 0x10) is the Back button that returns a
+	// sub-screen to the main view; it sits at the left of the lower ribbon.
+	const UICL::ThreeRectWidget &back = _uiclData->subButtons[0];
 	if (back.srcRectIdle.isEmpty() || back.destRect.isEmpty()) {
 		return;
 	}
@@ -1004,6 +1013,16 @@ void CellPhonePopup::drawBackLabel() {
 											back.destRect.top - chunkOrigin.y));
 }
 
+Common::Rect CellPhonePopup::backButtonHitRect() const {
+	// Popup-local hit rect for the Back button (subButtons[0]).
+	Common::Rect r = _uiclData->subButtons[0].destRect;
+	if (r.isEmpty()) {
+		return r;
+	}
+	r.translate(-_screenPosition.left, -_screenPosition.top);
+	return r;
+}
+
 const UICL::ThreeRectWidget &CellPhonePopup::scrollUpButton() const {
 	// Directory uses subButtons[1]; search / email / browser content all
 	// use subButtons[5] (which sits above the taller list LCD area).
@@ -1786,7 +1805,10 @@ void CellPhonePopup::handleInput(NancyInput &input) {
 			}
 		}
 
-		const Common::Rect backHit = backLabelHitRect();
+		// Help draws a real Back button (subButtons[0]); hit-test it so the
+		// visible button and the hotspot line up. Browser / email articles keep
+		// using the wider ribbon-area hotspot.
+		const Common::Rect backHit = isHelpContentView() ? backButtonHitRect() : backLabelHitRect();
 		const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
 										chunkMouse.y - _screenPosition.top);
 		const bool overUpDown =
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index 8ea47542274..2e3506027e1 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -138,6 +138,14 @@ private:
 				_screenState == kContentView;
 	}
 
+	// The help "?" page reuses the content-view state, but unlike browser /
+	// email articles the original renders it in the regular (keypad-visible)
+	// chrome and the small LCD, not the zoomed full-screen variant.
+	bool isHelpContentView() const {
+		return _screenState == kContentView && _uiclData &&
+				_contentHeading == &_uiclData->helpHeading;
+	}
+
 	// True for screens that hide the status icons and "?" button so the
 	// top bar shows only the section heading and the up arrow.
 	bool isSubScreenState() const {
@@ -185,6 +193,8 @@ private:
 	bool isContactVisible(const UICL::Contact &c) const;
 	// Popup-local rect of the Back hotspot in directory mode.
 	Common::Rect backLabelHitRect() const;
+	// Popup-local rect of the visible Back button (subButtons[0]).
+	Common::Rect backButtonHitRect() const;
 	// Move the directory selection by delta, scrolling as needed.
 	void moveDirectorySelection(int delta);
 


Commit: e2dde4229ce10190441f8d615979b36e2b2b8c57
    https://github.com/scummvm/scummvm/commit/e2dde4229ce10190441f8d615979b36e2b2b8c57
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:51+03:00

Commit Message:
NANCY: NANCY10: Implement new functionality for the Autotext AR

- Draw Autotext which is not drawn in the journal as render objects
- Raise image slots

Fixes text display when logging in Alan's computer in Nancy11

Changed paths:
    engines/nancy/action/autotext.cpp
    engines/nancy/action/autotext.h
    engines/nancy/action/puzzle/peepholepuzzle.h


diff --git a/engines/nancy/action/autotext.cpp b/engines/nancy/action/autotext.cpp
index 5ab834dc298..2d3a393540f 100644
--- a/engines/nancy/action/autotext.cpp
+++ b/engines/nancy/action/autotext.cpp
@@ -43,6 +43,9 @@ void Autotext::readData(Common::SeekableReadStream &stream) {
 	Common::Path imageName;
 	readFilename(stream, imageName);
 
+	// Nancy 10 raised the image slot array from 5 to 10 entries
+	uint maxImages = g_nancy->getGameType() <= kGameTypeNancy9 ? 5 : 10;
+
 	uint16 numImages = stream.readUint16LE();
 	if (numImages) {
 		for (uint i = 0; i < numImages; ++i) {
@@ -54,7 +57,14 @@ void Autotext::readData(Common::SeekableReadStream &stream) {
 
 		setImageName(imageName);
 	}
-	stream.skip((5 - numImages) * (2 + 16));
+	stream.skip((maxImages - numImages) * (2 + 16));
+
+	if (g_nancy->getGameType() >= kGameTypeNancy10) {
+		// Placement descriptor used to blit viewport surfaces (0-2) onscreen
+		_placementMode = stream.readUint16LE();
+		readRect(stream, _viewportDest);
+		readRect(stream, _viewportSrc);
+	}
 
 	readExtraData(stream);
 }
@@ -185,10 +195,12 @@ void Autotext::execute() {
 
 		const Font *font = g_nancy->_graphics->getFont(_fontID);
 		assert(font);
-		uint d = (font->getFontHeight() + 1) / 2 + 1;
 
-		textBounds.top += d + 1;
-		textBounds.left += d;
+		// The original indents the text within the surface by twice the width
+		// of a lowercase 'o' on the left, and once on top
+		uint indent = font->getStringWidth("o");
+		textBounds.top += indent;
+		textBounds.left += indent * 2;
 
 		// Original engine uses a particular value in the TBOX chunk in all
 		// text rendering, including Autotext
@@ -196,6 +208,21 @@ void Autotext::execute() {
 
 		drawAllText(textBounds, tbox->leftOffset - textBounds.left, _fontID, _fontID);
 		surfBounds = Common::Rect(_fullSurface.w, _drawnTextHeight);
+
+		// Viewport surfaces (0-2) are placed onscreen by the AutoText record itself.
+		// Journal surfaces (3+) are drawn by the notebook UI instead.
+		if (_selfDisplay && _surfaceID < 3 && !_viewportRender && !_viewportDest.isEmpty()) {
+			Common::Rect src = _viewportSrc;
+			src.clip(surf.getBounds());
+
+			_viewportRender = new AutotextRender(_placementMode);
+			_viewportRender->_drawSurface.create(surf, src);
+			_viewportRender->moveTo(_viewportDest);
+			_viewportRender->setTransparent(_transparency == kPlayOverlayTransparent);
+			_viewportRender->setVisible(true);
+			_viewportRender->init();
+			_viewportRender->registerGraphics();
+		}
 	}
 
 	_isDone = true;
diff --git a/engines/nancy/action/autotext.h b/engines/nancy/action/autotext.h
index 22b08063bf7..c5963ab4c51 100644
--- a/engines/nancy/action/autotext.h
+++ b/engines/nancy/action/autotext.h
@@ -28,15 +28,26 @@
 namespace Nancy {
 namespace Action {
 
+// Blits a filled viewport autotext surface (ids 0-2) on top of the scene
+// background. Nancy 10+ spawns an Overlay for this; we render it directly.
+class AutotextRender : public RenderObject {
+public:
+	AutotextRender(uint16 zOrder) : RenderObject(zOrder) {}
+	virtual ~AutotextRender() {}
+
+	bool isViewportRelative() const override { return true; }
+};
+
 // Action record used for rendering text inside the game viewport.
 // Can be used in two ways: for single-use texts that get thrown away
 // after a scene change, or for permanent storage (used in nancy's journal)
-// Does not own or display any image data; it draws to a surface inside
-// GraphicsManager, which other ActionRecords (Overlay and PeepholePuzzle) can use.
+// Draws to a surface inside GraphicsManager, which other ActionRecords
+// (Overlay and PeepholePuzzle) can use. For viewport surfaces (ids 0-2) it
+// also blits that surface onscreen itself, via an owned AutotextRender.
 class Autotext : public virtual ActionRecord, public Misc::HypertextParser {
 public:
 	Autotext() {}
-	virtual ~Autotext() {}
+	virtual ~Autotext() { delete _viewportRender; }
 
 	void readData(Common::SeekableReadStream &stream) override;
 	void execute() override;
@@ -65,6 +76,15 @@ protected:
 
 	Common::Array<uint16> _hotspotScenes;
 
+	// Nancy 10+ placement descriptor for viewport surfaces (0-2)
+	uint16 _placementMode = 0;
+	Common::Rect _viewportDest;
+	Common::Rect _viewportSrc;
+
+	// Set to false by subclasses (TextScroll) that handle their own display
+	bool _selfDisplay = true;
+	AutotextRender *_viewportRender = nullptr;
+
 	Graphics::ManagedSurface _image;
 };
 
diff --git a/engines/nancy/action/puzzle/peepholepuzzle.h b/engines/nancy/action/puzzle/peepholepuzzle.h
index fbd4bce43ee..54731ff04d0 100644
--- a/engines/nancy/action/puzzle/peepholepuzzle.h
+++ b/engines/nancy/action/puzzle/peepholepuzzle.h
@@ -82,7 +82,7 @@ protected:
 // on the fly and replaces the TextScroll/AutotextEntryList
 class TextScroll : public Autotext, public PeepholePuzzle {
 public:
-	TextScroll(bool isEntryList) : _isEntryList(isEntryList) {}
+	TextScroll(bool isEntryList) : _isEntryList(isEntryList) { _selfDisplay = false; }
 
 	void init() override;
 	void execute() override { PeepholePuzzle::execute(); }


Commit: 63264543bd1f9fb9d3fff6697985080d75536430
    https://github.com/scummvm/scummvm/commit/63264543bd1f9fb9d3fff6697985080d75536430
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:52+03:00

Commit Message:
NANCY: Use the correct top/left padding for ScrollTextBox

Fixes text placement in text boxes.
Fix #16890

Changed paths:
    engines/nancy/enginedata.cpp
    engines/nancy/enginedata.h
    engines/nancy/ui/scrolltextbox.cpp


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 732982644c8..4d98522ef2d 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -282,7 +282,7 @@ TBOX::TBOX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	defaultTextColor = chunkStream->readUint16LE();
 
 	if (g_nancy->getGameType() >= kGameTypeNancy10) {
-		chunkStream->skip(2);	// line-start X cursor
+		lineStartXCursor = chunkStream->readUint16LE();	// text left inset
 		chunkStream->skip(2);	// bottom margin
 		chunkStream->skip(2);	// unused
 		chunkStream->skip(2);	// initial color (we use the AR for this)
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 8658e75a767..0f07136cee9 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -181,6 +181,7 @@ struct TBOX : public EngineData {
 	// Nancy 10+ extra layout variables.
 	int32 maxScrollWidth = 0;
 	int32 firstLineY = 0; // added to the y-cursor when starting a new line
+	uint16 lineStartXCursor = 0; // left inset of the text within the text area
 	int32 unknown1 = 0;
 	int32 unknown2 = 0;
 	int32 contentWidth = 0;
diff --git a/engines/nancy/ui/scrolltextbox.cpp b/engines/nancy/ui/scrolltextbox.cpp
index 6d1e7e5373d..08ccfe21be7 100644
--- a/engines/nancy/ui/scrolltextbox.cpp
+++ b/engines/nancy/ui/scrolltextbox.cpp
@@ -128,8 +128,10 @@ void ScrollTextBox::drawContent() {
 	_textHighlightSurface.fillRect(Common::Rect(0, 0, _textHighlightSurface.w, _textHighlightSurface.h), transColor);
 
 	Common::Rect textBounds(0, 0, _fullSurface.w, _fullSurface.h);
-	textBounds.top  += _tboxData->scrollbarDefaultPos.y;
-	textBounds.left += _tboxData->scrollbarDefaultPos.x;
+	// The text's inset within the text area is the line-start X cursor. There is
+	// no vertical inset — the first line starts at the top of the text area, so
+	// don't add scrollbarDefaultPos (which is the scrollbar's gutter position).
+	textBounds.left += _tboxData->lineStartXCursor;
 
 	// Narration/caption text uses the (white) highlight font, not the (cyan)
 	// conversation body font that the ConversationPopup uses.


Commit: b1d03b80bdc522ee0e14c38207d5725604b5f5e2
    https://github.com/scummvm/scummvm/commit/b1d03b80bdc522ee0e14c38207d5725604b5f5e2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:52+03:00

Commit Message:
NANCY: NANCY12: Read win scene and detector zone from ActionZone data

MirrorLightPuzzle is now winnable. Leftover TODOs are cosmetic

Changed paths:
    engines/nancy/action/actionzone.cpp
    engines/nancy/action/actionzone.h
    engines/nancy/action/puzzle/mirrorlightpuzzle.cpp
    engines/nancy/action/puzzle/mirrorlightpuzzle.h


diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index aecb02f585f..3764d711d38 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -86,9 +86,9 @@ void ActionZone::readData(Common::SeekableReadStream &stream) {
 		tailId = stream.readSint16LE();
 		tailFlag = stream.readByte();
 		break;
-	case 0x15:
+	case 0x15:				// special effect + a trailing int32 (purpose unconfirmed)
 		readSpecialEffect(stream);
-		stream.skip(4);		// int32
+		stream.skip(4);
 		break;
 	case 0x0d:				// OverlayZone
 		readOverlayZone(stream);
@@ -103,22 +103,22 @@ void ActionZone::readData(Common::SeekableReadStream &stream) {
 	}
 }
 
-// Special Effect block: an int16 id, then a byte. If the byte is the 0xff
-// terminator the effect is absent; otherwise it is the first byte of an embedded
-// 21-byte SpecialEffect record (5 int32 + 1 byte).
+// Special Effect block: an int16 id (a target scene on transition zones), then the
+// effect type byte. If the type is the 0xff terminator the effect is absent;
+// otherwise a 21-byte SpecialEffect record follows (type + totalTime +
+// fadeToBlackTime + Rect), matching the standalone SpecialEffect action record.
 void ActionZone::readSpecialEffect(Common::SeekableReadStream &stream) {
 	specialEffectId = stream.readUint16LE();
-	byte b = stream.readByte();
-	if (b == 0xff) {
+	seType = stream.readByte();
+	if (seType == 0xff) {
+		seType = 0;
 		return;
 	}
 
-	stream.seek(-1, SEEK_CUR);
 	hasSpecialEffect = true;
-	for (int i = 0; i < 5; ++i) {
-		specialEffect[i] = stream.readSint32LE();
-	}
-	specialEffectFlag = stream.readByte();
+	seTotalTime = stream.readUint16LE();
+	seFadeToBlackTime = stream.readUint16LE();
+	readRect(stream, seRect);
 }
 
 void ActionZone::readOverlayZone(Common::SeekableReadStream &stream) {
diff --git a/engines/nancy/action/actionzone.h b/engines/nancy/action/actionzone.h
index f2711a5644a..75e32642548 100644
--- a/engines/nancy/action/actionzone.h
+++ b/engines/nancy/action/actionzone.h
@@ -60,8 +60,10 @@ struct ActionZone {
 	// special-effect subtypes when the effect byte is not the 0xff terminator).
 	uint16 specialEffectId = 0;
 	bool hasSpecialEffect = false;
-	int32 specialEffect[5] = {};
-	byte specialEffectFlag = 0;
+	byte seType = 0;				// 1 = blackout, 2 = cross-dissolve, 3 = through-black
+	uint16 seTotalTime = 0;
+	uint16 seFadeToBlackTime = 0;
+	Common::Rect seRect;
 
 	// int16 + byte trailer carried by the collision (0x0b) and trigger (0x0c)
 	// subtypes. For 0x0b it is an event-flag id + on/off; for 0x0c it is a target
diff --git a/engines/nancy/action/puzzle/mirrorlightpuzzle.cpp b/engines/nancy/action/puzzle/mirrorlightpuzzle.cpp
index 7e0f6fa0699..06b96697afe 100644
--- a/engines/nancy/action/puzzle/mirrorlightpuzzle.cpp
+++ b/engines/nancy/action/puzzle/mirrorlightpuzzle.cpp
@@ -33,17 +33,11 @@
 namespace Nancy {
 namespace Action {
 
-// TODO - open items for this puzzle:
-//  - Win/exit is fully EXTERNAL: the record has no scene-change of its own (its
-//    scene-change vtable slot is null) and writes no scene state, so the scene
-//    itself watches for the solved state and transitions. The exact signal (an
-//    event flag?) is still unidentified, so on solve we just finishExecution and
-//    the scene may not advance. The player's quit/cancel path is likewise unknown.
+// TODO - open items for this puzzle (mostly cosmetic):
 //  - Win presentation: the original lights the bulb via a per-zone movie
 //    animation (loaded by the movie loader at init); we draw a single static
 //    overlay frame with no sound instead.
-//  - Detector zone is chosen heuristically (any non-boundary zone away from the
-//    source); the precise detector is the tiny boundary zone inside the bulb.
+//  - The player's quit/cancel path (leaving an unsolved puzzle) is unknown.
 //  - Mirror rotation step (2 deg/click) approximates the original's exact
 //    per-click amount.
 
@@ -121,7 +115,6 @@ void MirrorLightPuzzle::traceBeam() {
 	// fixed 75deg mirror heads to the top-right mirror).
 	const double kStep = 1.0;
 	const int kMaxSteps = 20000;
-	const byte kZoneBoundary = 0x14;
 
 	double a = (double)_beamAngle * (M_PI / 180.0);
 	double dx = cos(a);
@@ -176,17 +169,10 @@ void MirrorLightPuzzle::traceBeam() {
 			continue;
 		}
 
-		// Reaching a detector zone (any non-boundary zone away from the source)
-		// solves the puzzle. TODO: confirm which zone is the true detector.
-		for (uint i = 0; i < _zones.size(); ++i) {
-			const ActionZone &z = _zones[i];
-			if (z.type != kZoneBoundary && !z.rect.isEmpty() &&
-					!z.rect.contains(origin) && z.rect.contains(p)) {
-				_solved = true;
-				break;
-			}
-		}
-		if (_solved) {
+		// Reaching the detector zone (the SpecialEffect zone at the bulb) solves
+		// the puzzle.
+		if (!_detectorRect.isEmpty() && _detectorRect.contains(p)) {
+			_solved = true;
 			_beamPath.push_back(p);
 			return;
 		}
@@ -341,6 +327,16 @@ void MirrorLightPuzzle::init() {
 	g_nancy->_resource->loadImage(_imageName, _image);
 	_image.setTransparentColor(_drawSurface.getTransparentColor());
 
+	// The detector zone carries a SpecialEffect whose id is the win scene (same
+	// semantics as MinigolfPuzzle's sink zone). Its rect is the beam target.
+	for (uint i = 0; i < _zones.size(); ++i) {
+		if (_zones[i].specialEffectId >= 1000) {
+			_detectorRect = _zones[i].rect;
+			_winScene.sceneID = _zones[i].specialEffectId;
+			break;
+		}
+	}
+
 	traceBeam();
 	redraw();
 }
@@ -363,10 +359,10 @@ void MirrorLightPuzzle::execute() {
 		}
 		break;
 	case kActionTrigger:
-		// TODO: the win/exit scene transitions are driven externally (an event
-		// flag the puzzle sets on solve + separate scene ARs), not embedded here.
-		// The solved event flag is still unidentified, so the scene may not
-		// advance yet; for now just release the record.
+		// The detector zone's SpecialEffect id is the win scene.
+		if (_solved && _winScene.sceneID >= 1000 && _winScene.sceneID != kNoScene) {
+			NancySceneState.changeScene(_winScene);
+		}
 		finishExecution();
 		break;
 	}
diff --git a/engines/nancy/action/puzzle/mirrorlightpuzzle.h b/engines/nancy/action/puzzle/mirrorlightpuzzle.h
index 9f8fe0ce728..1814cbc4815 100644
--- a/engines/nancy/action/puzzle/mirrorlightpuzzle.h
+++ b/engines/nancy/action/puzzle/mirrorlightpuzzle.h
@@ -73,6 +73,11 @@ protected:
 
 	Common::Array<ActionZone> _zones;
 
+	// Derived from the detector zone (the SpecialEffect zone at the bulb): its
+	// rect is the target the beam must reach, its specialEffectId is the win scene.
+	Common::Rect _detectorRect;
+	SceneChangeDescription _winScene;
+
 	// Runtime state
 	int16 _pickedUpMirror = -1;
 	bool _solved = false;


Commit: 6111dfcbe36ebdd71e342b24f3a5e1f8913d1bd6
    https://github.com/scummvm/scummvm/commit/6111dfcbe36ebdd71e342b24f3a5e1f8913d1bd6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-04T22:10:53+03:00

Commit Message:
NANCY: NANCY12: Implement MinigolfPuzzle

This is a minigolf game. The course is shown in a single static window:
the player drops the ball on the tee square, then aims a golf club
(angle + power) to putt it around a brick-walled course into the hole.

Changed paths:
  A engines/nancy/action/puzzle/minigolfpuzzle.cpp
  A engines/nancy/action/puzzle/minigolfpuzzle.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 26bdc2e47d2..31f0e60d385 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -53,6 +53,7 @@
 #include "engines/nancy/action/puzzle/mazechasepuzzle.h"
 #include "engines/nancy/action/puzzle/memorypuzzle.h"
 #include "engines/nancy/action/puzzle/mindpuzzle.h"
+#include "engines/nancy/action/puzzle/minigolfpuzzle.h"
 #include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
 #include "engines/nancy/action/puzzle/mouselightpuzzle.h"
 #include "engines/nancy/action/puzzle/multibuildpuzzle.h"
@@ -457,13 +458,12 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		return new HintSystem();
 	case 161:
 		// PlaySoundEventFlagTerse moved to 149 in Nancy12; this slot was reused for a new puzzle.
-		// TODO: Nancy12 MinigolfPuzzle (new), not implemented
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
-			return nullptr;
+			return new MinigolfPuzzle();
 		return new PlaySoundEventFlagTerse();
 	// -- Nancy 12 new puzzles/action records --
 	case 162:
-		// TODO: Nancy12 - new puzzle (reuses the AT_DRIVING_PUZZLE debug string), not implemented
+		// TODO: Nancy12 - sewing machine puzzle.
 		return nullptr;
 	case 163:
 		return new MirrorLightPuzzle();
diff --git a/engines/nancy/action/puzzle/minigolfpuzzle.cpp b/engines/nancy/action/puzzle/minigolfpuzzle.cpp
new file mode 100644
index 00000000000..7ba3d148078
--- /dev/null
+++ b/engines/nancy/action/puzzle/minigolfpuzzle.cpp
@@ -0,0 +1,466 @@
+/* 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/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/minigolfpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// TODO - open items:
+//  - Physics constants (power scaling, restitution) are approximations.
+//  - No abandon/exit path (quitting an unsolved hole) is identified.
+
+static const double kMaxDrag = 250.0;		// aim distance (mask px) for a full-power shot
+static const double kRestitution = 0.8;		// wall-bounce energy retained
+
+// Isometric projection (mask space -> screen): rotate 45 degrees, foreshorten Y by
+// half. cos45 == sin45; the Y component is additionally scaled by kIsoYScale.
+static const double kIsoCos = 0.70710678118654752;	// cos(pi/4) == sin(pi/4)
+static const double kIsoYScale = 0.5;
+
+void MinigolfPuzzle::readData(Common::SeekableReadStream &stream) {
+	// 106-byte PuzzleBase header (bulk-copied by the original into the puzzle object).
+	readFilename(stream, _ballImageName);		// 0x00
+	readFilename(stream, _holeBoundaryName);	// 0x21
+	_maxSpeed = stream.readSint32LE();			// 0x42
+	_decel = stream.readDoubleLE();				// 0x46
+	_launchMode = stream.readByte();			// 0x4e
+	readRect(stream, _teeRect);					// 0x4f - the tee / ball-start square
+	_initialPower = stream.readSint16LE();		// 0x5f
+	_initialAngle = stream.readSint16LE();		// 0x61
+	_winEventFlag = stream.readSint16LE();		// 0x63
+	stream.skip(4);								// 0x65
+	_mirrorFlag = stream.readByte();			// 0x69
+
+	// Ball roll animation frames (one sprite per evenly-spaced rotation).
+	uint16 numFrames = stream.readUint16LE();
+	_ballFrames.resize(numFrames);
+	for (uint i = 0; i < numFrames; ++i) {
+		readRect(stream, _ballFrames[i]);
+	}
+
+	// Two random-sound blocks: the putt and the wall-bounce cues.
+	_puttSound.readData(stream);
+	_wallSound.readData(stream);
+
+	// The hole/sink/overlay zones.
+	readActionZoneArray(stream, _zones);
+}
+
+void MinigolfPuzzle::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(_ballImageName, _image);
+	_image.setTransparentColor(_drawSurface.getTransparentColor());
+	g_nancy->_resource->loadImage(_holeBoundaryName, _boundaryMask);
+	if (_boundaryMask.empty()) {
+		warning("MinigolfPuzzle: boundary overlay '%s' did not load - walls will not work",
+			_holeBoundaryName.toString().c_str());
+	}
+
+	// Isometric projection anchors: the mask centre maps to the viewport centre.
+	_vpCenterX = vpBounds.width() / 2.0;
+	_vpCenterY = vpBounds.height() / 2.0;
+	if (!_boundaryMask.empty()) {
+		_maskCenterX = _boundaryMask.w / 2.0;
+		_maskCenterY = _boundaryMask.h / 2.0;
+	}
+
+	// The hole is the zone that plays the sink sounds (GOL_Sink*). (_teeRect comes
+	// from the header.) All coordinates are in mask/course space. That zone's
+	// "special effect" is really the win transition: its leading id is the scene to
+	// change to when the ball is potted (with the effect being the fade).
+	for (const ActionZone &z : _zones) {
+		bool isHole = false;
+		for (const Common::String &n : z._sound.names) {
+			if (n.contains("Sink") || n.contains("sink")) {
+				isHole = true;
+				break;
+			}
+		}
+		if (isHole) {
+			_holeRect = z.rect;
+			_sinkSound = z._sound;
+			_winScene.sceneID = z.specialEffectId;
+			if (z.hasSpecialEffect) {
+				_winHasFade = true;
+				_winFadeType = z.seType;
+				_winFadeTotalTime = z.seTotalTime;
+				_winFadeToBlackTime = z.seFadeToBlackTime;
+				_winFadeRect = z.seRect;
+			}
+			break;
+		}
+	}
+
+	// Start the ball on the tee. In the mask the fairway is WHITE and walls/out-of-
+	// bounds are BLACK, so the ball start sits on white - calibrate "open" there.
+	if (!_teeRect.isEmpty()) {
+		_ballX = (_teeRect.left + _teeRect.right) / 2.0;
+		_ballY = (_teeRect.top + _teeRect.bottom) / 2.0;
+	} else if (!_boundaryMask.empty()) {
+		_ballX = _boundaryMask.w / 2.0;
+		_ballY = _boundaryMask.h * 0.75;
+	} else {
+		_ballX = vpBounds.width() / 2.0;
+		_ballY = vpBounds.height() * 0.75;
+	}
+
+	if (!_boundaryMask.empty() && _ballX >= 0 && _ballY >= 0 &&
+			_ballX < _boundaryMask.w && _ballY < _boundaryMask.h) {
+		_openColor = _boundaryMask.getPixel((int)_ballX, (int)_ballY);
+	}
+
+	// A default aim in the level's preset direction, so the preview shows at once.
+	// The cursor sits behind the ball (opposite the launch direction).
+	double a = (double)_initialAngle * (M_PI / 180.0);
+	_aimCursor = Common::Point((int16)(_ballX - cos(a) * kMaxDrag), (int16)(_ballY + sin(a) * kMaxDrag));
+
+	// Every hole starts with the player placing the ball on the tee.
+	_mgState = kPlacing;
+	_lastUpdate = g_nancy->getTotalPlayTime();
+
+	redraw();
+}
+
+Common::Point MinigolfPuzzle::projectToScreen(double mx, double my) const {
+	double px = mx - _maskCenterX;
+	double py = my - _maskCenterY;
+	double sx = _vpCenterX + kIsoCos * (px - py);
+	double sy = _vpCenterY + kIsoCos * kIsoYScale * (px + py);
+	return Common::Point((int16)(sx + 0.5), (int16)(sy + 0.5));
+}
+
+void MinigolfPuzzle::unprojectToMask(int sx, int sy, double &mx, double &my) const {
+	double dx = (sx - _vpCenterX) / kIsoCos;
+	double dy = (sy - _vpCenterY) / (kIsoCos * kIsoYScale);
+	// Inverse of the (px-py, px+py) rotation.
+	double px = (dx + dy) / 2.0;
+	double py = (dy - dx) / 2.0;
+	mx = px + _maskCenterX;
+	my = py + _maskCenterY;
+}
+
+bool MinigolfPuzzle::isWall(int px, int py) const {
+	if (_boundaryMask.empty()) {
+		return false;	// no mask - fall back to open play
+	}
+	if (px < 0 || py < 0 || px >= (int)_boundaryMask.w || py >= (int)_boundaryMask.h) {
+		return true;	// off the course
+	}
+	return _boundaryMask.getPixel(px, py) != _openColor;
+}
+
+void MinigolfPuzzle::drawBall() {
+	if (_ballFrames.empty()) {
+		return;
+	}
+
+	const Common::Rect &src = _ballFrames[_ballFrame % _ballFrames.size()];
+	Common::Point c = projectToScreen(_ballX, _ballY);
+	Common::Point pos(c.x - src.width() / 2, c.y - src.height() / 2);
+	_drawSurface.blitFrom(_image, src, pos);
+}
+
+void MinigolfPuzzle::drawAimPreview() {
+	// Velocity the current aim would produce (mirrors launchBall: away from cursor).
+	double aimX = _ballX - _aimCursor.x;
+	double aimY = _ballY - _aimCursor.y;
+	double len = sqrt(aimX * aimX + aimY * aimY);
+	if (len < 3.0) {
+		return;
+	}
+	double power = MIN(len / kMaxDrag, 1.0) * (double)_maxSpeed;
+	double x = _ballX, y = _ballY;
+	double vx = (aimX / len) * power;
+	double vy = (aimY / len) * power;
+
+	// March the shot forward and drop a ghost ball (the ball sprite itself) at
+	// intervals along the predicted path, stopping at the hole or when it stalls.
+	const Common::Rect ghostSrc = _ballFrames.empty() ? Common::Rect() : _ballFrames[0];
+	const int kSteps = 240;
+	for (int i = 0; i < kSteps; ++i) {
+		bool reachedHole = stepBall(x, y, vx, vy, 1.0 / 30.0, false);
+		if (i % 12 == 0 && !ghostSrc.isEmpty()) {
+			Common::Point gc = projectToScreen(x, y);
+			Common::Point gp(gc.x - ghostSrc.width() / 2, gc.y - ghostSrc.height() / 2);
+			_drawSurface.blitFrom(_image, ghostSrc, gp);
+		}
+		if (reachedHole || sqrt(vx * vx + vy * vy) < 5.0) {
+			break;
+		}
+	}
+}
+
+void MinigolfPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	if (_mgState == kAiming) {
+		drawAimPreview();
+	}
+	drawBall();
+	_needsRedraw = true;
+}
+
+void MinigolfPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	if (block.names.empty()) {
+		return;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return;
+	}
+
+	SoundDescription desc;
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+}
+
+void MinigolfPuzzle::launchBall(const Common::Point &maskCursor) {
+	// The club is pulled back behind the ball: the shot travels away from the
+	// cursor (ball - cursor), golf-backswing style, not toward it.
+	double aimX = _ballX - maskCursor.x;
+	double aimY = _ballY - maskCursor.y;
+	double len = sqrt(aimX * aimX + aimY * aimY);
+	if (len < 3.0) {
+		return;
+	}
+
+	double power = MIN(len / kMaxDrag, 1.0) * (double)_maxSpeed;
+	_velX = (aimX / len) * power;
+	_velY = (aimY / len) * power;
+
+	playSoundBlock(_puttSound);
+	_mgState = kMoving;
+	_lastUpdate = g_nancy->getTotalPlayTime();
+}
+
+bool MinigolfPuzzle::stepBall(double &x, double &y, double &vx, double &vy, double dt, bool playSounds) {
+	double dispX = vx * dt;
+	double dispY = vy * dt;
+
+	// Walk the displacement in ~1px sub-steps so a fast ball can't tunnel through
+	// a thin wall (the original halves the step recursively for the same reason),
+	// reflecting off each axis independently so it can slide along an angled wall.
+	int steps = (int)ceil(MAX(ABS(dispX), ABS(dispY)));
+	if (steps < 1) {
+		steps = 1;
+	}
+	double sx = dispX / steps;
+	double sy = dispY / steps;
+	bool bounced = false;
+
+	for (int i = 0; i < steps; ++i) {
+		double nx = x + sx;
+		double ny = y + sy;
+
+		// Potting takes priority over wall reflection: the cup reads as non-fairway
+		// in the mask, so a sub-step that reaches the hole zone sinks the ball rather
+		// than bouncing off it. Checked per sub-step so a fast ball can't skip it.
+		if (!_holeRect.isEmpty() && _holeRect.contains(Common::Point((int16)(nx + 0.5), (int16)(ny + 0.5)))) {
+			x = nx;
+			y = ny;
+			return true;
+		}
+
+		if (isWall((int)(nx + 0.5), (int)(y + 0.5))) {
+			sx = -sx;
+			vx = -vx * kRestitution;
+			nx = x;
+			bounced = true;
+		}
+		if (isWall((int)(x + 0.5), (int)(ny + 0.5))) {
+			sy = -sy;
+			vy = -vy * kRestitution;
+			ny = y;
+			bounced = true;
+		}
+		if (isWall((int)(nx + 0.5), (int)(ny + 0.5))) {
+			sx = -sx;
+			sy = -sy;
+			vx = -vx * kRestitution;
+			vy = -vy * kRestitution;
+			nx = x;
+			ny = y;
+			bounced = true;
+		}
+
+		x = nx;
+		y = ny;
+	}
+
+	double f = 1.0 - _decel * dt;
+	if (f < 0.0) {
+		f = 0.0;
+	}
+	vx *= f;
+	vy *= f;
+
+	if (playSounds && bounced) {
+		playSoundBlock(_wallSound);
+	}
+	return false;
+}
+
+void MinigolfPuzzle::updateBall() {
+	uint32 now = g_nancy->getTotalPlayTime();
+	double dt = (now - _lastUpdate) / 1000.0;
+	_lastUpdate = now;
+	if (dt <= 0.0) {
+		return;
+	}
+	if (dt > 0.1) {
+		dt = 0.1;
+	}
+
+	bool reachedHole = stepBall(_ballX, _ballY, _velX, _velY, dt, true);
+
+	if (!_ballFrames.empty()) {
+		_ballFrame = (_ballFrame + 1) % _ballFrames.size();
+	}
+
+	// Potting the ball wins.
+	if (reachedHole) {
+		_velX = _velY = 0.0;
+		_ballX = (_holeRect.left + _holeRect.right) / 2.0;
+		_ballY = (_holeRect.top + _holeRect.bottom) / 2.0;
+		_mgState = kSunk;
+		_solved = true;
+		_sunkTime = now;
+		playSoundBlock(_sinkSound);
+		if (_winEventFlag != -1) {
+			NancySceneState.setEventFlag(_winEventFlag, g_nancy->_true);
+		}
+		return;
+	}
+
+	// Coming to rest without sinking readies the next stroke from where it stopped.
+	if (sqrt(_velX * _velX + _velY * _velY) < 5.0) {
+		_velX = _velY = 0.0;
+		_mgState = kAiming;
+	}
+}
+
+void MinigolfPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		_state = kRun;
+		// fall through
+	case kRun:
+		if (_mgState == kMoving) {
+			updateBall();
+			redraw();
+		} else if (_mgState == kSunk) {
+			// Hold on the sunk ball briefly, then release the record.
+			if (g_nancy->getTotalPlayTime() - _sunkTime > 1500) {
+				_state = kActionTrigger;
+			}
+		}
+		break;
+	case kActionTrigger:
+		// Potting shows the hole's win scene (and sets the win event flag). The sink
+		// zone's special effect is the fade that covers the change, so start it just
+		// before the scene change (it captures the current frame, then dissolves to
+		// the new scene).
+		if (_solved && _winScene.sceneID >= 1000 && _winScene.sceneID != kNoScene) {
+			if (_winHasFade) {
+				NancySceneState.specialEffect(_winFadeType, _winFadeTotalTime, _winFadeToBlackTime, _winFadeRect);
+			}
+			NancySceneState.changeScene(_winScene);
+		}
+		finishExecution();
+		break;
+	}
+}
+
+void MinigolfPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	// Keep the golf-club cursor while the ball is rolling / sunk (no input taken).
+	if (_mgState == kMoving || _mgState == kSunk) {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)34, true);
+		return;
+	}
+
+	// Cursor position, converted screen -> viewport -> mask (course) space.
+	Common::Rect screenPt(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1);
+	Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
+	double cmx, cmy;
+	unprojectToMask(vpPt.left, vpPt.top, cmx, cmy);
+	Common::Point cursor((int16)cmx, (int16)cmy);
+
+	if (_mgState == kPlacing) {
+		// The ball can only be dropped on the tee square; it tracks the cursor
+		// there until a click sets it down. Uses the drag-and-drop hand cursor.
+		g_nancy->_cursor->setCursorType(CursorManager::kDragHand);
+		if (!_teeRect.isEmpty()) {
+			_ballX = CLIP<int>(cursor.x, _teeRect.left, _teeRect.right - 1);
+			_ballY = CLIP<int>(cursor.y, _teeRect.top, _teeRect.bottom - 1);
+		} else {
+			_ballX = cursor.x;
+			_ballY = cursor.y;
+		}
+		redraw();
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			_mgState = kAiming;
+		}
+		return;
+	}
+
+	// kAiming: the golf-club cursor aims (angle) and its distance from the ball
+	// sets the power; a click strikes the ball. The club is CURS cursor type 34.
+	g_nancy->_cursor->setCursorType((CursorManager::CursorType)34, true);
+	_aimCursor = cursor;
+	redraw();
+	if (input.input & NancyInput::kLeftMouseButtonUp) {
+		launchBall(cursor);
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/minigolfpuzzle.h b/engines/nancy/action/puzzle/minigolfpuzzle.h
new file mode 100644
index 00000000000..3268eeeab82
--- /dev/null
+++ b/engines/nancy/action/puzzle/minigolfpuzzle.h
@@ -0,0 +1,130 @@
+/* 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_MINIGOLFPUZZLE_H
+#define NANCY_ACTION_MINIGOLFPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/action/actionzone.h"
+#include "engines/nancy/commontypes.h"
+
+namespace Nancy {
+namespace Action {
+
+// Minigolf minigame introduced in Nancy12 (AR 161). The course is shown in a
+// single static window: the player drops the ball on the tee square, then aims a
+// golf club (angle + power) to putt it around a brick-walled course into the hole.
+// Derives from the shared Nancy12 PuzzleBase layout (header + frame rects + two
+// sound blocks + ActionZone array), see ActionZone.
+class MinigolfPuzzle : public RenderActionRecord {
+public:
+	MinigolfPuzzle() : RenderActionRecord(7) {}
+	virtual ~MinigolfPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "MinigolfPuzzle"; }
+
+	void redraw();
+	void drawBall();
+	void drawAimPreview();
+	void playSoundBlock(const RandomSoundBlock &block);
+	void launchBall(const Common::Point &maskCursor);
+	bool stepBall(double &x, double &y, double &vx, double &vy, double dt, bool playSounds);
+	void updateBall();
+	bool isWall(int px, int py) const;
+
+	// The ball, zones and boundary mask live in a flat top-down "course" space (the
+	// mask resolution). The screen is a 2:1 isometric projection of it: rotate 45
+	// degrees about the mask centre and foreshorten Y by half, with the mask centre
+	// at the viewport centre. Physics run in mask space; these convert for drawing
+	// and for reading the cursor.
+	Common::Point projectToScreen(double mx, double my) const;
+	void unprojectToMask(int sx, int sy, double &mx, double &my) const;
+
+	// File data
+	Common::Path _ballImageName;		// GOL_Ball_OVL - the ball sprite sheet
+	Common::Path _holeBoundaryName;		// GOL_Hole05B_BNDRY - course collision boundary OVL
+
+	int32 _maxSpeed = 0;				// base+0x42, ball speed cap
+	double _decel = 0.0;				// base+0x46, per-second deceleration
+	byte _launchMode = 0;				// base+0x4e, ==2 the ball starts pre-placed on the tee
+	int16 _initialPower = 0;			// base+0x5f
+	int16 _initialAngle = 0;			// base+0x61, degrees - the default aim direction
+	int16 _winEventFlag = 0;			// base+0x63, set on sinking the ball
+	byte _mirrorFlag = 0;				// base+0x69
+
+	// Derived: scene shown when the ball is potted, taken from the sink zone's
+	// "special effect" (its leading id is the target scene, the effect is the fade).
+	SceneChangeDescription _winScene;
+	bool _winHasFade = false;			// the sink zone's fade, played over the win scene change
+	byte _winFadeType = 0;
+	uint16 _winFadeTotalTime = 0;
+	uint16 _winFadeToBlackTime = 0;
+	Common::Rect _winFadeRect;
+
+	Common::Array<Common::Rect> _ballFrames;	// ball roll animation frames
+
+	RandomSoundBlock _puttSound;		// played on launch
+	RandomSoundBlock _wallSound;		// played on a wall bounce
+
+	Common::Array<ActionZone> _zones;	// hole/sink/overlay zones
+	RandomSoundBlock _sinkSound;		// derived: the hole zone's sound
+
+	// Runtime state
+	enum State { kPlacing, kAiming, kMoving, kSunk };
+	State _mgState = kPlacing;
+
+	// Isometric projection anchors, computed in init().
+	double _maskCenterX = 0.0;			// mask width / 2
+	double _maskCenterY = 0.0;			// mask height / 2
+	double _vpCenterX = 0.0;			// viewport width / 2
+	double _vpCenterY = 0.0;			// viewport height / 2
+
+	Common::Rect _teeRect;				// derived: the tee square (course space)
+	Common::Rect _holeRect;				// derived: the sink zone's rect (course space)
+	uint32 _openColor = 0;				// boundary-mask colour that counts as open fairway
+
+	double _ballX = 0.0;
+	double _ballY = 0.0;
+	double _velX = 0.0;
+	double _velY = 0.0;
+	uint _ballFrame = 0;
+	Common::Point _aimCursor;			// current cursor position while aiming
+	uint32 _lastUpdate = 0;
+	uint32 _sunkTime = 0;
+	bool _solved = false;
+
+	Graphics::ManagedSurface _image;
+	Graphics::ManagedSurface _boundaryMask;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_MINIGOLFPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index f2cdce3da5c..fc4410e58af 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -37,6 +37,7 @@ MODULE_OBJS = \
   action/puzzle/matchpuzzle.o \
   action/puzzle/memorypuzzle.o \
   action/puzzle/mindpuzzle.o \
+  action/puzzle/minigolfpuzzle.o \
   action/puzzle/mirrorlightpuzzle.o \
   action/puzzle/mouselightpuzzle.o \
   action/puzzle/multibuildpuzzle.o \




More information about the Scummvm-git-logs mailing list