[Scummvm-git-logs] scummvm master -> 1874b80f3d0c8e3adfe1483b24584c7f7507b757

bluegr noreply at scummvm.org
Tue May 19 18:17:27 UTC 2026


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

Summary:
1874b80f3d NANCY: Rewrite multibuildpuzzle to be closed to the original sources


Commit: 1874b80f3d0c8e3adfe1483b24584c7f7507b757
    https://github.com/scummvm/scummvm/commit/1874b80f3d0c8e3adfe1483b24584c7f7507b757
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-05-19T21:17:18+03:00

Commit Message:
NANCY: Rewrite multibuildpuzzle to be closed to the original sources

Fixes issues with puzzles using multibuildpuzzle in Nancy 9: the
sandwich puzzle, the book stacking puzzle and the sand castle puzzle

Fix #16752, #16743, #16744, #16774

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


diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.cpp b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
index ead89f99a40..ab3b651f2cb 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.cpp
@@ -50,7 +50,6 @@ void MultiBuildPuzzle::init() {
 		int w = spriteSrc.width();
 		int h = spriteSrc.height();
 
-		// Rotation 0: blit from primary image using sprite source rect
 		p.rotateSurfaces[0].create(w, h, _primaryImage.format);
 		p.rotateSurfaces[0].setTransparentColor(_primaryImage.getTransparentColor());
 		p.rotateSurfaces[0].blitFrom(_primaryImage, spriteSrc, Common::Point(0, 0));
@@ -65,7 +64,6 @@ void MultiBuildPuzzle::init() {
 			}
 		}
 
-		// All pieces start at their homeRect (slot) in unplaced visual state
 		p.curRotation = 0;
 		p.gameRect = p.homeRect;
 		p.isPlaced = false;
@@ -76,22 +74,6 @@ void MultiBuildPuzzle::init() {
 		p.setZ((uint16)(_z + i + 1));
 	}
 
-	if (_hasCloseupImage) {
-		_shelfSlots.resize(_pieces.size());
-		for (uint i = 0; i < _pieces.size(); ++i) {
-			Piece &slot = _shelfSlots[i];
-			int w = _pieces[i].srcRect.width();
-			int h = _pieces[i].srcRect.height();
-			slot._drawSurface.create(w, h, _primaryImage.format);
-			slot._drawSurface.setTransparentColor(_primaryImage.getTransparentColor());
-			slot._drawSurface.blitFrom(_primaryImage, _pieces[i].srcRect, Common::Point(0, 0));
-			slot.moveTo(_pieces[i].homeRect);
-			slot.setTransparent(true);
-			slot.setVisible(true);
-			slot.setZ(_z);  // Below all active pieces (_z+1 and up)
-		}
-	}
-
 	_isInitialized = true;
 }
 
