[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