[Scummvm-git-logs] scummvm master -> b15a28f1b6916ddc7cdb770b29982bd62b94733b

bluegr noreply at scummvm.org
Thu Jul 9 19:54:13 UTC 2026


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

Summary:
cd874d2e7f NANCY: NANCY12: Implement DrivingPuzzle
b15a28f1b6 NANCY: NANCY12: Add scatter piece functionality in OneBuildPuzzle


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

Commit Message:
NANCY: NANCY12: Implement DrivingPuzzle

Real-time top-down driving minigames, with two modes:
- Driving: drive Nancy's car around the Titusville town map, entering
  locations by driving into them
- Chase: chasing another car

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


diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 324571c365b..f062244c793 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -428,6 +428,35 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
 			break;
 		}
 		case DependencyType::kElapsedPlayerDay:
+			if (g_nancy->getGameType() >= kGameTypeNancy12) {
+				// Nancy12 repurposed dependency type 10 as a resource check (e.g. the
+				// car's gas gauge): resource value vs. threshold, by condition modifier.
+				int32 resVal = NancySceneState.getUIResource(dep.label);
+				int32 threshold = dep.milliseconds;
+				switch (dep.condition) {
+				case 0:	// equal
+					dep.satisfied = (resVal == threshold);
+					break;
+				case 1:	// resource greater than the threshold
+					dep.satisfied = (resVal > threshold);
+					break;
+				case 2:	// resource greater than or equal to the threshold
+					dep.satisfied = (resVal >= threshold);
+					break;
+				case 3:	// resource less than the threshold
+					dep.satisfied = (resVal < threshold);
+					break;
+				case 4:	// resource less than or equal to the threshold
+					dep.satisfied = (resVal <= threshold);
+					break;
+				default:
+					dep.satisfied = false;
+					break;
+				}
+
+				break;
+			}
+
 			if (record._days == -1) {
 				record._days = NancySceneState.getPlayerTime().getDays();
 				dep.satisfied = true;
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 785b0b2b59b..34d0726dff1 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -45,6 +45,7 @@
 #include "engines/nancy/action/puzzle/cubepuzzle.h"
 #include "engines/nancy/action/puzzle/cuttingpuzzle.h"
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
+#include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/gridmappuzzle.h"
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
 #include "engines/nancy/action/puzzle/hamradiopuzzle.h"
@@ -446,9 +447,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 160:
 		// In Nancy12 the hint system was removed (the HINT boot chunk is gone) and this
 		// slot was reused for a new driving/racing puzzle.
-		// TODO: Nancy12 DrivingPuzzle (new), not implemented
 		if (g_nancy->getGameType() >= kGameTypeNancy12)
-			return nullptr;
+			return new DrivingPuzzle(DrivingPuzzle::kDriving);
 		return new HintSystem();
 	case 161:
 		// PlaySoundEventFlagTerse moved to 149 in Nancy12; this slot was reused for a new puzzle.
@@ -468,8 +468,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		// OneBuildPuzzle, moved here from 234 in Nancy12
 		return new OneBuildPuzzle();
 	case 167:
-		// TODO: Nancy12 ChasePuzzle (new), not implemented
-		return nullptr;
+		return new DrivingPuzzle(DrivingPuzzle::kChase);
 	case 168:
 		return new Set3DSoundListenerPosition();
 	// -- Nancy 13 new/relocated puzzles (types 169-176) --
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.cpp b/engines/nancy/action/puzzle/drivingpuzzle.cpp
new file mode 100644
index 00000000000..e45fdfd6362
--- /dev/null
+++ b/engines/nancy/action/puzzle/drivingpuzzle.cpp
@@ -0,0 +1,427 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/system.h"
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/drivingpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+void DrivingPuzzle::readFrameRects(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &out) {
+	int16 count = stream.readSint16LE();
+	if (count <= 0) {
+		return;
+	}
+
+	out.resize(count);
+	for (int i = 0; i < count; ++i) {
+		readRect(stream, out[i]);
+	}
+}
+
+void DrivingPuzzle::readWaypoints(Common::SeekableReadStream &stream, Common::Array<Waypoint> &out) {
+	int16 count = stream.readSint16LE();
+	if (count <= 0) {
+		return;
+	}
+
+	out.resize(count);
+	for (int i = 0; i < count; ++i) {
+		out[i].timeMs = stream.readUint32LE();
+		out[i].x = stream.readSint16LE();
+		out[i].y = stream.readSint16LE();
+		out[i].heading = stream.readDoubleLE();
+	}
+}
+
+void DrivingPuzzle::readBlob(Common::SeekableReadStream &stream) {
+	// 130-byte PuzzleBase header blob: three 33-byte filenames followed by the car's
+	// physics parameters (two short padding gaps separate some of the tail fields).
+	readFilename(stream, _imageName);		// blob 0x00: visible town map
+	readFilename(stream, _collisionName);	// blob 0x21: collision mask
+	readFilename(stream, _carSpriteName);	// blob 0x42: car rotation atlas
+
+	_startX = stream.readSint32LE();		// 0x63
+	_startY = stream.readSint32LE();		// 0x67
+	_startAngle = stream.readSint32LE();	// 0x6b
+	_forwardSpeed = stream.readSint32LE();	// 0x6f
+	_reverseSpeed = stream.readSint32LE();	// 0x73
+	_frictionIndex = stream.readSint16LE();	// 0x77
+	stream.skip(2);
+	_distanceDivisor = stream.readSint32LE();	// 0x7b
+	_retainState = stream.readByte() != 0;		// 0x7f
+	stream.skip(2);
+}
+
+void DrivingPuzzle::readData(Common::SeekableReadStream &stream) {
+	// 160 (kDriving) / 167 (kChase): a 130-byte PuzzleBase header blob followed by
+	// three random-sound blocks and a rotation-frame rect table.
+	readBlob(stream);
+
+	for (int i = 0; i < 3; ++i) {
+		_soundBlocks[i].readData(stream);
+	}
+
+	readFrameRects(stream, _frameRects);
+
+	if (_variant == kDriving) {
+		readActionZoneArray(stream, _zones);
+		return;
+	}
+
+	// 167 (kChase): five id/scene values, a second (chaser) car sprite name and a
+	// second rotation-frame table, then two ActionZone arrays and two recorded
+	// chaser paths.
+	for (int i = 0; i < 5; ++i) {
+		_chaseParams[i] = stream.readSint16LE();
+	}
+
+	readFilename(stream, _chaseCarImageName);
+	readFrameRects(stream, _frameRects2);
+
+	readActionZoneArray(stream, _zones);
+	readActionZoneArray(stream, _zones2);
+
+	readWaypoints(stream, _chaserPathA);
+	readWaypoints(stream, _chaserPathB);
+}
+
+void DrivingPuzzle::classifyZones(const Common::Array<ActionZone> &zones) {
+	// The car often spawns already sitting inside its starting location's zone, so
+	// zones are edge-triggered: a zone only fires once the car has left it and driven
+	// back in. Seed each zone's "inside" state from the spawn point.
+	Common::Point spawn(_startX, _startY);
+
+	for (uint i = 0; i < zones.size(); ++i) {
+		const ActionZone &z = zones[i];
+		switch (z.type) {
+		case 0x11:		// location entrance
+		case 0x0c: {	// chase finish line
+			DestinationZone dest;
+			dest.rect = z.rect;
+			dest.scene.sceneID = z.specialEffectId;
+			if (z.hasSpecialEffect) {
+				dest.hasFade = true;
+				dest.fadeType = z.seType;
+				dest.fadeTotalTime = z.seTotalTime;
+				dest.fadeToBlackTime = z.seFadeToBlackTime;
+				dest.fadeRect = z.seRect;
+			}
+			if (z.type == 0x0c) {
+				dest.eventFlag = z.tailId;
+				dest.eventFlagValue = z.tailFlag;
+			}
+			dest.carInside = dest.rect.contains(spawn);
+			_destinations.push_back(dest);
+			break;
+		}
+		case 0x0b: {	// checkpoint: sets an event flag once driven over
+			Checkpoint cp;
+			cp.rect = z.rect;
+			cp.flagId = z.tailId;
+			cp.flagValue = z.tailFlag;
+			cp.carInside = cp.rect.contains(spawn);
+			_checkpoints.push_back(cp);
+			break;
+		}
+		case 0x14:	// play-area boundary
+			_boundaries.push_back(z.rect);
+			break;
+		default:
+			// The remaining subtypes (overlays, driving hazards, terrain markers) are
+			// not simulated yet.
+			break;
+		}
+	}
+}
+
+void DrivingPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	if (block.names.empty()) {
+		return;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return;
+	}
+
+	SoundDescription desc;
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+}
+
+void DrivingPuzzle::init() {
+	Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+	_drawSurface.create(vpBounds.width(), vpBounds.height(),
+		g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	setTransparent(true);
+	setVisible(true);
+	moveTo(vpBounds);
+
+	g_nancy->_resource->loadImage(_imageName, _image);
+
+	g_nancy->_resource->loadImage(_carSpriteName, _carImage);
+	_carImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+	if (_variant == kChase && !_chaseCarImageName.empty()) {
+		g_nancy->_resource->loadImage(_chaseCarImageName, _chaseCarImage);
+		_chaseCarImage.setTransparentColor(_drawSurface.getTransparentColor());
+	}
+
+	// Seed the car from the physics parameters decoded from the header blob.
+	_carX = _startX;
+	_carY = _startY;
+	_carHeading = (double)_startAngle * (M_PI / 180.0);
+	_carVelocity = 0.0;
+	_speedCap = _forwardSpeed;
+
+	// Seed the chaser at the start of its recorded path.
+	if (!_chaserPathA.empty()) {
+		_chaserX = _chaserPathA[0].x;
+		_chaserY = _chaserPathA[0].y;
+		_chaserHeading = _chaserPathA[0].heading;
+	}
+}
+
+uint DrivingPuzzle::frameIndexForHeading(double heading, uint frameCount) const {
+	if (frameCount == 0) {
+		return 0;
+	}
+
+	// Quantize the heading evenly across the atlas frames (approximate).
+	while (heading < 0.0) {
+		heading += 2.0 * M_PI;
+	}
+	while (heading >= 2.0 * M_PI) {
+		heading -= 2.0 * M_PI;
+	}
+
+	uint idx = (uint)(heading / (2.0 * M_PI) * frameCount + 0.5);
+	return idx % frameCount;
+}
+
+void DrivingPuzzle::drawScene() {
+	int w = _drawSurface.w;
+	int h = _drawSurface.h;
+
+	// Car-centered camera, clamped to the map bounds.
+	int camX = CLIP<int>((int)(_carX + 0.5) - w / 2, 0, MAX(0, _image.w - w));
+	int camY = CLIP<int>((int)(_carY + 0.5) - h / 2, 0, MAX(0, _image.h - h));
+
+	_drawSurface.blitFrom(_image, Common::Rect(camX, camY, camX + w, camY + h), Common::Point(0, 0));
+
+	// The chaser car (kChase), drawn under the player car.
+	if (_variant == kChase && !_frameRects2.empty() && _chaseCarImage.w > 0) {
+		const Common::Rect &src = _frameRects2[frameIndexForHeading(_chaserHeading, _frameRects2.size())];
+		int sx = (int)(_chaserX + 0.5) - camX - src.width() / 2;
+		int sy = (int)(_chaserY + 0.5) - camY - src.height() / 2;
+		_drawSurface.blitFrom(_chaseCarImage, src, Common::Point(sx, sy));
+	}
+
+	// The player car sprite frame, centered on its on-screen position.
+	if (!_frameRects.empty()) {
+		const Common::Rect &src = _frameRects[frameIndexForHeading(_carHeading, _frameRects.size())];
+		int sx = (int)(_carX + 0.5) - camX - src.width() / 2;
+		int sy = (int)(_carY + 0.5) - camY - src.height() / 2;
+		_drawSurface.blitFrom(_carImage, src, Common::Point(sx, sy));
+	}
+
+	_needsRedraw = true;
+}
+
+void DrivingPuzzle::updateChaser() {
+	if (_chaserPathA.empty()) {
+		return;
+	}
+
+	if (!_chaseStarted) {
+		_chaseStartTime = g_system->getMillis();
+		_chaseStarted = true;
+		_chaserWaypoint = 0;
+	}
+
+	// Play the recorded path back in real time: advance to the last waypoint whose
+	// timestamp the elapsed chase time has passed.
+	uint32 elapsed = g_system->getMillis() - _chaseStartTime;
+	while (_chaserWaypoint + 1 < _chaserPathA.size() && _chaserPathA[_chaserWaypoint].timeMs < elapsed) {
+		++_chaserWaypoint;
+	}
+
+	const Waypoint &wp = _chaserPathA[_chaserWaypoint];
+	_chaserX = wp.x;
+	_chaserY = wp.y;
+	_chaserHeading = wp.heading;
+
+	// The closer the chaser is, the lower the player's speed cap (it bottoms out at a
+	// full stop once the chaser is right on top of the car).
+	double dx = _carX - _chaserX;
+	double dy = _carY - _chaserY;
+	double dist = sqrt(dx * dx + dy * dy);
+	const double slowRadius = 60.0;
+	const double slowSlope = 5.0;
+	_speedCap = dist >= slowRadius ? (double)_forwardSpeed : (double)_forwardSpeed - (slowRadius - dist) * slowSlope;
+
+	// TODO: switch onto the second path (_chaserPathB), the event-flag outcomes
+	// (_chaseParams) and the "chaser left the viewport" loss branch are not simulated.
+}
+
+// Per-frame car physics. The acceleration divisors (1.0/0.4) and 0.02 timestep are
+// exact; the steering rate and velocity decay are playability stand-ins.
+void DrivingPuzzle::updatePhysics(const NancyInput &input) {
+	const double timeStep = 0.02;
+	const double steerRate = 0.05;
+	const double decay = 0.98;
+	const double forwardCap = MAX(0.0, _speedCap);
+
+	if (input.input & NancyInput::kMoveLeft) {
+		_carHeading += steerRate;
+	}
+	if (input.input & NancyInput::kMoveRight) {
+		_carHeading -= steerRate;
+	}
+	if (_carHeading < 0.0) {
+		_carHeading += 2.0 * M_PI;
+	} else if (_carHeading >= 2.0 * M_PI) {
+		_carHeading -= 2.0 * M_PI;
+	}
+
+	if (input.input & NancyInput::kMoveUp) {
+		_carVelocity += (forwardCap / 1.0) * timeStep;
+	} else if (input.input & NancyInput::kMoveDown) {
+		_carVelocity -= ((double)_forwardSpeed / 0.4) * timeStep;
+	} else {
+		_carVelocity *= decay;
+	}
+	_carVelocity = CLIP<double>(_carVelocity, -(double)_reverseSpeed, forwardCap);
+
+	double newX = _carX + cos(_carHeading) * _carVelocity * timeStep;
+	double newY = _carY - sin(_carHeading) * _carVelocity * timeStep;
+
+	// Keep the car on the map and out of the boundary zones (coarse rect test, no mask).
+	newX = CLIP<double>(newX, 0.0, MAX(0, _image.w - 1));
+	newY = CLIP<double>(newY, 0.0, MAX(0, _image.h - 1));
+
+	Common::Point next((int)(newX + 0.5), (int)(newY + 0.5));
+	for (uint i = 0; i < _boundaries.size(); ++i) {
+		if (_boundaries[i].contains(next)) {
+			_carVelocity = 0.0;
+			return;
+		}
+	}
+
+	_carX = newX;
+	_carY = newY;
+
+	// TODO: burn fuel here - decrement the gas-gauge UI resource (index _frictionIndex)
+	// by distance/_distanceDivisor and stop the car at 0. Deferred: the resource is an
+	// integer, so the rate needs runtime tuning to avoid corrupting the saved value.
+
+	// Driving into a checkpoint (on entry) sets its event flag once.
+	for (uint i = 0; i < _checkpoints.size(); ++i) {
+		Checkpoint &cp = _checkpoints[i];
+		bool nowInside = cp.rect.contains(next);
+		if (nowInside && !cp.carInside && !cp.triggered && cp.flagId != -1) {
+			cp.triggered = true;
+			NancySceneState.setEventFlag(cp.flagId, cp.flagValue ? g_nancy->_true : g_nancy->_false);
+		}
+		cp.carInside = nowInside;
+	}
+
+	// Driving into a location entrance / finish line (on entry) transitions there. The
+	// zone the car spawned inside does not fire until the car leaves and re-enters.
+	for (uint i = 0; i < _destinations.size(); ++i) {
+		DestinationZone &dest = _destinations[i];
+		bool nowInside = dest.rect.contains(next);
+		if (nowInside && !dest.carInside && _triggeredDest < 0 && dest.scene.sceneID != kNoScene) {
+			_triggeredDest = (int)i;
+			_state = kActionTrigger;
+		}
+		dest.carInside = nowInside;
+	}
+}
+
+void DrivingPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		registerGraphics();
+		classifyZones(_zones);
+		if (_variant == kChase) {
+			classifyZones(_zones2);
+		}
+		playSoundBlock(_soundBlocks[2]);	// looping engine ambience
+		drawScene();
+		_state = kRun;
+		break;
+	case kRun:
+		break;
+	case kActionTrigger:
+		if (_triggeredDest >= 0 && _triggeredDest < (int)_destinations.size()) {
+			const DestinationZone &dest = _destinations[_triggeredDest];
+			if (dest.eventFlag != -1) {
+				NancySceneState.setEventFlag(dest.eventFlag, dest.eventFlagValue ? g_nancy->_true : g_nancy->_false);
+			}
+			if (dest.hasFade) {
+				NancySceneState.specialEffect(dest.fadeType, dest.fadeTotalTime, dest.fadeToBlackTime, dest.fadeRect);
+			}
+			NancySceneState.changeScene(dest.scene);
+		}
+		finishExecution();
+		break;
+	}
+}
+
+void DrivingPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun) {
+		return;
+	}
+
+	// Drive continuously so momentum, steering and the chaser animate every frame.
+	if (_variant == kChase) {
+		updateChaser();
+	}
+
+	updatePhysics(input);
+
+	if (_state == kRun) {
+		drawScene();
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.h b/engines/nancy/action/puzzle/drivingpuzzle.h
new file mode 100644
index 00000000000..074514128ba
--- /dev/null
+++ b/engines/nancy/action/puzzle/drivingpuzzle.h
@@ -0,0 +1,201 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_ACTION_DRIVINGPUZZLE_H
+#define NANCY_ACTION_DRIVINGPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/navigationrecords.h"
+#include "engines/nancy/action/actionzone.h"
+
+namespace Nancy {
+namespace Action {
+
+// Real-time top-down driving minigames introduced in Nancy12. Two closely related
+// action records share the same engine:
+//   160 - kDriving  (drive Nancy's car around the Titusville town map, entering
+//                    locations by driving into them)
+//   167 - kChase    (kDriving plus a second, chaser car, a second zone array and
+//                    two path-point arrays that steer the chaser)
+//
+// The map scrolls under a car-centered camera; the car is drawn as a rotation-atlas
+// sprite whose frame is chosen from its heading. The map is populated with an
+// ActionZone array: type 0x11 zones are location entrances (each carries the
+// destination scene id and the transition effect), type 0x14 zones are boundaries,
+// and the remaining subtypes are decorations and driving hazards.
+//
+// TODO (need runtime tuning):
+//  - Fuel: burn the gas-gauge UI resource (index _frictionIndex) while driving; it
+//    currently stays at its seeded value. The DT_RESOURCE dependency that reads it is
+//    handled in ActionManager::processDependency.
+//  - Steering: approximated with the arrow keys (the game uses click-to-steer).
+//  - Collision: only the type 0x14 boundary rects block the car (no per-pixel mask).
+//  - kChase: no second-path switch or "chaser left the viewport" loss branch.
+//  - Overlay / hazard zone subtypes (0x0d/0x0e/0x0f/0x05/0x03/0x17) are ignored.
+class DrivingPuzzle : public RenderActionRecord {
+public:
+	enum Variant { kDriving = 0, kChase };
+
+	DrivingPuzzle(Variant variant) : RenderActionRecord(7), _variant(variant) {}
+	virtual ~DrivingPuzzle() {}
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override {
+		return _variant == kChase ? "ChasePuzzle" : "DrivingPuzzle";
+	}
+
+	// A destination the car can drive into: a location entrance (type 0x11) or the
+	// chase's finish line (type 0x0c). Entering its map-space rect optionally sets an
+	// event flag and transitions to the destination scene through a fade.
+	struct DestinationZone {
+		Common::Rect rect;
+		SceneChangeDescription scene;
+		bool hasFade = false;
+		byte fadeType = 0;
+		uint16 fadeTotalTime = 0;
+		uint16 fadeToBlackTime = 0;
+		Common::Rect fadeRect;
+		int16 eventFlag = -1;
+		byte eventFlagValue = 0;
+		bool carInside = false;		// the car was inside this zone last frame
+	};
+
+	// A checkpoint (type 0x0b): driving over it sets an event flag once.
+	struct Checkpoint {
+		Common::Rect rect;
+		int16 flagId = -1;
+		byte flagValue = 0;
+		bool triggered = false;
+		bool carInside = false;		// the car was inside this zone last frame
+	};
+
+	// A recorded chaser-path waypoint (kChase): the pursuer plays these back in real
+	// time, jumping to the entry whose timestamp the elapsed chase time has passed.
+	struct Waypoint {
+		uint32 timeMs = 0;
+		int16 x = 0;
+		int16 y = 0;
+		double heading = 0.0;	// radians
+	};
+
+	// Reads an int16-prefixed array of Rects (a rotation-frame table).
+	void readFrameRects(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &out);
+
+	// Reads an int16-prefixed array of chaser-path waypoints (16 bytes each).
+	void readWaypoints(Common::SeekableReadStream &stream, Common::Array<Waypoint> &out);
+
+	// Reads the 130-byte PuzzleBase header blob: three filenames (map image, collision
+	// map, car sprite atlas) and the car's physics parameters.
+	void readBlob(Common::SeekableReadStream &stream);
+
+	// Sorts an ActionZone array into its gameplay roles (destination, checkpoint and
+	// boundary zones), decoding the destination scenes and their transition effects.
+	void classifyZones(const Common::Array<ActionZone> &zones);
+
+	// Plays one (randomly chosen) entry of a random-sound block.
+	void playSoundBlock(const RandomSoundBlock &block);
+
+	// Advances the car's heading/velocity/position for one frame from the current
+	// movement input.
+	void updatePhysics(const NancyInput &input);
+
+	// Advances the chaser along its recorded path (kChase) and slows the player's
+	// speed cap the closer the chaser gets.
+	void updateChaser();
+
+	// Chooses a rotation-atlas frame from a heading.
+	uint frameIndexForHeading(double heading, uint frameCount) const;
+
+	// Redraws the scrolling map (car-centered camera) and the car sprite(s) on top.
+	void drawScene();
+
+	Variant _variant;
+
+	// Three filenames decoded from the header blob.
+	Common::Path _imageName;			// visible town map ("MAP_Titusville")
+	Common::Path _collisionName;		// collision mask ("MAP_TitusvilleCollision")
+	Common::Path _carSpriteName;		// car rotation atlas ("MAP_Roadster_OVL")
+
+	// Car physics parameters decoded from the header blob.
+	int32 _startX = 0;			// blob+0x63: start position (map space)
+	int32 _startY = 0;			// blob+0x67
+	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
+	int32 _distanceDivisor = 0;	// blob+0x7b
+	bool _retainState = false;	// blob+0x7f: resume from the saved position
+
+	// Three random-sound blocks (tire blowout, horn, engine) and a rotation-frame
+	// rect table precede the ActionZone array.
+	RandomSoundBlock _soundBlocks[3];
+	Common::Array<Common::Rect> _frameRects;
+	Common::Array<ActionZone> _zones;
+
+	// kChase (167) extras: five id/scene values, a second (chaser) car sprite
+	// name, a second rotation-frame table, a second ActionZone array and two
+	// recorded chaser paths (a main route and a shorter one).
+	int16 _chaseParams[5] = {};
+	Common::Path _chaseCarImageName;
+	Common::Array<Common::Rect> _frameRects2;
+	Common::Array<ActionZone> _zones2;
+	Common::Array<Waypoint> _chaserPathA;
+	Common::Array<Waypoint> _chaserPathB;
+
+	// ActionZone gameplay roles.
+	Common::Array<DestinationZone> _destinations;	// types 0x11 / 0x0c
+	Common::Array<Checkpoint> _checkpoints;			// type 0x0b
+	Common::Array<Common::Rect> _boundaries;		// type 0x14
+
+	// Runtime state
+	double _carX = 0.0;			// current car position (map space)
+	double _carY = 0.0;
+	double _carHeading = 0.0;	// radians
+	double _carVelocity = 0.0;
+	double _speedCap = 0.0;		// current forward speed cap (lowered as the chaser closes in)
+	int _triggeredDest = -1;	// destination zone the car has entered (-1 == none)
+
+	// Chaser (kChase) runtime state.
+	bool _chaseStarted = false;
+	uint32 _chaseStartTime = 0;
+	uint _chaserWaypoint = 0;
+	double _chaserX = 0.0;
+	double _chaserY = 0.0;
+	double _chaserHeading = 0.0;
+
+	Graphics::ManagedSurface _image;			// the town map
+	Graphics::ManagedSurface _carImage;			// the player car rotation atlas
+	Graphics::ManagedSurface _chaseCarImage;	// the chaser car rotation atlas
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_DRIVINGPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 451fc52218d..7791e4cbba2 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -29,6 +29,7 @@ MODULE_OBJS = \
   action/puzzle/cubepuzzle.o \
   action/puzzle/cuttingpuzzle.o \
   action/puzzle/dotconnectpuzzle.o \
+  action/puzzle/drivingpuzzle.o \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/leverpuzzle.o \


Commit: b15a28f1b6916ddc7cdb770b29982bd62b94733b
    https://github.com/scummvm/scummvm/commit/b15a28f1b6916ddc7cdb770b29982bd62b94733b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-09T22:53:12+03:00

Commit Message:
NANCY: NANCY12: Add scatter piece functionality in OneBuildPuzzle

Fixes the pieces in the nuts and 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 618c3fe777a..f5dcfddccdf 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.cpp
@@ -101,8 +101,16 @@ void OneBuildPuzzle::init() {
 		} else {
 			// Normal pieces start at home with defaultRotation
 			p.curRotation = p.defaultRotation;
-			p.gameRect = p.homeRect;
 			p.placed = false;
+
+			// Nancy12 puzzles may ship pieces with an empty home rect
+			// (top == bottom), which means "start scattered": the original
+			// init picks a random spot inside the home-scatter zone. Without
+			// this, such pieces get a zero-height rect and are invisible.
+			if (g_nancy->getGameType() >= kGameTypeNancy12 && p.homeRect.top == p.homeRect.bottom)
+				scatterPiece(p);
+			else
+				p.gameRect = p.homeRect;
 		}
 
 		updatePieceRender(i);
@@ -137,9 +145,16 @@ void OneBuildPuzzle::readDataNancy12(Common::SeekableReadStream &stream) {
 	stream.skip(6);                         // 0x23: rotation/zone config + placement-mode byte
 	_slotTolerance = stream.readSint16LE(); // 0x29
 
-	// 0x2b..0x11f: placement-mode byte, final-animation centering rect, filler
-	// count and the home-scatter zone. None are needed by this port.
-	stream.skip(0x120 - 0x2b);
+	// 0x2b..0xe9: placement-mode byte, final-animation centering rect and filler
+	// count. None are needed by this port.
+	stream.skip(0xea - 0x2b);
+
+	// 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);
 
 	readFilename(stream, _extraSoundName);  // 0x120: final-animation atlas image
 	readRect(stream, _animRectA);           // 0x141
@@ -678,6 +693,30 @@ void OneBuildPuzzle::clampRectToViewport(Common::Rect &rect) {
 	}
 }
 
+void OneBuildPuzzle::scatterPiece(Piece &p) {
+	// Piece display size at its starting rotation. Fall back to rotation 0 when
+	// the rotated surface wasn't generated (non-rotatable pieces).
+	int rot = p.hasSurface[p.curRotation] ? p.curRotation : 0;
+	int w = p.rotateSurfaces[rot].w;
+	int h = p.rotateSurfaces[rot].h;
+
+	// The scatter zone comes from the puzzle data; if it's degenerate the
+	// original engine falls back to the full viewport (as kBegin does).
+	Common::Rect zone = _scatterZone;
+	if (zone.isEmpty()) {
+		const VIEW *viewData = GetEngineData(VIEW);
+		if (viewData)
+			zone = Common::Rect(viewData->screenPosition.width(), viewData->screenPosition.height());
+	}
+
+	int maxLeft = MAX<int>(zone.left, zone.right - w);
+	int maxTop  = MAX<int>(zone.top, zone.bottom - h);
+	int left = zone.left + (int)g_nancy->_randomSource->getRandomNumber(MAX(0, maxLeft - zone.left));
+	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));
+}
+
 void OneBuildPuzzle::checkAllPlaced() {
 	for (uint i = 0; i < _pieces.size(); ++i) {
 		if (_pieces[i].placed)
diff --git a/engines/nancy/action/puzzle/onebuildpuzzle.h b/engines/nancy/action/puzzle/onebuildpuzzle.h
index 6eb05f42df3..29d8cbe2903 100644
--- a/engines/nancy/action/puzzle/onebuildpuzzle.h
+++ b/engines/nancy/action/puzzle/onebuildpuzzle.h
@@ -109,6 +109,9 @@ protected:
 	SoundDescription _animSound2;
 	bool _hasFinalAnim = false;   // true when _animRectA is non-empty
 
+	// Nancy12: pieces with an empty home rect start scattered inside this zone.
+	Common::Rect _scatterZone;
+
 	Common::Array<Piece> _pieces;
 
 	// Nancy12 stores the puzzle's sounds as this many random-sound blocks, in
@@ -200,6 +203,8 @@ protected:
 	void playGoodPlacementSound();
 	void playBadPlacementSound();
 	void checkAllPlaced();
+	// Place a piece at a random spot inside _scatterZone (Nancy12 empty-home pieces)
+	void scatterPiece(Piece &p);
 	void rotatePiece(int pieceIdx);
 	void updateDragPosition(Common::Point mouseVP);
 	// Update the render object for a piece (set _drawSurface and moveTo gameRect)




More information about the Scummvm-git-logs mailing list