@@ -99,83 +81,73 @@ void MultiBuildPuzzle::registerGraphics() {
 	if (!_isInitialized)
 		return;
 
-	if (_hasCloseupImage) {
-		for (uint i = 0; i < _shelfSlots.size(); ++i)
-			_shelfSlots[i].registerGraphics();
-	}
 	for (uint i = 0; i < _pieces.size(); ++i)
 		_pieces[i].registerGraphics();
 }
 
 void MultiBuildPuzzle::readData(Common::SeekableReadStream &stream) {
-	// 0x00: primary image name (33 bytes)
 	readFilename(stream, _primaryImageName);
 
-	// 0x21: closeup image name (33 bytes)
 	Common::String secName;
 	readFilename(stream, secName);
 	_closeupImageName = Common::Path(secName);
 	_hasCloseupImage = (secName != "NO_FILE" && !secName.empty());
 
-	// 0x42: numPieces, requiredPieces
 	_numPieces = stream.readUint16LE();
 	_requiredPieces = stream.readUint16LE();
 
-	// 0x46: 1 unknown byte, 0x47: canRotateAll
-	stream.skip(1);
+	_autoSolveOnDrop = stream.readByte() != 0;
 	_canRotateAll = stream.readByte() != 0;
 
-	// 0x48-0x5e: 23 unknown bytes
-	stream.skip(23);
+	_useRotationHotspot = stream.readByte() != 0;
+	_rotHotspotHeight   = stream.readSint16LE();
+	_rotHotspotWidth    = stream.readSint16LE();
+
+	readRect(stream, _targetZone);
 
-	// 0x5f: pieces (always 20 × 67 bytes in data, only numPieces are valid)
+	_allowAltZoneSnap = stream.readByte() != 0;
+	_checkOverlapOnDrop = stream.readByte() != 0;
+
+	// Pieces: data file always has 20 × 67-byte slots; only _numPieces are used.
+	// Reserve up-front so counter-spawn push_back doesn't reallocate (pieces are
+	// RenderObjects already registered with the graphics manager).
+	_pieces.reserve(80);
 	_pieces.resize(_numPieces);
 	for (uint i = 0; i < 20; ++i) {
 		if (i < _numPieces) {
 			Piece &p = _pieces[i];
-			readRect(stream, p.srcRect);     // 0x00, 16 bytes
-			readRect(stream, p.homeRect);    // 0x10, 16 bytes
-			readRect(stream, p.altSrcRect);  // 0x20, 16 bytes
-			readRect(stream, p.cuSrcRect);   // 0x30, 16 bytes
-			p.counterByte  = stream.readByte();  // 0x40
-			p.mustPlace    = stream.readByte();  // 0x41
-			p.mustNotPlace = stream.readByte();  // 0x42
+			readRect(stream, p.srcRect);
+			readRect(stream, p.homeRect);
+			readRect(stream, p.altSrcRect);
+			readRect(stream, p.cuSrcRect);
+			p.counterByte  = stream.readByte();
+			p.mustPlace    = stream.readByte();
+			p.mustNotPlace = stream.readByte();
 		} else {
 			stream.skip(67);
 		}
 	}
 
-	// 0x59b: three SoundDescriptions (0x31 bytes each)
 	_rotationSound.readNormal(stream);
 	_pickupSound.readNormal(stream);
 	_dropSound.readNormal(stream);
 
-	// 0x62e: 6 unknown bytes
-	stream.skip(6);
+	_dragCursorID  = stream.readSint16LE();
+	_exitCursorID1 = stream.readSint16LE();
+	_exitCursorID2 = stream.readSint16LE();
 
-	// 0x634: solve scene, 25 bytes
 	_solveScene.readData(stream);
-
-	// 0x64d: solve sound (49 bytes)
 	_solveSound.readNormal(stream);
 
-	// 0x67e: solve text key (33 bytes, looked up in string table)
-	Common::String solveTextKey;
-	readFilename(stream, solveTextKey);
-
-	// 0x69f: solve raw text (200 bytes)
+	readFilename(stream, _solveTextKey);
 	char textBuf[200];
 	stream.read(textBuf, 200);
 	assembleTextLine(textBuf, _solveText, 200);
 
-	// 0x767: cancel scene, 25 bytes
 	_cancelScene.readData(stream);
 
-	// 0x780: exit hotspot rect (16 bytes)
 	readRect(stream, _exitHotspot);
-
-	// 0x790: target drop-zone rect — pieces must be within this area to count as validly placed
-	readRect(stream, _targetZone);
+	readRect(stream, _exitHotspot2);
 }
 
 void MultiBuildPuzzle::execute() {
@@ -192,28 +164,21 @@ void MultiBuildPuzzle::execute() {
 	case kRun:
 		switch (_solveState) {
 		case kIdle:
-			// Normal interaction; handleInput drives piece movement.
 			break;
 
 		case kWaitTimer:
-			// Short animation after placing a piece.
-			if (g_system->getMillis() >= _timerEnd) {
+			// Short debounce after placing a piece.
+			if (g_system->getMillis() >= _timerEnd)
 				_solveState = kIdle;
-			}
 			break;
 
 		case kPlaySolveSound:
-			// Play solve sound and show solve text, then wait for it to finish.
-			g_nancy->_sound->playSound(_solveSound);
-			if (!_solveText.empty()) {
-				NancySceneState.getTextbox().clear();
-				NancySceneState.getTextbox().addTextLine(_solveText);
-			}
+			// Solve sound + caption are now played synchronously inside
+			// checkIfSolved(); this state shouldn't normally be reached.
 			_solveState = kWaitSolveSound;
 			break;
 
 		case kWaitSolveSound:
-			// Wait until solve sound has finished, then trigger scene change.
 			if (!g_nancy->_sound->isSoundPlaying(_solveSound)) {
 				g_nancy->_sound->stopSound(_solveSound);
 				_state = kActionTrigger;
@@ -228,15 +193,17 @@ void MultiBuildPuzzle::execute() {
 		g_nancy->_sound->stopSound(_dropSound);
 		g_nancy->_sound->stopSound(_solveSound);
 		if (_isCancelled) {
-			// Change to cancel scene unconditionally, but only set the cancel flag if
-			// at least one counterByte==0 piece was placed.
 			NancySceneState.changeScene(_cancelScene._sceneChange);
+			// Cancel flag is only set if at least one piece was placed (or
+			// spawned). For sandwich (all counter pieces) the spawn delta is
+			// what trips the gate when a bad ingredient was placed.
 			if (_cancelScene._flag.label != kFlagNoLabel) {
 				uint16 count = 0;
-				for (uint i = 0; i < _pieces.size(); ++i) {
+				for (uint i = 0; i < _numPieces; ++i) {
 					if (_pieces[i].isPlaced && _pieces[i].counterByte == 0)
 						++count;
 				}
+				count += (uint16)(_pieces.size() - _numPieces);
 				if (count > 0)
 					NancySceneState.setEventFlag(_cancelScene._flag);
 			}
@@ -249,25 +216,145 @@ void MultiBuildPuzzle::execute() {
 	}
 }
 
+CursorManager::CursorType MultiBuildPuzzle::cursorFromDataID(int16 id, CursorManager::CursorType fallback) const {
+	if (id < 0 || id > 21)
+		return fallback;
+	return (CursorManager::CursorType)id;
+}
+
+bool MultiBuildPuzzle::altZoneSnapValid() const {
+	// Valid when the dragged piece sits atop a moved piece, within an X
+	// tolerance of 20 px and a Y tolerance of 4 px. Used for sand-castle
+	// stacking when _allowAltZoneSnap is set.
+	if (_pickedUpPiece < 0)
+		return false;
+
+	const Piece &pp = _pieces[_pickedUpPiece];
+	int halfW = (pp.gameRect.width()  - 1) / 2;
+	int halfH = (pp.gameRect.height() - 1) / 2;
+	int cx = pp.gameRect.left + halfW;
+	int cy = pp.gameRect.top  + halfH;
+
+	for (uint i = 0; i < _pieces.size(); ++i) {
+		if ((int)i == _pickedUpPiece)
+			continue;
+		const Piece &other = _pieces[i];
+		if (other.gameRect == other.homeRect)
+			continue;
+		if (other.gameRect.top  - 4 < cy + halfH &&
+		    cy < other.gameRect.top &&
+		    other.gameRect.left - 20 < cx - halfW &&
+		    cx + halfW < (other.gameRect.right - 1) + 20)
+			return true;
+	}
+	return false;
+}
+
+void MultiBuildPuzzle::spawnCounterPiece(int srcIdx) {
+	if (srcIdx < 0 || srcIdx >= (int)_pieces.size())
+		return;
+	// Defensive cap to avoid runaway memory use (sand castle can spawn endlessly).
+	if (_pieces.size() >= 80)
+		return;
+
+	const Piece &src = _pieces[srcIdx];
+	// All clones share surfaces with the original piece at typeIdx.
+	int sharedType = (src.typeIdx >= 0) ? src.typeIdx : srcIdx;
+	const Piece &surf = _pieces[sharedType];
+
+	_pieces.push_back(Piece());
+	Piece &np = _pieces.back();
+	np.srcRect      = src.srcRect;
+	np.homeRect     = src.homeRect;
+	np.altSrcRect   = src.altSrcRect;
+	np.cuSrcRect    = src.cuSrcRect;
+	np.counterByte  = src.counterByte;
+	np.mustPlace    = src.mustPlace;
+	np.mustNotPlace = src.mustNotPlace;
+	np.typeIdx      = sharedType;
+	np.curRotation  = 0;
+	np.gameRect     = np.homeRect;
+	np.isPlaced     = false;
+
+	// Each clone needs its own ManagedSurface since it's an independent RenderObject.
+	for (int r = 0; r < 4; ++r) {
+		if (!surf.hasSurface[r])
+			continue;
+		int w = surf.rotateSurfaces[r].w;
+		int h = surf.rotateSurfaces[r].h;
+		np.rotateSurfaces[r].create(w, h, surf.rotateSurfaces[r].format);
+		np.rotateSurfaces[r].setTransparentColor(surf.rotateSurfaces[r].getTransparentColor());
+		np.rotateSurfaces[r].blitFrom(surf.rotateSurfaces[r], Common::Point(0, 0));
+		np.hasSurface[r] = true;
+	}
+
+	int newIdx = (int)_pieces.size() - 1;
+	updatePieceRender(newIdx);
+	_pieces[newIdx].setVisible(true);
+	_pieces[newIdx].setTransparent(true);
+	_pieces[newIdx].setZ((uint16)(_z + newIdx + 1));
+	_pieces[newIdx].registerGraphics();
+}
+
 bool MultiBuildPuzzle::isValidDrop() const {
+	if (_pickedUpPiece < 0)
+		return false;
+
 	const Piece &pp = _pieces[_pickedUpPiece];
 
-	// Geometric checks apply only to non-closeup puzzles with a win condition (e.g. books).
-	// Sand castle (no closeup image, _requiredPieces=0) allows free stacking.
-	// Sandwich puzzle (has closeup image) allows free-form placement.
-	if (!_hasCloseupImage && _requiredPieces > 0) {
-		// Boundary check: drop center must be inside the target zone
-		Common::Point dropCenter((pp.gameRect.left + pp.gameRect.right) / 2,
-								 (pp.gameRect.top + pp.gameRect.bottom) / 2);
-		if (!_targetZone.isEmpty() && !_targetZone.contains(dropCenter))
+	// Bounding-box-inside test. Left/top are strict; right/bottom allow up to
+	// kEdgeTolerance px of overflow (matches the design tolerance the engine
+	// uses for the overlap check below).
+	const int kEdgeTolerance = 3;
+	bool inTargetZone =
+		!_targetZone.isEmpty() &&
+		_targetZone.left   < pp.gameRect.left &&
+		pp.gameRect.right  <= _targetZone.right  + kEdgeTolerance &&
+		_targetZone.top    < pp.gameRect.top &&
+		pp.gameRect.bottom <= _targetZone.bottom + kEdgeTolerance;
+
+	if (!inTargetZone) {
+		if (!_allowAltZoneSnap || !altZoneSnapValid())
 			return false;
+	}
+
+	// Overlap check with 3-px tolerance: pieces are considered overlapping
+	// only if their bounding boxes overlap by more than 3 pixels (so adjacent
+	// or barely-touching placements are accepted).
+	if (_checkOverlapOnDrop) {
+		int ppLeft   = pp.gameRect.left;
+		int ppTop    = pp.gameRect.top;
+		int ppRight  = pp.gameRect.right  - 1;
+		int ppBottom = pp.gameRect.bottom - 1;
 
-		// Overlap check: piece must not overlap any already-placed piece
 		for (uint i = 0; i < _pieces.size(); ++i) {
-			if ((int)i != _pickedUpPiece && _pieces[i].isPlaced &&
-				pp.gameRect.intersects(_pieces[i].gameRect)) {
+			if ((int)i == _pickedUpPiece)
+				continue;
+			if (!_pieces[i].isPlaced)
+				continue;
+			const Piece &other = _pieces[i];
+			int otherLeft   = other.gameRect.left;
+			int otherTop    = other.gameRect.top;
+			int otherRight  = other.gameRect.right  - 1;
+			int otherBottom = other.gameRect.bottom - 1;
+
+			bool xOverlap = false;
+			if (ppLeft < otherLeft)
+				xOverlap = (otherLeft + 3 <= ppRight);
+			else if (ppLeft <= otherRight - 3)
+				xOverlap = true;
+
+			if (!xOverlap)
+				continue;
+
+			bool yOverlap = false;
+			if (ppTop < otherTop)
+				yOverlap = (otherTop + 3 <= ppBottom);
+			else if (ppTop <= otherBottom - 3)
+				yOverlap = true;
+
+			if (yOverlap)
 				return false;
-			}
 		}
 	}
 
@@ -288,11 +375,14 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 	Common::Point mouseVP(input.mousePos.x - vpScreen.left,
 	                      input.mousePos.y - vpScreen.top);
 
+	CursorManager::CursorType dragCursor = cursorFromDataID(_dragCursorID, CursorManager::kCustom1);
+
 	if (_isDragging) {
-		// Update dragged piece to center on cursor
+		// Centre dragged piece on cursor. Offset uses (width-1)/2 to match
+		// the original's inclusive-coordinate delta arithmetic.
 		Piece &pp = _pieces[_pickedUpPiece];
-		int newLeft = mouseVP.x - _pickedUpWidth / 2;
-		int newTop  = mouseVP.y - _pickedUpHeight / 2;
+		int newLeft = mouseVP.x - (_pickedUpWidth  - 1) / 2;
+		int newTop  = mouseVP.y - (_pickedUpHeight - 1) / 2;
 		pp.gameRect.left   = newLeft;
 		pp.gameRect.top    = newTop;
 		pp.gameRect.right  = newLeft + _pickedUpWidth;
@@ -300,7 +390,7 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 		updatePieceRender(_pickedUpPiece);
 		bool validDrop = isValidDrop();
 
-		g_nancy->_cursor->setCursorType(validDrop ? CursorManager::kCustom1 : CursorManager::kNormal);
+		g_nancy->_cursor->setCursorType(validDrop ? dragCursor : CursorManager::kNormal);
 
 		// Right click: rotate the carried piece
 		if ((input.input & NancyInput::kRightMouseButtonUp) && pp.hasSurface[1]) {
@@ -312,14 +402,9 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 			return;
 		}
 
-		// Left click: drop the piece wherever the cursor is.
-		// For non-closeup puzzles: reject if the drop center is outside the target zone or
-		// the piece overlaps an already-placed piece; piece returns to shelf on rejection.
-		// For closeup puzzles: no geometric checks — free-form placement.
+		// Left click: drop. Invalid drop returns the piece to its home rect.
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
-			// Clear drag state BEFORE updatePieceRender so the correct visual is chosen:
-			// - valid drop:   isPlaced=true,  isDragging=false -> shows rotation surface at drop pos
-			// - invalid drop: isPlaced=false, isDragging=false -> shows shelf srcRect at homeRect
+			// Clear drag state before updatePieceRender so the correct visual is chosen.
 			_isDragging = false;
 			int placedIdx = _pickedUpPiece;
 			_pickedUpPiece = -1;
@@ -327,17 +412,21 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 			if (validDrop) {
 				pp.isPlaced = true;
 				g_nancy->_sound->playSound(_dropSound);
+
+				// Counter pieces respawn at home for unlimited supply.
+				if (pp.counterByte != 0)
+					spawnCounterPiece(placedIdx);
 			} else {
-				// Return piece to its shelf position
 				pp.gameRect = pp.homeRect;
 			}
 
 			updatePieceRender(placedIdx);
 
-			// After placing, check if the puzzle is now solved (FUN_0046da47)
-			checkIfSolved();
+			// Solve check runs on drop for puzzles with _autoSolveOnDrop, and
+			// also once piece count grows past the original's auto-solve trigger.
+			if (_autoSolveOnDrop || _pieces.size() > 79)
+				checkIfSolved();
 
-			// Brief debounce before next interaction (only if not transitioning to solve)
 			if (_solveState == kIdle) {
 				_solveState = kWaitTimer;
 				_timerEnd = g_system->getMillis() + 300;
@@ -356,9 +445,8 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 			pp.curRotation = 0;
 			_pickedUpWidth  = pp.rotateSurfaces[0].w;
 			_pickedUpHeight = pp.rotateSurfaces[0].h;
-			// Centre the drag rect on the cursor now that we leave the CU display position
-			int newLeft = mouseVP.x - _pickedUpWidth / 2;
-			int newTop  = mouseVP.y - _pickedUpHeight / 2;
+			int newLeft = mouseVP.x - (_pickedUpWidth  - 1) / 2;
+			int newTop  = mouseVP.y - (_pickedUpHeight - 1) / 2;
 			pp.gameRect = Common::Rect(newLeft, newTop,
 			                          newLeft + _pickedUpWidth, newTop + _pickedUpHeight);
 			updatePieceRender(sel);
@@ -367,18 +455,56 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
-	// Not dragging and nothing selected: look for a piece to pick up / select
+	// Find a piece to pick up. First pass detects the rotation hotspot
+	// (a small rect at each piece's top-left) when rotation is enabled.
+	int16 topmostRot = -1;
 	int16 topmost = -1;
+	bool rotationsEnabled = _canRotateAll && _useRotationHotspot &&
+	                        _rotHotspotWidth > 0 && _rotHotspotHeight > 0;
 	for (int i = (int)_pieces.size() - 1; i >= 0; --i) {
 		Piece &p = _pieces[i];
 		if (!p.gameRect.contains(mouseVP))
 			continue;
+		// Placed counter pieces are locked (can't re-grab the placed ingredient).
+		if (p.isPlaced && p.counterByte != 0)
+			continue;
+		if (rotationsEnabled) {
+			Common::Rect rotRect(p.gameRect.left, p.gameRect.top,
+			                     p.gameRect.left + _rotHotspotWidth,
+			                     p.gameRect.top  + _rotHotspotHeight);
+			if (rotRect.contains(mouseVP)) {
+				if (topmostRot == -1 || p.getZOrder() > _pieces[topmostRot].getZOrder())
+					topmostRot = (int16)i;
+				continue;
+			}
+		}
 		if (topmost == -1 || p.getZOrder() > _pieces[topmost].getZOrder())
 			topmost = (int16)i;
 	}
 
+	if (topmostRot != -1) {
+		// Hovering rotation hotspot: click rotates 90° and picks up.
+		g_nancy->_cursor->setCursorType(CursorManager::kRotateCW);
+		if (input.input & NancyInput::kLeftMouseButtonUp) {
+			Piece &pp = _pieces[topmostRot];
+			pp.isPlaced = false;
+			pp.curRotation = (pp.curRotation + 1) % 4;
+			if (!pp.hasSurface[pp.curRotation])
+				pp.curRotation = 0;
+			pp.setZ((uint16)(_z + (int)_pieces.size() * 2));
+			pp.registerGraphics();
+			_isDragging = true;
+			_pickedUpPiece = topmostRot;
+			_pickedUpWidth  = pp.rotateSurfaces[pp.curRotation].w;
+			_pickedUpHeight = pp.rotateSurfaces[pp.curRotation].h;
+			g_nancy->_sound->playSound(_rotationSound);
+			updatePieceRender(topmostRot);
+		}
+		return;
+	}
+
 	if (topmost != -1) {
-		g_nancy->_cursor->setCursorType(CursorManager::kCustom1);
+		g_nancy->_cursor->setCursorType(dragCursor);
 
 		if (input.input & NancyInput::kLeftMouseButtonUp) {
 			Piece &pp = _pieces[topmost];
@@ -388,8 +514,7 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 			pp.registerGraphics();
 
 			if (_hasCloseupImage && !pp.cuSrcRect.isEmpty()) {
-				// First click shows the close-up view centered on the
-				// piece's current center.
+				// First click shows the closeup view centred on the piece.
 				_selectedPiece = topmost;
 				const int cuW = pp.cuSrcRect.width();
 				const int cuH = pp.cuSrcRect.height();
@@ -399,12 +524,11 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 				const int centerY = pp.gameRect.top  + pieceH / 2;
 				int cuLeft = centerX - cuW / 2;
 				int cuTop  = centerY - cuH / 2;
-				// Clamp so the close-up stays fully inside the viewport.
 				cuLeft = CLIP<int>(cuLeft, 0, MAX(0, vpScreen.width()  - cuW));
 				cuTop  = CLIP<int>(cuTop,  0, MAX(0, vpScreen.height() - cuH));
 				pp.gameRect = Common::Rect(cuLeft, cuTop, cuLeft + cuW, cuTop + cuH);
 			} else {
-				// Direct drag: first click immediately starts dragging
+				// Direct drag on first click.
 				_isDragging = true;
 				_pickedUpPiece = topmost;
 				_pickedUpWidth  = pp.rotateSurfaces[0].w;
@@ -416,80 +540,79 @@ void MultiBuildPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
-	// Check exit hotspot
-	if (!_exitHotspot.isEmpty()) {
-		Common::Rect exitScreen = NancySceneState.getViewport().convertViewportToScreen(_exitHotspot);
-		if (exitScreen.contains(input.mousePos)) {
-			g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
-			if (input.input & NancyInput::kLeftMouseButtonUp) {
-				checkIfSolvedOnExit();
-			}
-		}
-	}
+	// Exit hotspots: a click in either fires the exit path. Each uses its own data cursor id.
+	if (!checkExitHotspot(_exitHotspot, _exitCursorID1, input))
+		checkExitHotspot(_exitHotspot2, _exitCursorID2, input);
 }
 
-void MultiBuildPuzzle::checkIfSolvedOnExit() {
-	if (_requiredPieces == 1) {
-		// Check constraints again
-		bool placedBadPiece = false;
-		bool placedPiece = false;
-
-		for (uint i = 0; i < _pieces.size(); ++i) {
-			if (_pieces[i].isPlaced) {
-				if (_pieces[i].mustNotPlace) {
-					NancySceneState.setEventFlag(_cancelScene._flag);
-					placedBadPiece = true;
-					break;
-				}
-				placedPiece = true;
-			}
-		}
+bool MultiBuildPuzzle::checkExitHotspot(const Common::Rect &hot, int16 cursorID, const NancyInput &input) {
+	if (hot.isEmpty())
+		return false;
+	Common::Rect exitScreen = NancySceneState.getViewport().convertViewportToScreen(hot);
+	if (!exitScreen.contains(input.mousePos))
+		return false;
+	g_nancy->_cursor->setCursorType(cursorFromDataID(cursorID, g_nancy->_cursor->_puzzleExitCursor));
+	if (input.input & NancyInput::kLeftMouseButtonUp)
+		checkIfSolvedOnExit();
+	return true;
+}
 
-		if (!placedPiece || placedBadPiece) {
-			// Player hasn't placed any pieces, or has placed
-			// at least one bad piece -> retry scene or lose,
-			// depending on the cancelScene flag.
-			_isCancelled = true;
-			_state = kActionTrigger;
-		} else {
-			// Player has placed only good pieces -> player won.
-			_isSolved = true;
-			_solveState = kPlaySolveSound;
-		}
-	} else {
+void MultiBuildPuzzle::checkIfSolvedOnExit() {
+	// Run the solve check; if it doesn't mark the puzzle solved, cancel out.
+	checkIfSolved();
+	if (!_isSolved) {
 		_isCancelled = true;
 		_state = kActionTrigger;
 	}
 }
 
 void MultiBuildPuzzle::checkIfSolved() {
-	// Non-CU puzzles (no secondary image) with _requiredPieces == 0 have no win condition;
-	// the player exits manually (e.g. sand castle). CU puzzles (sandwich) with
-	// _requiredPieces == 0 still use mustPlace/mustNotPlace constraints to determine solve.
-	if (_requiredPieces == 0 && !_hasCloseupImage)
-		return;
-
-	// Count correctly placed pieces (those with counterByte == 0)
+	// Count = placed pieces with counterByte == 0, plus the spawn delta
+	// (so counter-piece puzzles like sandwich count each placement).
 	uint16 count = 0;
-	for (uint i = 0; i < _pieces.size(); ++i) {
+	for (uint i = 0; i < _numPieces; ++i) {
 		if (_pieces[i].isPlaced && _pieces[i].counterByte == 0)
 			++count;
 	}
+	count += (uint16)(_pieces.size() - _numPieces);
 
 	if (count < _requiredPieces)
 		return;
 
-	// Check constraints: no placed mustNotPlace piece, no unplaced mustPlace piece
-	for (uint i = 0; i < _pieces.size(); ++i) {
+	// Bail without solving on any constraint failure.
+	for (uint i = 0; i < _numPieces; ++i) {
 		if (_pieces[i].isPlaced && _pieces[i].mustNotPlace)
 			return;
 		if (!_pieces[i].isPlaced && _pieces[i].mustPlace)
 			return;
 	}
 
-	// Solved!
+	// Play sound + caption inline (rather than parking in kPlaySolveSound for
+	// execute() to pick up) so the transition is deterministic regardless of
+	// action-record processing order.
 	_isSolved = true;
-	_solveState = kPlaySolveSound;
+	g_nancy->_sound->playSound(_solveSound);
+
+	// Caption: prefer the CONVO lookup of the text key. An empty lookup
+	// result means audio-only — keep the textbox empty. Only fall back to
+	// the raw _solveText when the key isn't in CONVO.
+	Common::String textToShow;
+	bool useLookup = false;
+	if (!_solveTextKey.empty()) {
+		const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
+		if (convo && convo->texts.contains(_solveTextKey)) {
+			textToShow = convo->texts[_solveTextKey];
+			useLookup = true;
+		}
+	}
+	if (!useLookup)
+		textToShow = _solveText;
+	if (!textToShow.empty()) {
+		NancySceneState.getTextbox().clear();
+		NancySceneState.getTextbox().addTextLine(textToShow);
+	}
+
+	_solveState = kWaitSolveSound;
 }
 
 void MultiBuildPuzzle::updatePieceRender(int pieceIdx) {
@@ -510,15 +633,14 @@ void MultiBuildPuzzle::updatePieceRender(int pieceIdx) {
 			p._drawSurface.blitFrom(p.rotateSurfaces[rot], Common::Point(0, 0));
 		}
 	} else if (isSelected && _hasCloseupImage && !p.cuSrcRect.isEmpty()) {
-		// Show zoomed close-up
+		// Zoomed closeup.
 		int w = p.cuSrcRect.width();
 		int h = p.cuSrcRect.height();
 		p._drawSurface.create(w, h, _closeupImage.format);
 		p._drawSurface.setTransparentColor(_closeupImage.getTransparentColor());
 		p._drawSurface.blitFrom(_closeupImage, p.cuSrcRect, Common::Point(0, 0));
 	} else {
-		// Unplaced and at rest on the shelf (or selected with no close-up available):
-		// show srcRect from primary image.
+		// At rest on the shelf: show srcRect from primary image.
 		int w = p.srcRect.width();
 		int h = p.srcRect.height();
 		p._drawSurface.create(w, h, _primaryImage.format);
diff --git a/engines/nancy/action/puzzle/multibuildpuzzle.h b/engines/nancy/action/puzzle/multibuildpuzzle.h
index 93b1f3534de..0a3b755982d 100644
--- a/engines/nancy/action/puzzle/multibuildpuzzle.h
+++ b/engines/nancy/action/puzzle/multibuildpuzzle.h
@@ -23,6 +23,7 @@
 #define NANCY_ACTION_MULTIBUILDPUZZLE_H
 
 #include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/cursor.h"
 #include "engines/nancy/renderobject.h"
 
 namespace Nancy {
@@ -59,21 +60,19 @@ protected:
 	struct Piece : RenderObject {
 		Piece() : RenderObject(0) {}
 
-		// --- File data ---
-		Common::Rect srcRect;     // Source rect in primary image (unplaced visual)
-		Common::Rect homeRect;    // Screen position in viewport coords (= the slot)
-		Common::Rect altSrcRect;  // If non-zero area: used as source for sprite creation
-		Common::Rect cuSrcRect;   // Source rect in secondary image (overlay when placed)
-		uint8 counterByte = 0;    // If 0, piece counts toward requiredPieces tally
-		uint8 mustPlace = 0;      // If 1, piece MUST be placed for solution
-		uint8 mustNotPlace = 0;   // If 1, placing this piece fails the solution check
-
-		// --- Runtime ---
-		Common::Rect gameRect;    // Current viewport-space rect (homeRect or cursor-following)
+		Common::Rect srcRect;     // Source in primary image (unplaced visual)
+		Common::Rect homeRect;    // Slot position in viewport coords
+		Common::Rect altSrcRect;  // If non-empty: used as source for sprite creation
+		Common::Rect cuSrcRect;   // Source in closeup image
+		uint8 counterByte = 0;    // Non-zero: respawns on placement; doesn't count toward solve
+		uint8 mustPlace = 0;      // Must be placed for solution
+		uint8 mustNotPlace = 0;   // Placing this fails the solution check
+
+		Common::Rect gameRect;    // Current viewport-space rect
 		int curRotation = 0;
 		bool isPlaced = false;
+		int  typeIdx = -1;        // -1 for original pieces; source piece index for counter clones
 
-		// Up to 4 rotation surfaces (rotation 1-3 only if canRotateAll or altSrcRect non-zero)
 		Graphics::ManagedSurface rotateSurfaces[4];
 		bool hasSurface[4] = {};
 
@@ -82,49 +81,54 @@ protected:
 		bool isViewportRelative() const override { return true; }
 	};
 
-	// --- File data ---
-
 	Common::Path _primaryImageName;
 	Common::Path _closeupImageName;
 	bool _hasCloseupImage = false;
 
 	uint16 _numPieces = 0;
-	uint16 _requiredPieces = 0;  // Minimum placed pieces (with counterByte==0) needed to trigger solve check
+	uint16 _requiredPieces = 0;        // Minimum placed pieces (counterByte==0) for solve check
+	bool _autoSolveOnDrop = false;     // If true, solve check fires after each drop
 	bool _canRotateAll = false;
+	bool _useRotationHotspot = false;  // Rotation triggered only by clicking a hotspot rect
+	int16 _rotHotspotHeight = 0;
+	int16 _rotHotspotWidth = 0;
+	bool _allowAltZoneSnap = false;    // Allow drop outside target zone if stacking on a moved piece
+	bool _checkOverlapOnDrop = false;  // Reject drop if it overlaps an already-placed piece
 
 	Common::Array<Piece> _pieces;
-	// For closeup puzzles (e.g. sandwich): permanent shelf visuals that stay at homeRect
-	// so the shelf always shows the source ingredient regardless of where the active piece is.
-	Common::Array<Piece> _shelfSlots;
 
 	SoundDescription _rotationSound;
 	SoundDescription _pickupSound;
 	SoundDescription _dropSound;
 
+	int16 _dragCursorID  = 16;  // Cursor for hover/drag/drop (kCustom1 by default)
+	int16 _exitCursorID1 = -1;  // -1: use _puzzleExitCursor
+	int16 _exitCursorID2 = -1;
+
 	SceneChangeWithFlag _solveScene;
 	SoundDescription _solveSound;
-	Common::String _solveText;
+	Common::String _solveTextKey;  // Looked up in CONVO chunk first
+	Common::String _solveText;     // Raw fallback used if key missing
 
 	SceneChangeWithFlag _cancelScene;
 	Common::Rect _exitHotspot;
-	Common::Rect _targetZone;  // Valid drop area (drawer/plate/etc.); pieces must be within this to solve
-
-	// --- Runtime state ---
+	Common::Rect _exitHotspot2;
+	Common::Rect _targetZone;      // Valid drop area (drawer/plate/...)
 
 	Graphics::ManagedSurface _primaryImage;
 	Graphics::ManagedSurface _closeupImage;
 
-	int16 _selectedPiece = -1;  // Index of selected piece (CU view shown, not yet dragging), -1 if none
-	int16 _pickedUpPiece = -1;  // Index of piece being dragged, -1 if none
+	int16 _selectedPiece = -1;  // Piece shown in closeup view, -1 if none
+	int16 _pickedUpPiece = -1;  // Piece being dragged, -1 if none
 	bool _isDragging = false;
 	bool _isSolved = false;
 	bool _isCancelled = false;
 
 	enum SolveState {
-		kIdle          = 0,
-		kWaitTimer     = 1,
-		kWaitSolveSound = 4,  // Waiting for solve sound to stop playing
-		kPlaySolveSound = 5   // Trigger solve sound + text, then wait
+		kIdle           = 0,
+		kWaitTimer      = 1,
+		kWaitSolveSound = 4,
+		kPlaySolveSound = 5
 	};
 	SolveState _solveState = kIdle;
 	uint32 _timerEnd = 0;
@@ -132,18 +136,22 @@ protected:
 	int16 _pickedUpWidth = 0;
 	int16 _pickedUpHeight = 0;
 
-	// Initialization flag, used to ensure that the puzzle pieces have been initialized
-	// before drawing them on screen
 	bool _isInitialized = false;
 
-	// --- Internal methods ---
-
-	void checkIfSolved();	// FUN_0046da47
+	void checkIfSolved();
 	void checkIfSolvedOnExit();
-	// Update a piece's _drawSurface and position to match its current state
 	void updatePieceRender(int pieceIdx);
-	// Rotate a surface 90 degrees clockwise into dst (dst is allocated here)
 	static void rotateSurface90CW(const Graphics::ManagedSurface &src, Graphics::ManagedSurface &dst);
+	// Clone an existing piece at the end of _pieces (counter-piece respawn).
+	void spawnCounterPiece(int srcIdx);
+	// Drop is valid if it overhangs the top of a moved piece (sand-castle stacking).
+	bool altZoneSnapValid() const;
+	// Map data cursor id (0..21) to CursorManager::CursorType; out-of-range falls back.
+	CursorManager::CursorType cursorFromDataID(int16 id, CursorManager::CursorType fallback) const;
+	// Tests one exit hotspot and (if hovered) sets its cursor / handles the click.
+	// Returns true when the cursor is inside `hot`, so the caller can skip the
+	// other hotspot.
+	bool checkExitHotspot(const Common::Rect &hot, int16 cursorID, const NancyInput &input);
 };
 
 } // End of namespace Action




More information about the Scummvm-git-logs mailing list