[Scummvm-git-logs] scummvm master -> 6aa8fa9b6f9e5a7ae670cc355af1b727ff75c995
bluegr
noreply at scummvm.org
Fri Aug 7 03:06:49 UTC 2026
This automated email contains information about 10 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .
Summary:
f36dc38bea NANCY: NANCY12: More work on DrivingPuzzle
a72a80d983 NANCY: NANCY7: Prefer Bink videos when both Bink and AVF are present
4133e1fdc5 NANCY: NANCY12: Implement new SortPuzzle functionality
1c8420f637 NANCY: NANCY12: Implement new functionality for OneBuildPuzzle
5504142fbd NANCY: NANCY12: Show correct subtitles for overriden "I can't do that"
07d7b5fb66 NANCY: NANCY12: Fix jitter of last placed piece in SortPuzzle
6c3705875a NANCY: NANCY12: Fix flag triggering in SewingMachinePuzzle
c0477955d0 NANCY: NANCY12: Set cursor from puzzle data in OrderingPuzzle
d9e5cd4e2d NANCY: NANCY10: Add correct draw check for the checkbox
6aa8fa9b6f NANCY: NANCY12: Implement new solve state handling in TwoDialPuzzle
Commit: f36dc38bead14f74d671f3505ec16469627b7eec
https://github.com/scummvm/scummvm/commit/f36dc38bead14f74d671f3505ec16469627b7eec
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:24+03:00
Commit Message:
NANCY: NANCY12: More work on DrivingPuzzle
- Add handling for obstacles (e.g. cows)
- Fix layers drawn above the car (trees, power poles, roofs etc)
- Fix depletion of gas (needs to be preserved across scenes)
- Add cheat for gas tank refill and infinite fuel with Control-Shift-G
- Add cheat to fix and replace the car tyre with Control-Shift-T
This bumps up the savegame version
Changed paths:
engines/nancy/action/actionzone.cpp
engines/nancy/action/actionzone.h
engines/nancy/action/puzzle/drivingpuzzle.cpp
engines/nancy/action/puzzle/drivingpuzzle.h
engines/nancy/nancy.h
engines/nancy/puzzledata.cpp
engines/nancy/puzzledata.h
diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index bf16c6bf8bd..41447b17280 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -172,7 +172,7 @@ void ActionZone::readOverlayZone(Common::SeekableReadStream &stream, bool isNanc
}
stream.skip(4); // int32
stream.skip(1); // byte (loop/play mode)
- stream.skip(4); // int32
+ overlayLayer = stream.readSint32LE();
}
void readActionZoneArray(Common::SeekableReadStream &stream, Common::Array<ActionZone> &out, bool isNancy13) {
diff --git a/engines/nancy/action/actionzone.h b/engines/nancy/action/actionzone.h
index 96d255e6a85..4d3045a09aa 100644
--- a/engines/nancy/action/actionzone.h
+++ b/engines/nancy/action/actionzone.h
@@ -121,6 +121,7 @@ struct ActionZone {
Common::String overlayName;
Common::Array<Common::Rect> overlaySrcRects;
Common::Rect overlayDestRect;
+ int32 overlayLayer = 0; // draw pass: 0 renders under the car, 1 over it
// The Nancy13 pinball layout (AR 175) differs from the Nancy12 one: the base carries an
// extra int32 before the sound block, and the overlay/unknown-0x15/bumper subtypes have
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.cpp b/engines/nancy/action/puzzle/drivingpuzzle.cpp
index cd66be16c15..44b20a0b4a2 100644
--- a/engines/nancy/action/puzzle/drivingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/drivingpuzzle.cpp
@@ -23,6 +23,7 @@
#include "common/random.h"
#include "engines/nancy/nancy.h"
+#include "engines/nancy/enginedata.h"
#include "engines/nancy/graphics.h"
#include "engines/nancy/resource.h"
#include "engines/nancy/sound.h"
@@ -181,11 +182,20 @@ void DrivingPuzzle::classifyZones(const Common::Array<ActionZone> &zones) {
ov.destRect = z.overlayDestRect;
ov.condFlag = z.val49;
ov.condValue = z.val4b;
+ ov.aboveCar = z.overlayLayer != 0;
if (ov.imageIndex >= 0) {
_overlays.push_back(ov);
}
break;
}
+ case kZoneBoundary: { // flag-gated road obstacle (a cow blocking the road, etc.)
+ Obstacle obs;
+ obs.rect = z.rect;
+ obs.condFlag = z.val49;
+ obs.condValue = z.val4b;
+ _obstacles.push_back(obs);
+ break;
+ }
default:
// The remaining subtypes (terrain markers) are not simulated yet.
break;
@@ -275,6 +285,8 @@ void DrivingPuzzle::init() {
_carY = data->carY;
_carHeading = data->heading;
_tireDamage = data->tireDamage;
+ _fuelBurnAccum = data->fuelBurnAccum;
+ _infiniteFuel = data->infiniteFuel;
}
}
@@ -329,7 +341,7 @@ int DrivingPuzzle::overlayImageIndex(const Common::String &name) {
return (int)_overlayImages.size() - 1;
}
-void DrivingPuzzle::drawOverlays(const Common::Point &cam) {
+void DrivingPuzzle::drawOverlays(const Common::Point &cam, bool aboveCar) {
if (_overlays.empty()) {
return;
}
@@ -340,6 +352,9 @@ void DrivingPuzzle::drawOverlays(const Common::Point &cam) {
for (uint i = 0; i < _overlays.size(); ++i) {
const Overlay &ov = _overlays[i];
+ if (ov.aboveCar != aboveCar) {
+ continue;
+ }
if (ov.imageIndex < 0 || !view.intersects(ov.destRect)) {
continue;
}
@@ -368,6 +383,24 @@ void DrivingPuzzle::saveState() const {
data->carY = (int32)(_carY + 0.5);
data->heading = _carHeading;
data->tireDamage = _tireDamage;
+ data->fuelBurnAccum = _fuelBurnAccum;
+ data->infiniteFuel = _infiniteFuel;
+ }
+}
+
+void DrivingPuzzle::refillFuel() {
+ const UIRC *uirc = GetEngineData(UIRC)
+ if (uirc && _frictionIndex >= 0 && (uint)_frictionIndex < uirc->items.size()) {
+ NancySceneState.setUIResource(_frictionIndex, uirc->items[_frictionIndex].id);
+ _fuelBurnAccum = 0.0;
+ }
+}
+
+void DrivingPuzzle::repairTire() {
+ _tireDamage = 0;
+ const UIRC *uirc = GetEngineData(UIRC)
+ if (uirc && kTireResourceIndex < uirc->items.size()) {
+ NancySceneState.setUIResource(kTireResourceIndex, uirc->items[kTireResourceIndex].id);
}
}
@@ -383,7 +416,20 @@ bool DrivingPuzzle::isWall(int px, int py) const {
}
bool DrivingPuzzle::isBlocked(const Common::Point &p) const {
- return isWall(p.x, p.y);
+ if (isWall(p.x, p.y)) {
+ return true;
+ }
+
+ // A cow (or similar) is blocking the road while its story flag is set.
+ for (uint i = 0; i < _obstacles.size(); ++i) {
+ const Obstacle &obs = _obstacles[i];
+ if (obs.rect.contains(p) &&
+ (obs.condFlag == -1 || NancySceneState.getEventFlag(obs.condFlag, obs.condValue))) {
+ return true;
+ }
+ }
+
+ return false;
}
void DrivingPuzzle::drawScene() {
@@ -393,9 +439,9 @@ void DrivingPuzzle::drawScene() {
_drawSurface.blitFrom(_image, Common::Rect(camX, camY, camX + _drawSurface.w, camY + _drawSurface.h), Common::Point(0, 0));
- // Map decorations (buildings, cars, potholes, animated cows/flags) sit on the map,
+ // Ground-level decorations (potholes, parked cars, cows, fountain) lie on the road,
// under the cars.
- drawOverlays(cam);
+ drawOverlays(cam, false);
// The chaser car (kChase), drawn under the player car.
if (_variant == kChase && !_frameRects2.empty() && _chaseCarImage.w > 0) {
@@ -413,6 +459,10 @@ void DrivingPuzzle::drawScene() {
_drawSurface.blitFrom(_carImage, src, Common::Point(sx, sy));
}
+ // Tall decorations (buildings, trees, power-line poles, flags) draw over the cars,
+ // so the car passes behind them.
+ drawOverlays(cam, true);
+
_needsRedraw = true;
}
@@ -580,7 +630,7 @@ void DrivingPuzzle::updatePhysics(int throttle, double cursorDist) {
// The gas tank empties by the distance the car actually travels this frame divided by
// the header's distance divisor. The DT_RESOURCE scene dependency reads the same
// resource to warn Nancy when it runs low.
- if (_distanceDivisor > 0) {
+ if (_distanceDivisor > 0 && !_infiniteFuel) {
double moved = sqrt((_carX - preX) * (_carX - preX) + (_carY - preY) * (_carY - preY));
_fuelBurnAccum += moved / (double)_distanceDivisor;
if (_fuelBurnAccum >= 1.0) {
@@ -679,6 +729,27 @@ void DrivingPuzzle::handleInput(NancyInput &input) {
return;
}
+ // Cheats: Ctrl+Shift+G toggles infinite fuel (tops the tank off and stops the drain);
+ // Ctrl+Shift+T repairs the spare tire (clears pothole wear and restores it to good).
+ for (uint i = 0; i < input.otherKbdInput.size(); ++i) {
+ const Common::KeyState &key = input.otherKbdInput[i];
+ if ((key.flags & Common::KBD_CTRL) == 0 || (key.flags & Common::KBD_SHIFT) == 0) {
+ continue;
+ }
+ if (key.keycode == Common::KEYCODE_g) {
+ _infiniteFuel = !_infiniteFuel;
+ if (_infiniteFuel) {
+ refillFuel();
+ }
+ saveState();
+ debug("Gas cheat: infinite fuel %s", _infiniteFuel ? "ON" : "OFF");
+ } else if (key.keycode == Common::KEYCODE_t) {
+ repairTire();
+ saveState();
+ debug("Tire cheat: spare tire repaired");
+ }
+ }
+
// A tire has blown: hold the car still until the blowout sound finishes, then leave
// for the flat-tire scene (where Nancy fits the spare).
if (_flatTirePending) {
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.h b/engines/nancy/action/puzzle/drivingpuzzle.h
index 5e9e8fb9bd1..f944cbc9dac 100644
--- a/engines/nancy/action/puzzle/drivingpuzzle.h
+++ b/engines/nancy/action/puzzle/drivingpuzzle.h
@@ -137,6 +137,15 @@ protected:
bool carInside = false; // the car was inside this zone last frame
};
+ // A removable road obstacle (type 0x14): a cow (or similar) that blocks the car while
+ // its event-flag condition holds. Each has a matching 0x0d cow overlay gated on the same
+ // flag, so the sprite and the collision appear and vanish together with the story state.
+ struct Obstacle {
+ Common::Rect rect;
+ int16 condFlag = -1; // base zone val49: event flag gating the block
+ byte condValue = 0; // base zone val4b: the flag value that activates it
+ };
+
// A cosmetic map decoration (type 0x0d): a sprite drawn onto the map at destRect.
// A single source rect is static; several are animation frames cycled over time.
// It is only visible while its event-flag condition holds (condFlag == -1 = always).
@@ -146,6 +155,7 @@ protected:
Common::Rect destRect; // map space
int16 condFlag = -1; // base zone val49: event flag gating visibility
byte condValue = 0; // base zone val4b: the flag value that shows it
+ bool aboveCar = false; // layer 1 draws over the car (tall props), 0 under
};
// A recorded chaser-path waypoint (kChase): the pursuer plays these back in real
@@ -191,6 +201,12 @@ protected:
// (and saving). Only does anything when the header's retainState flag is set.
void saveState() const;
+ // Refills the gas tank to the full amount from the UIRC boot chunk (the infinite-fuel cheat).
+ void refillFuel();
+
+ // Clears the accumulated pothole wear and restores the spare tire to good (the fix-tire cheat).
+ void repairTire();
+
// Whether a map-space point is off the road: off the map, or a non-white (dark)
// pixel in the collision mask (its white marks the drivable streets).
bool isWall(int px, int py) const;
@@ -211,7 +227,7 @@ protected:
// Draws the map's cosmetic decorations (animated frames cycled over time), offset by
// the camera and clipped to the visible window.
- void drawOverlays(const Common::Point &cam);
+ void drawOverlays(const Common::Point &cam, bool aboveCar);
// Redraws the scrolling map (car-centered camera) and the car sprite(s) on top.
void drawScene();
@@ -229,7 +245,8 @@ protected:
int32 _startAngle = 0; // blob+0x6b: start heading, degrees
int32 _forwardSpeed = 0; // blob+0x6f: forward speed cap
int32 _reverseSpeed = 0; // blob+0x73
- int16 _frictionIndex = 0; // blob+0x77: index into the shared friction table
+ int16 _frictionIndex = 0; // blob+0x77: UIRC resource index for the fuel gauge
+ static const uint kTireResourceIndex = 2; // UIRC resource index for the tire gauge (1 = good)
int32 _distanceDivisor = 0; // blob+0x7b
bool _retainState = false; // blob+0x7f: resume from the saved position
uint16 _finishScene = kNoScene; // blob+0x80: the scene entered when a tire goes flat
@@ -256,6 +273,7 @@ protected:
Common::Array<MudZone> _mudZones; // type 0x03
Common::Array<Pothole> _potholes; // type 0x17
Common::Array<Overlay> _overlays; // type 0x0d (map decorations)
+ Common::Array<Obstacle> _obstacles; // type 0x14 (flag-gated road obstacles)
Common::Array<Graphics::ManagedSurface> _overlayImages;
Common::Array<Common::String> _overlayImageNames;
@@ -291,6 +309,7 @@ protected:
double _fuelBurnAccum = 0.0;
int _tireDamage = 0;
bool _flatTirePending = false;
+ bool _infiniteFuel = false; // cheat: Ctrl+Shift+G tops the tank and stops it draining
// Chaser (kChase) runtime state.
bool _chaseStarted = false;
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index 583aa165b23..14c9c5a0bcd 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -54,7 +54,7 @@ class Serializer;
*/
namespace Nancy {
-static const int kSavegameVersion = 7;
+static const int kSavegameVersion = 8;
struct NancyGameDescription;
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 9c1457140a9..290f1505180 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -496,6 +496,9 @@ void DrivingData::synchronize(Common::Serializer &ser) {
ser.syncAsDoubleLE(heading);
ser.syncAsSint32LE(tireDamage);
ser.syncAsByte(flatTire);
+ // Added in savegame version 8; older saves default these to 0/false.
+ ser.syncAsDoubleLE(fuelBurnAccum, 8);
+ ser.syncAsByte(infiniteFuel, 8);
}
PuzzleData *makePuzzleData(const uint32 tag) {
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 3a42c7820f6..90badcc3894 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -411,6 +411,8 @@ struct DrivingData : public PuzzleData {
double heading = 0.0;
int32 tireDamage = 0;
bool flatTire = false;
+ double fuelBurnAccum = 0.0; // fractional fuel drained but not yet a whole unit
+ bool infiniteFuel = false; // cheat toggle, kept across building visits
};
PuzzleData *makePuzzleData(const uint32 tag);
Commit: a72a80d983b6efbc66a63511112b87a979e9e05b
https://github.com/scummvm/scummvm/commit/a72a80d983b6efbc66a63511112b87a979e9e05b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:25+03:00
Commit Message:
NANCY: NANCY7: Prefer Bink videos when both Bink and AVF are present
Fixes graphics in SortPuzzle (Cake Puzzle) in Nancy12, where the
puzzle's background (EXT_PieSorting_PUZ) exists as both a Bink and an
AVF video, but the AVF video contains an early unused version
Changed paths:
engines/nancy/movieplayer.cpp
engines/nancy/movieplayer.h
diff --git a/engines/nancy/movieplayer.cpp b/engines/nancy/movieplayer.cpp
index ce685c3650f..6c6ad8aa384 100644
--- a/engines/nancy/movieplayer.cpp
+++ b/engines/nancy/movieplayer.cpp
@@ -54,13 +54,13 @@ bool MoviePlayer::loadFile(const Common::Path &name, bool bidirectionalCache) {
const Common::Path avfPath = name.append(".avf");
const Common::Path bikPath = name.append(".bik");
- // Detect the format from which file exists (AVF wins if both do).
- if (Common::File::exists(avfPath)) {
- _videoType = kVideoPlaytypeAVF;
- _decoder.reset(new AVFDecoder(bidirectionalCache ? AVFDecoder::kLoadBidirectional : AVFDecoder::kLoadForward));
- } else if (Common::File::exists(bikPath)) {
+ // Detect the format from which file exists. Bink wins if both do.
+ if (Common::File::exists(bikPath)) {
_videoType = kVideoPlaytypeBink;
_decoder.reset(new Video::BinkDecoder());
+ } else if (Common::File::exists(avfPath)) {
+ _videoType = kVideoPlaytypeAVF;
+ _decoder.reset(new AVFDecoder(bidirectionalCache ? AVFDecoder::kLoadBidirectional : AVFDecoder::kLoadForward));
} else {
_decoder.reset();
return false;
diff --git a/engines/nancy/movieplayer.h b/engines/nancy/movieplayer.h
index d8f5aa8df05..07d9d1259ab 100644
--- a/engines/nancy/movieplayer.h
+++ b/engines/nancy/movieplayer.h
@@ -59,7 +59,7 @@ public:
~MoviePlayer();
// Load <name> + ".avf"/".bik", auto-detecting the format from which file
- // exists (AVF preferred if both do) and creating the matching decoder.
+ // exists (Bink preferred if both do) and creating the matching decoder.
// bidirectionalCache enables fast bidirectional scrubbing; pass it only for
// scrubbed panorama scenes.
bool loadFile(const Common::Path &name, bool bidirectionalCache = false);
Commit: 4133e1fdc5744ba9767e61a14b64331a8114327b
https://github.com/scummvm/scummvm/commit/4133e1fdc5744ba9767e61a14b64331a8114327b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:26+03:00
Commit Message:
NANCY: NANCY12: Implement new SortPuzzle functionality
Fixes issues in the Cake Sorting puzzle
Changed paths:
engines/nancy/action/puzzle/sortpuzzle.cpp
engines/nancy/action/puzzle/sortpuzzle.h
engines/nancy/cursor.cpp
engines/nancy/cursor.h
diff --git a/engines/nancy/action/puzzle/sortpuzzle.cpp b/engines/nancy/action/puzzle/sortpuzzle.cpp
index b5304b93daf..e5cfcff41fb 100644
--- a/engines/nancy/action/puzzle/sortpuzzle.cpp
+++ b/engines/nancy/action/puzzle/sortpuzzle.cpp
@@ -127,23 +127,31 @@ void SortPuzzle::readData(Common::SeekableReadStream &stream) {
}
// Nancy 12 reworked the record: the board layout is computed from a single cell
-// size plus origin/spacing (rather than a per-position rect grid), gems are drawn
-// from one source sprite per value, and there is an (unused-here) preset board and
-// shuffle flag. The engine reshuffles itself, so those are skipped.
+// size plus origin/spacing (rather than a per-position rect grid), the solved
+// layout is stored as a preset grid of values, and the win check is configurable
+// through a set of flags.
void SortPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
readFilename(stream, _boardImageName); // 0x000
readFilename(stream, _cursorImageName); // 0x021
_retainState = (stream.readByte() != 0); // 0x042
_rows = stream.readUint16LE(); // 0x043
_cols = stream.readUint16LE(); // 0x045
- stream.skip(4); // 0x047 unknown
- stream.skip(1); // 0x04b preset/shuffle flag (engine reshuffles)
- stream.skip(200); // 0x04c preset board values (100 x int16)
+
+ _matchRow = (stream.readByte() != 0); // 0x047
+ _matchGroup = (stream.readByte() != 0); // 0x048
+ _matchValue = (stream.readByte() != 0); // 0x049
+ _allowSwappedPairs = (stream.readByte() != 0); // 0x04a
+ _usePresetBoard = (stream.readByte() != 0); // 0x04b
+
+ for (uint i = 0; i < (uint)(kPresetStride * kPresetStride); ++i) { // 0x04c
+ _presetBoard[i] = stream.readSint16LE();
+ }
+
stream.skip(2); // 0x114 unknown
_groupDivisor = stream.readUint16LE(); // 0x116
_valueRange = stream.readUint16LE(); // 0x118
- _valueSrcRects.resize(kNumValueRects); // 0x11a: one source sprite per gem value
+ _valueSrcRects.resize(kNumValueRects); // 0x11a
for (uint i = 0; i < (uint)kNumValueRects; ++i) {
readRect(stream, _valueSrcRects[i]);
}
@@ -177,17 +185,22 @@ void SortPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
_cellWidth = _valueSrcRects[0].width();
_cellHeight = _valueSrcRects[0].height();
+
+ // Cells only react to clicks well inside their own bounds, so that the
+ // near-contiguous grid doesn't drop a piece into the neighboring slot
+ _hotspotInsetX = (_cellWidth - 1) / 4;
+ _hotspotInsetY = (_cellHeight - 1) / 4;
}
Common::Rect SortPuzzle::cellSprite(const Cell &cell) const {
if (g_nancy->getGameType() >= kGameTypeNancy12) {
- // Each pie has a kind (its group/column, cell.srcCol) and a size
- // (cell.value, smallest to biggest). The source sprites are laid out as a
- // grid - kind * kSizesPerKind + size - so the index must combine both,
- // otherwise every kind would draw the same pie at a given size.
- if (cell.srcCol >= 0 && cell.value >= 0) {
- uint idx = (uint)cell.srcCol * kSizesPerKind + (uint)cell.value;
- if (idx < _valueSrcRects.size()) {
+ // A pie is identified by the row it started in (cell.srcRow), its kind
+ // (the group of columns it started in, cell.srcCol) and its size
+ // (cell.value). The source sprites are laid out as a grid over all three,
+ // so the index has to combine them.
+ if (cell.srcRow >= 0 && cell.srcCol >= 0 && cell.value >= 0) {
+ uint idx = ((uint)cell.srcRow * kGroupsPerRow + (uint)cell.srcCol) * kSizesPerKind + (uint)cell.value;
+ if (idx < (uint)kNumBoardRects) {
return _valueSrcRects[idx];
}
}
@@ -201,6 +214,19 @@ Common::Rect SortPuzzle::cellSprite(const Cell &cell) const {
return Common::Rect();
}
+// Nancy 12 keeps a second, smaller set of sprites for the piece being carried
+// around. When a size has no such sprite the board one is drawn instead.
+Common::Rect SortPuzzle::heldSprite(const Cell &cell) const {
+ if (cell.value >= 0 && cell.value < kSizesPerKind) {
+ const Common::Rect &src = _valueSrcRects[kNumBoardRects + cell.value];
+ if (!src.isEmpty()) {
+ return src;
+ }
+ }
+
+ return cellSprite(cell);
+}
+
void SortPuzzle::initState() {
SortPuzzleData *spd = (SortPuzzleData *)NancySceneState.getPuzzleData(SortPuzzleData::getTag());
if (_retainState && spd && !spd->currentState.empty() && !spd->solvedState.empty()) {
@@ -216,9 +242,19 @@ void SortPuzzle::initState() {
Cell &cell = _solved[r][c];
cell.srcRow = (int16)r;
cell.srcCol = (int16)(c / groupSize);
- cell.value = (int16)(g_nancy->_randomSource->getRandomNumber(_valueRange - 1));
cell.isEmpty = false;
+ if (_usePresetBoard) {
+ cell.value = _presetBoard[r * kPresetStride + c];
+ if (cell.value == kEmptyCell) {
+ cell.srcRow = kEmptyCell;
+ cell.isEmpty = true;
+ }
+ continue;
+ }
+
+ cell.value = (int16)(g_nancy->_randomSource->getRandomNumber(_valueRange - 1));
+
int groupStart = (c / groupSize) * groupSize;
int k = c;
while (k > groupStart && _solved[r][k].value < _solved[r][k - 1].value) {
@@ -265,8 +301,12 @@ void SortPuzzle::init() {
g_nancy->_resource->loadImage(_boardImageName, _boardImage);
_boardImage.setTransparentColor(_drawSurface.getTransparentColor());
- g_nancy->_resource->loadImage(_cursorImageName, _cursorImage);
- _cursorImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+ // Nancy 12 has no separate cursor image and draws the held piece from the board one
+ if (!_cursorImageName.empty() && _cursorImageName.baseName() != "NO_FILE") {
+ g_nancy->_resource->loadImage(_cursorImageName, _cursorImage);
+ _cursorImage.setTransparentColor(_drawSurface.getTransparentColor());
+ }
initState();
@@ -344,10 +384,19 @@ Common::Rect SortPuzzle::cellRect(int row, int col) const {
return Common::Rect(x, y, x + _cellWidth, y + _cellHeight);
}
+Common::Rect SortPuzzle::cellHotspot(int row, int col) const {
+ Common::Rect rect = cellRect(row, col);
+ rect.left += _hotspotInsetX;
+ rect.right -= _hotspotInsetX;
+ rect.top += _hotspotInsetY;
+ rect.bottom -= _hotspotInsetY;
+ return rect;
+}
+
bool SortPuzzle::hitTestCell(const Common::Point &p, int &outRow, int &outCol) const {
for (int r = 0; r < (int)_rows; ++r) {
for (int c = 0; c < (int)_cols; ++c) {
- if (cellRect(r, c).contains(p)) {
+ if (cellHotspot(r, c).contains(p)) {
outRow = r;
outCol = c;
return true;
@@ -383,26 +432,42 @@ void SortPuzzle::handleInput(NancyInput &input) {
redraw();
}
+ // Nancy 12 uses the dedicated puzzle hands: a closed one over a piece that can
+ // be picked up, an open one over a slot the carried piece can go into. Off the
+ // grid the cursor goes blank, since the carried piece is drawn at the mouse.
+ const bool useNewCursors = g_nancy->getGameType() >= kGameTypeNancy12;
+
int row, col;
bool hitCell = hitTestCell(mouseVP, row, col);
+ // An empty slot is only a target while carrying a piece
+ if (hitCell && !_hasHeld && _current[row][col].isEmpty) {
+ hitCell = false;
+ }
+
if (!hitCell) {
if (!_exitHotspot.isEmpty() && _exitHotspot.contains(mouseVP)) {
g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
if (input.input & NancyInput::kLeftMouseButtonUp)
_subState = kExitToCancel;
+ } else if (_hasHeld && useNewCursors) {
+ g_nancy->_cursor->setCursorType(CursorManager::kNewBlank);
}
return;
}
- g_nancy->_cursor->setCursorType(_hasHeld ? CursorManager::kDropHand
- : CursorManager::kHotspot);
+ if (useNewCursors) {
+ g_nancy->_cursor->setCursorType(_hasHeld ? CursorManager::kNewUseHandHotspot
+ : CursorManager::kNewDragHandHotspot);
+ } else {
+ g_nancy->_cursor->setCursorType(_hasHeld ? CursorManager::kDropHand
+ : CursorManager::kHotspot);
+ }
+
if (!(input.input & NancyInput::kLeftMouseButtonUp))
return;
if (!_hasHeld) {
- if (_current[row][col].isEmpty)
- return;
_held = _current[row][col];
_hasHeld = true;
_current[row][col].isEmpty = true;
@@ -431,16 +496,63 @@ void SortPuzzle::handleInput(NancyInput &input) {
redraw();
}
+bool SortPuzzle::cellsMatch(const Cell &cur, const Cell &sol) const {
+ if (cur.isEmpty || sol.isEmpty) {
+ return cur.isEmpty && sol.isEmpty;
+ }
+
+ if (_matchRow && cur.srcRow != sol.srcRow) {
+ return false;
+ }
+
+ if (_matchGroup && cur.srcCol != sol.srcCol) {
+ return false;
+ }
+
+ if (_matchValue && cur.value != sol.value) {
+ return false;
+ }
+
+ return true;
+}
+
void SortPuzzle::checkSolved() {
+ const int groupSize = _groupDivisor ? (int)_cols / (int)_groupDivisor : 0;
+
for (int r = 0; r < (int)_rows; ++r) {
- for (int c = 0; c < (int)_cols; ++c) {
- const Cell &cur = _current[r][c];
- const Cell &sol = _solved[r][c];
- if (cur.isEmpty || cur.srcRow != sol.srcRow ||
- cur.srcCol != sol.srcCol || cur.value != sol.value)
+ int c = 0;
+ while (c < (int)_cols) {
+ if (cellsMatch(_current[r][c], _solved[r][c])) {
+ ++c;
+ continue;
+ }
+
+ // Puzzles that pair their cells up two at a time accept a pair sorted
+ // the other way around
+ if (!_allowSwappedPairs || groupSize != 2 || c >= (int)_cols - 1) {
+ return;
+ }
+
+ if (!cellsMatch(_current[r][c + 1], _solved[r][c]) ||
+ !cellsMatch(_current[r][c], _solved[r][c + 1])) {
+ return;
+ }
+
+ const Cell &first = _current[r][c];
+ const Cell &second = _current[r][c + 1];
+ bool firstKeepsGroup = first.isEmpty || first.srcCol == _solved[r][c].srcCol;
+ bool secondKeepsGroup = second.isEmpty || second.srcCol == _solved[r][c + 1].srcCol;
+
+ if (firstKeepsGroup && secondKeepsGroup) {
+ c += 2;
+ } else if (!_matchGroup || firstKeepsGroup) {
+ c += 1;
+ } else {
return;
+ }
}
}
+
_isSolved = true;
_subState = kPlayWinSound;
}
@@ -475,10 +587,10 @@ void SortPuzzle::redraw() {
}
}
if (!drawn) {
- Common::Rect src = cellSprite(_held);
+ Common::Rect src = g_nancy->getGameType() >= kGameTypeNancy12 ? heldSprite(_held) : cellSprite(_held);
if (!src.isEmpty()) {
- int x = _heldDrawPos.x - _cellWidth / 2;
- int y = _heldDrawPos.y - _cellHeight / 2;
+ int x = _heldDrawPos.x - src.width() / 2;
+ int y = _heldDrawPos.y - src.height() / 2;
_drawSurface.blitFrom(_boardImage, src, Common::Point(x, y));
}
}
diff --git a/engines/nancy/action/puzzle/sortpuzzle.h b/engines/nancy/action/puzzle/sortpuzzle.h
index 42ff9ebe781..db26f5536fa 100644
--- a/engines/nancy/action/puzzle/sortpuzzle.h
+++ b/engines/nancy/action/puzzle/sortpuzzle.h
@@ -63,8 +63,18 @@ protected:
static const int kMaxSourceRows = 8;
static const int kMaxSourceCols = 5;
static const int kNumCursors = 10;
- static const int kNumValueRects = 114; // Nancy 12: per-kind/size source sprites (19 kinds x 6 sizes)
- static const int kSizesPerKind = 6; // Nancy 12: stride between kinds in _valueSrcRects
+
+ // Nancy 12 source sprite table: one sprite per (row, group, value) triple,
+ // followed by kSizesPerKind sprites used for the piece held at the cursor.
+ static const int kSizesPerKind = 6;
+ static const int kGroupsPerRow = 3;
+ static const int kSpriteRows = 6;
+ static const int kNumBoardRects = kSpriteRows * kGroupsPerRow * kSizesPerKind;
+ static const int kNumValueRects = kNumBoardRects + kSizesPerKind;
+
+ // Nancy 12 preset board: a fixed-stride grid of values, -1 marking an empty cell
+ static const int kPresetStride = 10;
+ static const int16 kEmptyCell = -1;
// File data
@@ -77,6 +87,16 @@ protected:
uint16 _groupDivisor = 1;
uint16 _valueRange = 1;
+ // Which of a cell's three identifying fields the win check compares, and
+ // whether two neighboring cells may be sorted the other way around
+ bool _matchRow = true;
+ bool _matchGroup = true;
+ bool _matchValue = true;
+ bool _allowSwappedPairs = false;
+
+ bool _usePresetBoard = false;
+ int16 _presetBoard[kPresetStride * kPresetStride] = {};
+
Common::Rect _cellSrcRects[kMaxSourceRows][kMaxSourceCols];
Common::Rect _cursorSrcRects[kNumCursors];
Common::Array<Common::Rect> _valueSrcRects; // Nancy 12: indexed by gem value
@@ -87,6 +107,8 @@ protected:
uint16 _spacingY = 0;
int16 _cellWidth = 0;
int16 _cellHeight = 0;
+ int16 _hotspotInsetX = 0;
+ int16 _hotspotInsetY = 0;
SoundDescription _pickupSound;
SoundDescription _dropSound;
@@ -127,8 +149,11 @@ protected:
void persistState();
void redraw();
void checkSolved();
+ bool cellsMatch(const Cell &cur, const Cell &sol) const;
Common::Rect cellRect(int row, int col) const;
+ Common::Rect cellHotspot(int row, int col) const;
Common::Rect cellSprite(const Cell &cell) const;
+ Common::Rect heldSprite(const Cell &cell) const;
bool hitTestCell(const Common::Point &p, int &outRow, int &outCol) const;
};
diff --git a/engines/nancy/cursor.cpp b/engines/nancy/cursor.cpp
index 13e8d4fdaa4..d15bf7cfd31 100644
--- a/engines/nancy/cursor.cpp
+++ b/engines/nancy/cursor.cpp
@@ -184,8 +184,10 @@ uint CursorManager::resolveNancy10CursorID(CursorType type, int16 itemID, bool s
case kInvertedRotateRight: return kNewInvertedRotateRight;
case kInvertedRotateLeft: return kNewInvertedRotateLeft;
case kDragHand: return kNewDragHand;
+ case kNewDragHandHotspot: return kNewDragHandHotspot;
case kNewUseHand: return kNewUseHand;
case kNewUseHandHotspot: return kNewUseHandHotspot;
+ case kNewBlank: return kNewBlank;
case kNewRotatePiece: return kNewRotatePiece;
case kPuzzleArrow: return kNewPuzzleArrow;
case kNewPuzzleSlideUp: return kNewPuzzleSlideUp;
diff --git a/engines/nancy/cursor.h b/engines/nancy/cursor.h
index 429ffedd92b..1cb44f99d30 100644
--- a/engines/nancy/cursor.h
+++ b/engines/nancy/cursor.h
@@ -90,9 +90,10 @@ public:
kNewRotateLeft = 30, // Type 15 â 360 scenes
kNewInvertedRotateRight = 32, // Type 16 â Inverted 360 rotation
kNewInvertedRotateLeft = 34, // Type 17 â Inverted 360 rotation
- kNewUseHand = 36, // Type 18 â Hand used while using items, and while carrying a puzzle piece
- kNewUseHandHotspot = 37, // Type 18 hotspot â Hand shown when hovering a piece that can be picked up
- kNewDragHand = 38, // Type 19 â Hand used while dragging puzzle pieces (e.g. SortPuzzle pickup action sets this)
+ kNewUseHand = 36, // Type 18 â Open hand, used while using items and while carrying a puzzle piece
+ kNewUseHandHotspot = 37, // Type 18 hotspot â Open hand shown over a slot a carried piece can be dropped into
+ kNewDragHand = 38, // Type 19 â Closed hand, used while dragging puzzle pieces
+ kNewDragHandHotspot = 39, // Type 19 hotspot â Closed hand shown over a piece that can be picked up
kNewRotatePiece = 40, // Type 20 â Rotate arrows shown over a rotatable puzzle piece
kNewDialCW = 41, // Type 20 hotspot â Dial turn cursors, used by SafeDialPuzzle
kNewDialCCW = 43, // Type 21 hotspot
@@ -102,6 +103,7 @@ public:
kNewPuzzleSlideLeft = 51, // Type 25 hotspot
kNewPuzzleSlideRight = 53, // Type 26 hotspot
kNewDropHand = 64, // Type 32 â Hand shown when a held piece is dropped (briefly set on the drop action)
+ kNewBlank = 72, // Type 36 â Empty sprite, used while a puzzle draws a carried piece at the mouse itself
// Cursor types in Nancy13 and newer games. Nancy13 rebuilt the CURS
// sheet with 45 system cursor types, each stored as an [idle, hotspot]
Commit: 1c8420f6377b8f639d0e46124e40e73ae4ce89b7
https://github.com/scummvm/scummvm/commit/1c8420f6377b8f639d0e46124e40e73ae4ce89b7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:28+03:00
Commit Message:
NANCY: NANCY12: Implement new functionality for OneBuildPuzzle
Fixes issues in Zippy's nuts'n'bolts puzzle
Changed paths:
engines/nancy/action/puzzle/onebuildpuzzle.cpp
engines/nancy/action/puzzle/onebuildpuzzle.h
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.cpp b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
index 13a56f960d9..d6888327e23 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -62,6 +62,22 @@ void OneBuildPuzzle::init() {
_finalAnimOverlay.setVisible(false);
}
+ // A Nancy 12 puzzle can put more pieces on screen than it describes: every
+ // piece past the described ones is a copy of a randomly picked description,
+ // and always starts scattered (see scatterPiece()).
+ if (!_pieces.empty() && _pieces.size() < _totalPieces) {
+ uint numDescribed = _pieces.size();
+ _pieces.resize(_totalPieces);
+
+ for (uint i = numDescribed; i < _totalPieces; ++i) {
+ Piece &p = _pieces[i];
+ const Piece &copied = _pieces[g_nancy->_randomSource->getRandomNumber(numDescribed - 1)];
+ p.srcRect = copied.srcRect;
+ p.altSrcRect = copied.altSrcRect;
+ p.slotRect = copied.slotRect;
+ }
+ }
+
for (uint i = 0; i < _pieces.size(); ++i) {
Piece &p = _pieces[i];
int w = p.srcRect.width();
@@ -114,12 +130,15 @@ void OneBuildPuzzle::init() {
p.gameRect = p.homeRect;
}
- updatePieceRender(i);
p.setVisible(true);
p.setTransparent(true);
p.setZ(_z + (uint16)i + 1);
+ updatePieceRender(i);
}
+ if (_countMode != kCountAllPieces)
+ updateCounter();
+
_isInitialized = true;
}
@@ -132,6 +151,9 @@ void OneBuildPuzzle::registerGraphics() {
if (_hasFinalAnim)
_finalAnimOverlay.registerGraphics();
+
+ if (_countMode != kCountAllPieces)
+ _counterDisplay.registerGraphics();
}
// Nancy12 (AR 166) reworked OneBuildPuzzle onto the shared PuzzleBase loader:
@@ -143,19 +165,39 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
readFilename(stream, _imageName); // 0x00
_freePlacement = stream.readByte(); // 0x21
_canRotateAll = stream.readByte(); // 0x22
- stream.skip(6); // 0x23: rotation/zone config + placement-mode byte
+ stream.skip(6); // 0x23: rotation/zone config
_slotTolerance = stream.readSint16LE(); // 0x29
- // 0x2b..0xe9: placement-mode byte, final-animation centering rect and filler
- // count. None are needed by this port.
- stream.skip(0xea - 0x2b);
+ _placementMode = (PlacementMode)stream.readByte(); // 0x2b
+ _countMode = (CountMode)stream.readByte(); // 0x2c
+ stream.skip(1); // 0x2d: percentage flag
+
+ for (uint i = 0; i < kNumDigits; ++i) // 0x2e: counter digit sprites
+ readRect(stream, _digitSrcRects[i]);
+
+ _counterPos.x = (int16)stream.readSint32LE(); // 0xce
+ _counterPos.y = (int16)stream.readSint32LE(); // 0xd2
+ _counterSpacing = stream.readSint16LE(); // 0xd6
+
+ stream.skip(0xe8 - 0xd8); // 0xd8: final-animation centering rect
+ _requiredPieces = stream.readSint16LE(); // 0xe8
// 0xea: home-scatter zone. Pieces whose stored home rect is empty are
// scattered to a random spot inside this rect at init (see scatterPiece()).
readRect(stream, _scatterZone); // 0xea..0xf9
- // 0xfa..0x11f: misc config, unused by this port.
- stream.skip(0x120 - 0xfa);
+ readRect(stream, _placementZone); // 0xfa: a piece may only be released in here
+ readRect(stream, _exitHotspot); // 0x10a
+
+ // Nancy 13 reuses this record type with a rearranged header, so the bytes
+ // read above aren't rects there. Drop one that can't be a hotspot instead of
+ // handing it to the viewport, which clips (and asserts on) what it is given.
+ if (!_exitHotspot.isValidRect())
+ _exitHotspot = Common::Rect();
+
+ _pieceCursorType = stream.readSint16LE(); // 0x11a
+ _heldPieceCursorType = stream.readSint16LE(); // 0x11c
+ stream.skip(2); // 0x11e: exit cursor, always _puzzleExitCursor
readFilename(stream, _extraSoundName); // 0x120: final-animation atlas image
readRect(stream, _animRectA); // 0x141
@@ -204,7 +246,7 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
_badTexts.resize(3);
// --- Piece array (variable count) ---
- stream.readSint16LE(); // Secondary piece count (matches numPieces in practice)
+ _totalPieces = stream.readUint16LE();
_numPieces = stream.readUint16LE();
_pieces.resize(_numPieces);
@@ -253,6 +295,7 @@ void OneBuildPuzzle::readData(Common::SeekableReadStream &stream) {
readFilename(stream, _imageName);
_numPieces = stream.readUint16LE();
+ _totalPieces = _numPieces;
_freePlacement = stream.readByte();
_canRotateAll = stream.readByte();
stream.skip(6); // rotationMode, zoneHeight, zoneWidth, mouse-clamping flag
@@ -454,7 +497,7 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
// The held fork shows the hotspot hand cursor while over the placement
// region, and the plain magnifying glass everywhere else.
if (_placementZone.isEmpty() || _placementZone.contains(mouseVP))
- setPieceCursor();
+ setPieceCursor(true);
else
g_nancy->_cursor->setCursorType(CursorManager::kNormal);
@@ -509,7 +552,19 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
++_piecesPlaced;
} else {
_correctlyPlaced = false;
- if (!_freePlacement) {
+
+ // In counter mode a slot swallows whatever is dropped into it, so
+ // landing in one that isn't the piece's own costs a mistake. Once
+ // there are more mistakes than the puzzle allows it is lost.
+ if (_placementMode == kPlacementCounter && findSlotAt(piece.gameRect) != -1) {
+ piece.placed = true;
+ ++_mistakes;
+
+ if (_mistakes > _totalPieces - _requiredPieces) {
+ _isCancelled = true;
+ _state = kActionTrigger;
+ }
+ } else if (!_freePlacement) {
piece.gameRect = _prevDragGameRect;
} else {
piece.curRotation = piece.defaultRotation;
@@ -517,6 +572,8 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
}
}
+ updateCounter();
+
// Re-arm at-home art when the piece lands back on homeRect
if (!piece.altSurface.empty() && !piece.placed &&
piece.gameRect == piece.homeRect) {
@@ -552,6 +609,13 @@ void OneBuildPuzzle::handleInput(NancyInput &input) {
Piece &p = _pieces[i];
if (!p.gameRect.contains(mouseVP))
continue;
+
+ // A piece that has dropped into its slot in counter mode is gone for
+ // good, so it doesn't react to the cursor any more. Everywhere else a
+ // placed piece can still be picked back up.
+ if (p.placed && _placementMode == kPlacementCounter)
+ continue;
+
if (topmostAny == -1 || p.getZOrder() > _pieces[topmostAny].getZOrder())
topmostAny = (int16)i;
if (!p.placed) {
@@ -623,16 +687,30 @@ void OneBuildPuzzle::readPlacementTexts(Common::SeekableReadStream &stream, Comm
}
}
-void OneBuildPuzzle::setPieceCursor() {
- if (g_nancy->getGameType() >= kGameTypeNancy10)
+void OneBuildPuzzle::setPieceCursor(bool isHeld) {
+ if (g_nancy->getGameType() >= kGameTypeNancy10) {
+ // Nancy 12 carries a second cursor for a piece that's on the cursor;
+ // the older games use the same one for hovering and carrying.
+ int16 cursorType = (isHeld && _heldPieceCursorType != 0) ? _heldPieceCursorType : _pieceCursorType;
+
// The piece hand uses the hotspot variant (blue hand with an outline).
- g_nancy->_cursor->setCursorType((CursorManager::CursorType)_pieceCursorType, true, true);
- else
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, true);
+ } else {
g_nancy->_cursor->setCursorType(CursorManager::kCustom1);
+ }
}
void OneBuildPuzzle::updatePieceRender(int pieceIdx) {
Piece &p = _pieces[pieceIdx];
+
+ // In counter mode the slot rect is a container the piece is dropped into
+ // (a drawer, in the Nancy 12 nuts and bolts puzzle) and is much larger than
+ // the piece itself, so a placed piece is hidden instead of drawn in it.
+ if (p.placed && _placementMode == kPlacementCounter) {
+ p.setVisible(false);
+ return;
+ }
+
if (p.useAltSurface && !p.altSurface.empty()) {
p._drawSurface.create(p.altSurface, p.altSurface.getBounds());
} else {
@@ -745,20 +823,94 @@ void OneBuildPuzzle::scatterPiece(Piece &p) {
int top = zone.top + (int)g_nancy->_randomSource->getRandomNumber(MAX(0, maxTop - zone.top));
p.gameRect = Common::Rect((int16)left, (int16)top, (int16)(left + w), (int16)(top + h));
+
+ // The scattered spot becomes the piece's home, so a piece dropped away from
+ // its slot returns there instead of to the empty rect it was loaded with.
+ p.homeRect = p.gameRect;
}
-void OneBuildPuzzle::checkAllPlaced() {
+int16 OneBuildPuzzle::findSlotAt(const Common::Rect &rect) const {
for (uint i = 0; i < _pieces.size(); ++i) {
- if (_pieces[i].placed)
- continue;
-
- // Nancy 10: pieces with an empty slotRect (top == 0 && bottom == 0)
- // are filler â they don't need to be placed for the puzzle to solve.
const Common::Rect &slot = _pieces[i].slotRect;
- if (slot.top == 0 && slot.bottom == 0)
+ if (slot.isEmpty())
continue;
+ if (rect.left >= slot.left - _slotTolerance && rect.top >= slot.top - _slotTolerance &&
+ rect.right <= slot.right + _slotTolerance && rect.bottom <= slot.bottom + _slotTolerance)
+ return (int16)i;
+ }
+
+ return -1;
+}
+
+void OneBuildPuzzle::updateCounter() {
+ if (_countMode == kCountAllPieces)
return;
+
+ uint16 value;
+ if (_countMode == kCountPlacements)
+ value = _piecesPlaced;
+ else if (_placementMode == kPlacementCounter)
+ value = _mistakes;
+ else
+ value = _totalPieces - _piecesPlaced;
+
+ Common::String digits = Common::String::format("%u", (uint)value);
+
+ int width = 0;
+ int height = 0;
+ for (uint i = 0; i < digits.size(); ++i) {
+ const Common::Rect &digit = _digitSrcRects[digits[i] - '0'];
+ width += digit.width() + (i ? _counterSpacing : 0);
+ height = MAX<int>(height, digit.height());
+ }
+
+ if (width == 0 || height == 0)
+ return;
+
+ _counterDisplay._drawSurface.create(width, height, _image.format);
+ _counterDisplay.setTransparent(true);
+
+ // Clear to the transparent color first so the gaps between the digits stay
+ // see-through.
+ _counterDisplay._drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+ int destX = 0;
+ for (uint i = 0; i < digits.size(); ++i) {
+ const Common::Rect &digit = _digitSrcRects[digits[i] - '0'];
+ _counterDisplay._drawSurface.blitFrom(_image, digit, Common::Point(destX, 0));
+ destX += digit.width() + _counterSpacing;
+ }
+
+ Common::Rect dest(_counterPos.x, _counterPos.y, _counterPos.x + width, _counterPos.y + height);
+ const VIEW *viewData = GetEngineData(VIEW);
+ if (viewData)
+ dest.translate(viewData->screenPosition.left, viewData->screenPosition.top);
+
+ _counterDisplay.moveTo(dest);
+ _counterDisplay.setVisible(true);
+ _counterDisplay.setNeedsRedraw(true);
+}
+
+void OneBuildPuzzle::checkAllPlaced() {
+ if (_countMode != kCountAllPieces) {
+ // Counter puzzles end as soon as enough pieces have gone into the right
+ // slot, even when a few are still lying around.
+ if (_piecesPlaced < _requiredPieces)
+ return;
+ } else {
+ for (uint i = 0; i < _pieces.size(); ++i) {
+ if (_pieces[i].placed)
+ continue;
+
+ // Nancy 10: pieces with an empty slotRect (top == 0 && bottom == 0)
+ // are filler â they don't need to be placed for the puzzle to solve.
+ const Common::Rect &slot = _pieces[i].slotRect;
+ if (slot.top == 0 && slot.bottom == 0)
+ continue;
+
+ return;
+ }
}
_isSolved = true;
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.h b/engines/nancy/action/puzzle/onebuildpuzzle.h
index 8e8e6b9e31a..592065d6485 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.h
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.h
@@ -35,7 +35,7 @@ namespace Action {
// Otherwise it returns to its previous position (or home in free placement mode).
class OneBuildPuzzle : public RenderActionRecord {
public:
- OneBuildPuzzle() : RenderActionRecord(7), _finalAnimOverlay(99) {}
+ OneBuildPuzzle() : RenderActionRecord(7), _finalAnimOverlay(99), _counterDisplay(99) {}
virtual ~OneBuildPuzzle() {}
void init() override;
@@ -56,6 +56,21 @@ protected:
// pre-placed flag it used to be.
static const uint8 kPrePlacedRotation = 10;
+ enum PlacementMode {
+ kPlacementNormal = 1, // A placed piece stays on screen, drawn in its slot
+ kPlacementCounter = 2 // A placed piece drops out of sight into its slot
+ };
+
+ // What the puzzle counts, both to decide when it is finished and to fill in
+ // the on-screen counter.
+ enum CountMode {
+ kCountAllPieces = 0, // No counter; the puzzle ends once every piece is in its slot
+ kCountPlacements = 1, // Correct placements
+ kCountMistakes = 2 // Mistakes, or pieces left to place outside counter placement
+ };
+
+ static const uint kNumDigits = 10;
+
struct Piece : RenderObject {
Piece() : RenderObject(0) {}
@@ -90,13 +105,25 @@ protected:
// --- File data ---
Common::Path _imageName;
- uint16 _numPieces = 0;
+ uint16 _numPieces = 0; // Number of piece descriptions in the puzzle data
+ uint16 _totalPieces = 0; // Number of pieces on screen; see init() for the extra ones
bool _freePlacement = false; // Wrong drop restores to previous position, not home
bool _canRotateAll = false; // All pieces can be rotated
int16 _slotTolerance = 0; // Proximity for snapping to slot
bool _orderedPlacement = false; // Pieces must be placed in a specific order
+ PlacementMode _placementMode = kPlacementNormal; // Nancy 12 only, earlier games are always normal
Common::Array<int16> _placementOrder; // 1-indexed piece IDs in required placement order
+ // Counter puzzles (Nancy 12): the puzzle is solved once _requiredPieces have
+ // been placed correctly, and lost once the remaining pieces have all been
+ // dropped in the wrong slot. The running count is drawn from digit sprites
+ // found in the puzzle image.
+ CountMode _countMode = kCountAllPieces;
+ int16 _requiredPieces = 0;
+ Common::Rect _digitSrcRects[kNumDigits];
+ Common::Point _counterPos;
+ int16 _counterSpacing = 0;
+
// Stacking order of the pieces that start out already placed, 1-indexed.
// TODO: not applied yet; pre-placed pieces keep their array order.
Common::Array<int16> _preplacedZOrder;
@@ -106,8 +133,10 @@ protected:
// Filename only (no SoundDescription metadata).
Common::String _extraSoundName;
- // Cursor type shown while hovering or carrying a piece (Nancy 10+).
+ // Cursor type shown while hovering a piece (Nancy 10+), and the one shown
+ // while carrying it (Nancy 12+; the older games reuse the hover cursor).
int16 _pieceCursorType = 0;
+ int16 _heldPieceCursorType = 0;
// Post-placement sprite-sheet animation. _animRectA is the on-screen
// rect where the animation plays AND the click hotspot the user must
@@ -184,6 +213,7 @@ protected:
bool _isDropSound = false; // True if last sound played was a drop sound
bool _correctlyPlaced = false; // True if the last drop was correctly placed
uint16 _piecesPlaced = 0; // Number of pieces correctly placed so far
+ uint16 _mistakes = 0; // Number of pieces dropped in the wrong slot
uint32 _timerEnd = 0; // Millisecond timestamp when the current timer expires
bool _finalAnimDone = false;
@@ -194,6 +224,8 @@ protected:
int16 _animFrameCounter = 0; // 0..framesPerStep-1, the X index within the current row.
int16 _animRowCounter = 0; // 0..totalRows-1, how many cycles have completed.
+ RenderObject _counterDisplay; // Digit sprites showing the running count.
+
// Previous drag position (for freePlacement restore on wrong drop)
Common::Rect _prevDragGameRect;
@@ -213,7 +245,7 @@ protected:
// Read a good/bad caption block: three AUTOTEXT keys then three inline
// texts; each caption uses its key if known, else the inline text.
void readPlacementTexts(Common::SeekableReadStream &stream, Common::Array<Common::String> &out);
- void setPieceCursor();
+ void setPieceCursor(bool isHeld = false);
void playPickupSound();
void playRotateSoundAndStartTimer();
@@ -223,6 +255,10 @@ protected:
void checkAllPlaced();
// Place a piece at a random spot inside _scatterZone (Nancy12 empty-home pieces)
void scatterPiece(Piece &p);
+ // Index of the first slot the given rect fits inside, or -1 if it fits none
+ int16 findSlotAt(const Common::Rect &rect) const;
+ // Redraw the counter with the value the puzzle's count mode calls for
+ void updateCounter();
void rotatePiece(int pieceIdx);
void updateDragPosition(Common::Point mouseVP);
// Update the render object for a piece (set _drawSurface and moveTo gameRect)
Commit: 5504142fbd41d8c9d2496270261d1d36d6b2d382
https://github.com/scummvm/scummvm/commit/5504142fbd41d8c9d2496270261d1d36d6b2d382
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:29+03:00
Commit Message:
NANCY: NANCY12: Show correct subtitles for overriden "I can't do that"
Fixes subtitles in the tire changing scene
Changed paths:
engines/nancy/state/scene.cpp
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 408df240032..83b231d2d15 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -529,26 +529,28 @@ void Scene::installInventorySoundOverride(byte command, const SoundDescription &
// Nancy9 and newer no longer store the "can't" caption alongside the sound.
// Instead, the caption is looked up in the CVTX text chunks by the played
// sound's name: the narration/observations (AUTOTEXT) chunk is searched first,
-// then the conversation (CONVO) chunk.
-static Common::String getSoundSubtitle(const Common::String &soundName) {
- if (soundName.empty() || soundName.equalsIgnoreCase("NO SOUND")) {
- return Common::String();
- }
-
- const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
- if (autotext) {
- Common::String text = autotext->texts.getValOrDefault(soundName, "");
- if (!text.empty()) {
- return text;
+// then the conversation (CONVO) chunk. Games that predate the change have no
+// text chunks, so they always end up with the caption stored in the data.
+static Common::String getSoundSubtitle(const Common::String &soundName, const Common::String &fallback) {
+ if (!soundName.empty() && !soundName.equalsIgnoreCase("NO SOUND")) {
+ const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+ if (autotext) {
+ Common::String text = autotext->texts.getValOrDefault(soundName, "");
+ if (!text.empty()) {
+ return text;
+ }
}
- }
- const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
- if (convo) {
- return convo->texts.getValOrDefault(soundName, "");
+ const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
+ if (convo) {
+ Common::String text = convo->texts.getValOrDefault(soundName, "");
+ if (!text.empty()) {
+ return text;
+ }
+ }
}
- return Common::String();
+ return fallback;
}
void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
@@ -575,7 +577,8 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
g_nancy->_sound->playSound(inventoryData->cantSound);
if (ConfMan.getBool("subtitles")) {
- _textbox.addTextLine(inventoryData->cantText, inventoryData->captionAutoClearTime);
+ _textbox.addTextLine(getSoundSubtitle(inventoryData->cantSound.name, inventoryData->cantText),
+ inventoryData->captionAutoClearTime);
}
} else {
// TVD and nancy1 contain no sound data in INV, and have no captions
@@ -591,7 +594,10 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
g_nancy->_sound->playSound(override.sound);
if (ConfMan.getBool("subtitles")) {
- _textbox.addTextLine(override.caption, inventoryData->captionAutoClearTime);
+ // The caption is looked up in the CVTX text chunks by the
+ // override's sound name; the stored caption is the fallback
+ _textbox.addTextLine(getSoundSubtitle(override.sound.name, override.caption),
+ inventoryData->captionAutoClearTime);
}
return;
} else {
@@ -611,12 +617,15 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
g_nancy->_sound->playSound(inventoryData->cantSound);
if (ConfMan.getBool("subtitles")) {
- _textbox.addTextLine(inventoryData->cantText, inventoryData->captionAutoClearTime);
+ _textbox.addTextLine(getSoundSubtitle(inventoryData->cantSound.name, inventoryData->cantText),
+ inventoryData->captionAutoClearTime);
}
} else {
// Should be unreachable
g_nancy->_sound->playSound("CANT");
}
+
+ return;
}
}
@@ -650,10 +659,7 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
// The caption is looked up in the CVTX text chunks by the sound's
// name; the item's own caption field is only a fallback
- cantText = getSoundSubtitle(cantSound.name);
- if (cantText.empty()) {
- cantText = item.cantTexts[soundIndex];
- }
+ cantText = getSoundSubtitle(cantSound.name, item.cantTexts[soundIndex]);
}
g_nancy->_sound->loadSound(cantSound);
@@ -668,7 +674,8 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
g_nancy->_sound->playSound(inventoryData->cantSound);
if (ConfMan.getBool("subtitles")) {
- _textbox.addTextLine(inventoryData->cantText, inventoryData->captionAutoClearTime);
+ _textbox.addTextLine(getSoundSubtitle(inventoryData->cantSound.name, inventoryData->cantText),
+ inventoryData->captionAutoClearTime);
}
} else {
// TVD and nancy1 contain no sound data in INV, and have no captions
Commit: 07d7b5fb668b7cb877ed48e6a9881e258971a3fc
https://github.com/scummvm/scummvm/commit/07d7b5fb668b7cb877ed48e6a9881e258971a3fc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:31+03:00
Commit Message:
NANCY: NANCY12: Fix jitter of last placed piece in SortPuzzle
Changed paths:
engines/nancy/action/puzzle/sortpuzzle.cpp
diff --git a/engines/nancy/action/puzzle/sortpuzzle.cpp b/engines/nancy/action/puzzle/sortpuzzle.cpp
index e5cfcff41fb..ee8e98724bb 100644
--- a/engines/nancy/action/puzzle/sortpuzzle.cpp
+++ b/engines/nancy/action/puzzle/sortpuzzle.cpp
@@ -470,6 +470,10 @@ void SortPuzzle::handleInput(NancyInput &input) {
if (!_hasHeld) {
_held = _current[row][col];
_hasHeld = true;
+ // Anchor the piece to the mouse right away; the move check above only runs
+ // once something is already held, so it would otherwise be drawn for one
+ // frame at the spot where the previous piece was dropped
+ _heldDrawPos = mouseVP;
_current[row][col].isEmpty = true;
if (_pickupSound.name != "NO SOUND") {
g_nancy->_sound->loadSound(_pickupSound);
Commit: 6c3705875aa3fa9879fc210c7f6349cfbb293ba0
https://github.com/scummvm/scummvm/commit/6c3705875aa3fa9879fc210c7f6349cfbb293ba0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:32+03:00
Commit Message:
NANCY: NANCY12: Fix flag triggering in SewingMachinePuzzle
Also, add a cheat, Ctrl-Shift-C, to forgive any mistakes made while
sewing.
Changed paths:
engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
engines/nancy/action/puzzle/sewingmachinepuzzle.h
diff --git a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
index 82b0e26f389..b348c02b413 100644
--- a/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
+++ b/engines/nancy/action/puzzle/sewingmachinepuzzle.cpp
@@ -170,11 +170,11 @@ void SewingMachinePuzzle::feedCloth(const Common::Point &delta) {
drawCloth();
checkSeam();
- // Reaching any bottom trigger finishes the seam.
+ // Reaching a bottom trigger whose mistake-flag gate is satisfied finishes the seam.
if (!_solved) {
Common::Point end = needleInStrip();
for (uint i = 0; i < _triggerZones.size(); ++i) {
- if (_zones[_triggerZones[i]].rect.contains(end)) {
+ if (triggerFires(_zones[_triggerZones[i]], end)) {
_solved = true;
_state = kActionTrigger;
break;
@@ -183,6 +183,17 @@ void SewingMachinePuzzle::feedCloth(const Common::Point &delta) {
}
}
+bool SewingMachinePuzzle::triggerFires(const ActionZone &z, const Common::Point &needle) const {
+ if (!z.rect.contains(needle)) {
+ return false;
+ }
+
+ // Both triggers gate on the mistake flag (val49): the narrow zone fires only on a
+ // clean run (flag clear, val4b 0 -> EV_Solved_Dress), the wide zone only when a
+ // mistake was made (flag set, val4b 1 -> EV_Tried_Dress), so they are exclusive.
+ return z.val49 == kFlagNoLabel || NancySceneState.getEventFlag(z.val49, z.val4b);
+}
+
void SewingMachinePuzzle::checkSeam() {
if (!_hasSeamMask || _collisionZone < 0) {
return;
@@ -288,14 +299,14 @@ void SewingMachinePuzzle::execute() {
case kRun:
break;
case kActionTrigger: {
- // Raise a flag for every bottom trigger the needle finished inside: the wide
- // zone marks the seam attempted, the narrow (centered) zone marks it solved.
+ // Raise the flag for whichever bottom trigger fires: a clean run finishes in the
+ // narrow zone (EV_Solved_Dress), a mistaken one in the wide zone (EV_Tried_Dress).
// They share the win scene, so cross-dissolve to it once.
Common::Point end = needleInStrip();
const ActionZone *sceneZone = nullptr;
for (uint i = 0; i < _triggerZones.size(); ++i) {
const ActionZone &tz = _zones[_triggerZones[i]];
- if (!tz.rect.contains(end)) {
+ if (!triggerFires(tz, end)) {
continue;
}
if (tz.tailId != -1) {
@@ -325,6 +336,19 @@ void SewingMachinePuzzle::handleInput(NancyInput &input) {
return;
}
+ // Cheat: Ctrl+Shift+C forgives any mistakes, clearing the collision flag so a
+ // finished seam still counts as a clean solve. The off-seam latch is left as-is so
+ // clearing while still off the line doesn't instantly re-flag; the next fresh stray
+ // does.
+ for (uint i = 0; i < input.otherKbdInput.size(); ++i) {
+ const Common::KeyState &key = input.otherKbdInput[i];
+ if ((key.flags & Common::KBD_CTRL) && (key.flags & Common::KBD_SHIFT) &&
+ key.keycode == Common::KEYCODE_c && _collisionZone >= 0) {
+ NancySceneState.setEventFlag(_zones[_collisionZone].tailId, g_nancy->_false);
+ debug("Sewing cheat: mistakes cleared");
+ }
+ }
+
g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
if (input.input & NancyInput::kLeftMouseButtonDown) {
diff --git a/engines/nancy/action/puzzle/sewingmachinepuzzle.h b/engines/nancy/action/puzzle/sewingmachinepuzzle.h
index 7fb43e7807a..6885674267c 100644
--- a/engines/nancy/action/puzzle/sewingmachinepuzzle.h
+++ b/engines/nancy/action/puzzle/sewingmachinepuzzle.h
@@ -64,6 +64,9 @@ protected:
// Advances the sewing by a drag delta, marking the puzzle solved once the whole
// seam has been fed through.
void feedCloth(const Common::Point &delta);
+ // Whether a bottom trigger fires: the needle is inside it and its mistake-flag gate
+ // matches (narrow = clean run, wide = a mistake was made).
+ bool triggerFires(const ActionZone &z, const Common::Point &needle) const;
// Tests the needle against the seam mask; leaving the marked corridor plays a
// mistake line and sets the zone's flag (edge-triggered, once per excursion).
Commit: c0477955d0f355951fb6765d13091b6b92326806
https://github.com/scummvm/scummvm/commit/c0477955d0f355951fb6765d13091b6b92326806
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:34+03:00
Commit Message:
NANCY: NANCY12: Set cursor from puzzle data in OrderingPuzzle
Fixes cursor in the diary puzzle
Changed paths:
engines/nancy/action/puzzle/orderingpuzzle.cpp
engines/nancy/action/puzzle/orderingpuzzle.h
diff --git a/engines/nancy/action/puzzle/orderingpuzzle.cpp b/engines/nancy/action/puzzle/orderingpuzzle.cpp
index d130dd186d5..96e094c7425 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/orderingpuzzle.cpp
@@ -247,7 +247,9 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
// codes (the win still uses the sequence read above), then three button
// rect arrays - sprite source rects, on-screen dest positions, and tighter
// per-button hotspots. The dest rects double as the hotspots here.
- stream.skip(0x309 - 0x10c); // grid + per-stage codes (not modeled)
+ _buttonCursorID = stream.readUint16LE();
+ _exitCursorID = stream.readUint16LE();
+ stream.skip(0x309 - 0x110); // grid + per-stage codes (not modeled)
readRectArray(stream, _down1Rects, numElements, 30); // button sprite source rects
readRectArray(stream, _destRects, numElements, 30); // on-screen button positions
stream.skip(30 * 16); // tighter per-button hotspots (we use _destRects)
@@ -713,7 +715,7 @@ void OrderingPuzzle::handleInput(NancyInput &input) {
}
if (NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
- g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
+ setHoverCursor(_exitCursorID, g_nancy->_cursor->_puzzleExitCursor);
if (canClick && input.input & NancyInput::kLeftMouseButtonUp) {
_state = kActionTrigger;
@@ -722,7 +724,7 @@ void OrderingPuzzle::handleInput(NancyInput &input) {
}
if (_needButtonToCheckSuccess && NancySceneState.getViewport().convertViewportToScreen(_checkButtonDest).contains(input.mousePos)) {
- g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+ setHoverCursor(_buttonCursorID, CursorManager::kHotspot);
if (canClick && input.input & NancyInput::kLeftMouseButtonUp) {
_checkButtonPressed = true;
@@ -744,7 +746,7 @@ void OrderingPuzzle::handleInput(NancyInput &input) {
} else if (NancySceneState.getViewport().convertViewportToScreen(_specialCursor2Dest).contains(input.mousePos)) {
g_nancy->_cursor->setCursorType((CursorManager::CursorType)_specialCursor2Id, true);
} else {
- g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+ setHoverCursor(_buttonCursorID, CursorManager::kHotspot);
}
if (canClick && input.input & NancyInput::kLeftMouseButtonUp) {
@@ -818,6 +820,14 @@ Common::String OrderingPuzzle::getRecordTypeName() const {
}
}
+void OrderingPuzzle::setHoverCursor(int16 cursorID, CursorManager::CursorType defaultCursor) {
+ if (cursorID >= 0) {
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorID, true);
+ } else {
+ g_nancy->_cursor->setCursorType(defaultCursor);
+ }
+}
+
void OrderingPuzzle::pushDown(uint id) {
if (g_nancy->getGameType() == kGameTypeVampire) {
g_nancy->_sound->playSound("BUOK");
diff --git a/engines/nancy/action/puzzle/orderingpuzzle.h b/engines/nancy/action/puzzle/orderingpuzzle.h
index c4308327a07..1c1e957f303 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.h
+++ b/engines/nancy/action/puzzle/orderingpuzzle.h
@@ -54,6 +54,7 @@ public:
protected:
Common::String getRecordTypeName() const override;
+ void setHoverCursor(int16 cursorID, CursorManager::CursorType defaultCursor);
void pushDown(uint id);
void setToSecondState(uint id);
void popUp(uint id);
@@ -102,6 +103,12 @@ protected:
SceneChangeWithFlag _deathScene;
bool _stageDeath = false;
+ // Nancy 12 keypads pick their hover cursors from the action record data.
+ // Both are raw Nancy 10+ system cursor types; -1 means the puzzle uses the
+ // engine defaults instead.
+ int16 _buttonCursorID = -1;
+ int16 _exitCursorID = -1;
+
uint16 _specialCursor1Id = CursorManager::kHotspot;
Common::Rect _specialCursor1Dest;
uint16 _specialCursor2Id = CursorManager::kHotspot;
Commit: d9e5cd4e2da059f7f8fede6a6fdd8367a233327e
https://github.com/scummvm/scummvm/commit/d9e5cd4e2da059f7f8fede6a6fdd8367a233327e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:35+03:00
Commit Message:
NANCY: NANCY10: Add correct draw check for the checkbox
Fixes subtitles not being displayed in the Nancy12 ham radio puzzle
Changed paths:
engines/nancy/state/scene.cpp
engines/nancy/ui/textbox.cpp
engines/nancy/ui/textbox.h
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 83b231d2d15..e36efabcc9a 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1875,7 +1875,7 @@ void Scene::clearSceneData(bool nextIsNoArt) {
_lightning->endLightning();
}
- if (_textbox.hasBeenDrawn() || g_nancy->getGameType() >= kGameTypeNancy10) {
+ if (_textbox.hasBeenDrawn()) {
// Improvement: the dog portrait scenes in nancy7 queue a piece of text,
// then immediately change the scene. This makes the text disappear instantly;
// instead, we check if the textbox has been drawn, and don't clear it if it hasn't.
diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index d86f359fa13..ca2eda74480 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -217,6 +217,13 @@ void Textbox::clear() {
}
}
+bool Textbox::hasBeenDrawn() const {
+ if (_scrollTextBox)
+ return !_scrollTextBox->needsRedraw();
+
+ return Misc::HypertextParser::hasBeenDrawn();
+}
+
void Textbox::addTextLine(const Common::String &text, uint32 autoClearTime) {
// WORKAROUND: Don't draw debug strings in the textbox. Refer to bug
// #16745 for a case in Nancy9, scene 2579 (after making a sandwich).
diff --git a/engines/nancy/ui/textbox.h b/engines/nancy/ui/textbox.h
index 83894ba5b02..f1284a097d6 100644
--- a/engines/nancy/ui/textbox.h
+++ b/engines/nancy/ui/textbox.h
@@ -49,6 +49,8 @@ public:
void drawTextbox();
void clear() override;
+ bool hasBeenDrawn() const;
+
void addTextLine(const Common::String &text, uint32 autoClearTime = 0);
void setOverrideFont(const uint fontID);
Commit: 6aa8fa9b6f9e5a7ae670cc355af1b727ff75c995
https://github.com/scummvm/scummvm/commit/6aa8fa9b6f9e5a7ae670cc355af1b727ff75c995
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-07T06:06:37+03:00
Commit Message:
NANCY: NANCY12: Implement new solve state handling in TwoDialPuzzle
Fixes handling of the ham radio puzzle
Changed paths:
engines/nancy/action/puzzle/twodialpuzzle.cpp
engines/nancy/action/puzzle/twodialpuzzle.h
diff --git a/engines/nancy/action/puzzle/twodialpuzzle.cpp b/engines/nancy/action/puzzle/twodialpuzzle.cpp
index 79b8668ee0b..b1746447623 100644
--- a/engines/nancy/action/puzzle/twodialpuzzle.cpp
+++ b/engines/nancy/action/puzzle/twodialpuzzle.cpp
@@ -133,28 +133,16 @@ void TwoDialPuzzle::execute() {
_state = kRun;
// fall through
case kRun:
+ if (g_nancy->getGameType() >= kGameTypeNancy12) {
+ runNancy12();
+ break;
+ }
+
if (g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) || g_nancy->_sound->isSoundPlaying(_rotateSounds[1])) {
return;
}
- if (g_nancy->getGameType() >= kGameTypeNancy12) {
- // A combo solves only while one of its solutions is active: the dial
- // positions match and that solution's condition flag is currently set.
- // The matched solution supplies the scene to change to.
- for (uint i = 0; i < _solutions.size(); ++i) {
- const DialSolution &sol = _solutions[i];
- if (sol.sceneID != kNoScene &&
- _currentPositions[0] == sol.positions[0] &&
- _currentPositions[1] == sol.positions[1] &&
- NancySceneState.getEventFlag(sol.condition.label, sol.condition.flag)) {
- _solveScene._sceneChange.sceneID = sol.sceneID;
- _state = kActionTrigger;
- _isSolved = true;
- _solveSoundDelayTime = g_nancy->getTotalPlayTime() + (_solveSoundDelay * 1000);
- break;
- }
- }
- } else if ((uint)_currentPositions[0] == _correctPositions[0] && (uint)_currentPositions[1] == _correctPositions[1]) {
+ if ((uint)_currentPositions[0] == _correctPositions[0] && (uint)_currentPositions[1] == _correctPositions[1]) {
_state = kActionTrigger;
_isSolved = true;
_solveSoundDelayTime = g_nancy->getTotalPlayTime() + (_solveSoundDelay * 1000);
@@ -163,7 +151,11 @@ void TwoDialPuzzle::execute() {
break;
case kActionTrigger:
if (_isSolved) {
- if (_solveSoundDelayTime != 0) {
+ if (g_nancy->getGameType() >= kGameTypeNancy12) {
+ // The solve sound has already played out during the run phase, and
+ // the matched solution has supplied the scene to change to
+ _solveScene.execute();
+ } else if (_solveSoundDelayTime != 0) {
if (g_nancy->getTotalPlayTime() < _solveSoundDelayTime) {
return;
}
@@ -191,8 +183,67 @@ void TwoDialPuzzle::execute() {
}
}
+void TwoDialPuzzle::runNancy12() {
+ switch (_solveState) {
+ case kCheckSolutions: {
+ // A combo solves only while one of its solutions is active: the dial
+ // positions match and that solution's condition flag is currently set
+ int16 matched = -1;
+ for (uint i = 0; i < _solutions.size(); ++i) {
+ const DialSolution &sol = _solutions[i];
+ if (sol.sceneID != kNoScene &&
+ _currentPositions[0] == sol.positions[0] &&
+ _currentPositions[1] == sol.positions[1] &&
+ NancySceneState.getEventFlag(sol.condition.label, sol.condition.flag)) {
+ matched = i;
+ break;
+ }
+ }
+
+ if (matched == -1) {
+ _lastMatchedSolution = -1;
+ break;
+ }
+
+ if (matched != _lastMatchedSolution) {
+ // The dials only just landed on this solution; it counts once they
+ // have rested on it for solveSoundDelay milliseconds
+ _lastMatchedSolution = matched;
+ _solveSoundDelayTime = g_nancy->getTotalPlayTime() + _solveSoundDelay;
+ break;
+ }
+
+ if (g_nancy->getTotalPlayTime() > _solveSoundDelayTime) {
+ _solveScene._sceneChange.sceneID = _solutions[matched].sceneID;
+ _isSolved = true;
+ _solveState = kPlaySolveSound;
+ }
+
+ break;
+ }
+ case kPlaySolveSound:
+ g_nancy->_sound->loadSound(_solveSound);
+ g_nancy->_sound->playSound(_solveSound);
+ _solveState = kWaitForSounds;
+ break;
+ case kWaitForSounds:
+ if (_isSolved) {
+ if (!g_nancy->_sound->isSoundPlaying(_solveSound)) {
+ g_nancy->_sound->stopSound(_solveSound);
+ _state = kActionTrigger;
+ }
+ } else if (!g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) &&
+ !g_nancy->_sound->isSoundPlaying(_rotateSounds[1])) {
+ _solveState = kCheckSolutions;
+ }
+
+ break;
+ }
+}
+
void TwoDialPuzzle::handleInput(NancyInput &input) {
- bool canClick = (_state == kRun) && !g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) && !g_nancy->_sound->isSoundPlaying(_rotateSounds[1]);
+ bool canClick = (_state == kRun) && !_isSolved &&
+ !g_nancy->_sound->isSoundPlaying(_rotateSounds[0]) && !g_nancy->_sound->isSoundPlaying(_rotateSounds[1]);
if (NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
g_nancy->_cursor->setCursorType(g_nancy->_cursor->_puzzleExitCursor);
@@ -218,6 +269,12 @@ void TwoDialPuzzle::handleInput(NancyInput &input) {
}
g_nancy->_sound->playSound(_rotateSounds[i]);
+
+ if (g_nancy->getGameType() >= kGameTypeNancy12) {
+ // A dial in motion suspends the solution check until it stops
+ _solveState = kWaitForSounds;
+ }
+
_drawSurface.fillRect(_dests[0].findIntersectingRect(_dests[1]), _drawSurface.getTransparentColor());
// Blit both dials just in case
diff --git a/engines/nancy/action/puzzle/twodialpuzzle.h b/engines/nancy/action/puzzle/twodialpuzzle.h
index ce2948812df..083cc78f95a 100644
--- a/engines/nancy/action/puzzle/twodialpuzzle.h
+++ b/engines/nancy/action/puzzle/twodialpuzzle.h
@@ -45,6 +45,8 @@ public:
protected:
Common::String getRecordTypeName() const override { return "TwoDialPuzzle"; }
+ void runNancy12();
+
Common::Path _imageName;
bool _isClockwise[2] = { false, false };
@@ -66,6 +68,14 @@ protected:
};
Common::Array<DialSolution> _solutions;
+ // Nancy 12+ splits the run phase into three steps: the dials must rest on a
+ // matching solution long enough for it to count, then the solve sound plays
+ // out before the record finishes. Turning a dial also drops back into
+ // kWaitForSounds, which suspends the check until the dial stops rattling.
+ enum SolveState { kCheckSolutions, kPlaySolveSound, kWaitForSounds };
+ SolveState _solveState = kCheckSolutions;
+ int16 _lastMatchedSolution = -1;
+
SoundDescription _rotateSounds[2];
SceneChangeWithFlag _solveScene;
More information about the Scummvm-git-logs
mailing list