[Scummvm-git-logs] scummvm master -> 1807329275bf6f6d5dfaa9fdc34c9830e17e959d
bluegr
noreply at scummvm.org
Tue Sep 1 05:38:40 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:
37caa7b292 NANCY: NANCY14: Use the correct cursor variant in puzzles
c473d30af4 NANCY: NANCY14: Play secondary movies correctly in panorama scenes
69d9dea238 NANCY: NANCY14: Draw the brush correctly in the PaintPuzzle
8255622eee NANCY: Add video names to debug info for secondary movies and videos
8a7ef2dd5a NANCY: NANCY14: Implement RolloverOverlay (AR 53)
aa8a45bc4d NANCY: NANCY13: Fix winning PachinkoPuzzle
7cdb76f1ec NANCY: NANCY13: Implement differences in PlaySecondaryMovie ARs 41, 46
e5ebcdc2f2 NANCY: NANCY14: Implement DecoderPuzzle (AR 182)
8eeca4f053 NANCY: NANCY14: Implement MeterPuzzle
1807329275 NANCY: NANCY14: Add PuzzleData records for DecoderPuzzle
Commit: 37caa7b292ced7e05646094ea2004bdf231656a7
https://github.com/scummvm/scummvm/commit/37caa7b292ced7e05646094ea2004bdf231656a7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:12+03:00
Commit Message:
NANCY: NANCY14: Use the correct cursor variant in puzzles
Changed paths:
engines/nancy/action/puzzle/adjustpuzzle.cpp
engines/nancy/action/puzzle/adjustpuzzle.h
engines/nancy/action/puzzle/hangmanpuzzle.cpp
engines/nancy/action/puzzle/hangmanpuzzle.h
diff --git a/engines/nancy/action/puzzle/adjustpuzzle.cpp b/engines/nancy/action/puzzle/adjustpuzzle.cpp
index 7280b91cb50..23752fcc137 100644
--- a/engines/nancy/action/puzzle/adjustpuzzle.cpp
+++ b/engines/nancy/action/puzzle/adjustpuzzle.cpp
@@ -37,7 +37,7 @@ namespace Action {
void AdjustPuzzle::readData(Common::SeekableReadStream &stream) {
readFilename(stream, _imageName); // 0x1c0
- _field3d = stream.readSint16LE(); // 0x3d
+ _testCursorType = stream.readUint16LE(); // 0x3d
int16 numPieces = stream.readSint16LE();
_pieces.resize(numPieces); // 0x3f
@@ -66,7 +66,7 @@ void AdjustPuzzle::readData(Common::SeekableReadStream &stream) {
_adjustSound.readData(stream); // 0x12f
}
- _field18a = stream.readSint16LE(); // 0x18a
+ _animCursorType = stream.readUint16LE(); // 0x18a
int16 numOverlays = stream.readSint16LE();
_overlayNames.resize(numOverlays); // 0x18c
@@ -273,7 +273,7 @@ void AdjustPuzzle::handleInput(NancyInput &input) {
// The "test" control: evaluate the current settings.
if (!_rectB5.isEmpty() &&
NancySceneState.getViewport().convertViewportToScreen(_rectB5).contains(input.mousePos)) {
- g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)_testCursorType, true);
if (click) {
runTest();
}
diff --git a/engines/nancy/action/puzzle/adjustpuzzle.h b/engines/nancy/action/puzzle/adjustpuzzle.h
index 7a486f270b9..4c1f59edc62 100644
--- a/engines/nancy/action/puzzle/adjustpuzzle.h
+++ b/engines/nancy/action/puzzle/adjustpuzzle.h
@@ -50,7 +50,7 @@ public:
protected:
Common::String getRecordTypeName() const override { return "AdjustPuzzle"; }
- // One adjustable element. rects[0]/rects[2] are the decrement/increment
+ // One adjustable element. rects[1]/rects[3] are the decrement/increment
// hotspots; subRects are the per-state display frames (blitted at boundRect).
struct Piece {
Common::Rect rects[4];
@@ -82,7 +82,7 @@ protected:
// -- File data --
Common::Path _imageName; // 0x1c0
- int16 _field3d = 0; // 0x3d
+ uint16 _testCursorType = 0; // 0x3d - raw Nancy14 cursor type, shown over the test control
Common::Array<Piece> _pieces; // 0x3f
Common::Rect _rectA5; // 0xa5
@@ -96,7 +96,7 @@ protected:
Common::Rect _adjustRect; // 0x11f
RandomSoundBlock _adjustSound; // 0x12f (present only when _adjustName is set)
- int16 _field18a = 0; // 0x18a
+ uint16 _animCursorType = 0; // 0x18a - cursor for the plotter animation, unused here
Common::Array<Common::Path> _overlayNames; // 0x18c, result overlays
Common::Array<MatrixRow> _matrix; // 0x1b0
diff --git a/engines/nancy/action/puzzle/hangmanpuzzle.cpp b/engines/nancy/action/puzzle/hangmanpuzzle.cpp
index dada33264a1..580f53e7a0c 100644
--- a/engines/nancy/action/puzzle/hangmanpuzzle.cpp
+++ b/engines/nancy/action/puzzle/hangmanpuzzle.cpp
@@ -39,7 +39,7 @@ namespace Action {
void HangmanPuzzle::readData(Common::SeekableReadStream &stream) {
readFilename(stream, _puzzleImageName); // 0x3d
readFilename(stream, _lettersImageName); // 0x41
- _field45 = stream.readSint16LE(); // 0x45
+ _hoverCursorType = stream.readUint16LE(); // 0x45
int16 numWords = stream.readSint16LE();
_words.resize(numWords);
@@ -305,8 +305,7 @@ void HangmanPuzzle::handleInput(NancyInput &input) {
int tile = tileAtCursor(input.mousePos);
if (tile >= 0 && !_letters[tile].used) {
- // Clickable-hotspot cursor for puzzles (the blue pointing hand).
- g_nancy->_cursor->setCursorType(CursorManager::kPuzzleArrow);
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
if (input.input & NancyInput::kLeftMouseButtonUp) {
commitGuess((uint)tile);
}
diff --git a/engines/nancy/action/puzzle/hangmanpuzzle.h b/engines/nancy/action/puzzle/hangmanpuzzle.h
index 34632972fb8..a2d6bdd787f 100644
--- a/engines/nancy/action/puzzle/hangmanpuzzle.h
+++ b/engines/nancy/action/puzzle/hangmanpuzzle.h
@@ -86,7 +86,7 @@ protected:
// -- File data --
Common::Path _puzzleImageName; // 0x3d
Common::Path _lettersImageName; // 0x41
- int16 _field45 = 0; // 0x45
+ uint16 _hoverCursorType = 0; // 0x45 - raw Nancy14 cursor type, shown over a letter tile
Common::Array<Common::String> _words; // candidate word bank
Common::Array<Common::Rect> _hangPieceRects; // 0x57, hang-stage pieces
Commit: c473d30af4ebf25230373e7563cccd15ef2bd4fd
https://github.com/scummvm/scummvm/commit/c473d30af4ebf25230373e7563cccd15ef2bd4fd
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:16+03:00
Commit Message:
NANCY: NANCY14: Play secondary movies correctly in panorama scenes
Fixes Heather's animation staying stuck at the beginning
Changed paths:
engines/nancy/action/secondarymovie.cpp
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 1406e5deb9f..7dc72fb281d 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -771,9 +771,9 @@ void PlaySecondaryMovie::execute() {
_hotspot = _screenPosition;
_hasHotspot = true;
}
- } else if (_isRandom) {
- // Random movies aren't gated on hotspot/viewport-frame
- // matches the way regular PSMs are: play full viewport.
+ } else if (_isRandom && _videoDescs.empty()) {
+ // A random movie with no descriptors isn't tied to a specific
+ // background frame: play it across the full viewport.
_screenPosition = NancySceneState.getViewport().getBounds();
setVisible(true);
_hasHotspot = false;
@@ -804,7 +804,7 @@ void PlaySecondaryMovie::execute() {
(_decoder.needsUpdate() ? _decoder.decodeNextFrame() : nullptr);
if (decodedFrame) {
- uint descID = 0;
+ int descID = -1;
for (uint i = 0; i < _videoDescs.size(); ++i) {
if (_videoDescs[i].frameID == _curViewportFrame) {
@@ -815,12 +815,12 @@ void PlaySecondaryMovie::execute() {
GraphicsManager::copyToManaged(*decodedFrame, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
// Nancy14 stores an all -1 srcRect to mean "use the whole frame".
- Common::Rect srcRect = _videoDescs[descID].srcRect;
+ Common::Rect srcRect = descID != -1 ? _videoDescs[descID].srcRect : Common::Rect();
if (srcRect.isEmpty()) {
srcRect = Common::Rect(_fullFrame.w, _fullFrame.h);
}
- Common::Rect destRect = _videoDescs[descID].destRect;
+ Common::Rect destRect = descID != -1 ? _videoDescs[descID].destRect : _screenPosition;
// The videoDesc's size might be larger than the decoded video (for example, nancy10's
// COR_AceFidgetEars_ANIM, and nancy12's PAR_ArcadeAnimationB); clamp here to avoid
Commit: 69d9dea2382b03821e6688b641bbd579804dccc0
https://github.com/scummvm/scummvm/commit/69d9dea2382b03821e6688b641bbd579804dccc0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:18+03:00
Commit Message:
NANCY: NANCY14: Draw the brush correctly in the PaintPuzzle
Changed paths:
engines/nancy/action/puzzle/paintpuzzle.cpp
engines/nancy/action/puzzle/paintpuzzle.h
diff --git a/engines/nancy/action/puzzle/paintpuzzle.cpp b/engines/nancy/action/puzzle/paintpuzzle.cpp
index 583f7b3b0a4..f961f4e65f4 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.cpp
+++ b/engines/nancy/action/puzzle/paintpuzzle.cpp
@@ -35,7 +35,7 @@ namespace Action {
void PaintPuzzle::readData(Common::SeekableReadStream &stream) {
readFilename(stream, _imageName); // 0x3d
- _field5e = stream.readSint16LE(); // 0x5e
+ _hoverCursorType = stream.readUint16LE(); // 0x5e
_offset.x = stream.readSint32LE(); // 0x60
_offset.y = stream.readSint32LE(); // 0x64
readRect(stream, _canvasRect); // 0x68
@@ -197,6 +197,62 @@ void PaintPuzzle::drawRegion(uint regionIndex) {
}
}
+// Draws the held color's brush sprite at the cursor. The cursor is blanked while a
+// color is held, so the brush is what the player sees moving.
+void PaintPuzzle::drawBrush() {
+ if (_heldColor < 0 || _heldColor >= (int)_colors.size() || _image.w == 0) {
+ return;
+ }
+
+ const Common::Rect &src = _colors[_heldColor].fillRect;
+ if (src.isEmpty()) {
+ return;
+ }
+
+ byte tr, tg, tb;
+ g_nancy->_graphics->getInputPixelFormat().colorToRGB(g_nancy->_graphics->getTransColor(), tr, tg, tb);
+
+ // The sprite is centered on the cursor, then shifted by the offset.
+ const Common::Point dest(_brushPos.x - src.width() / 2 - _offset.x,
+ _brushPos.y - src.height() / 2 - _offset.y);
+
+ for (int y = 0; y < src.height() && src.top + y < _image.h; ++y) {
+ const int dy = dest.y + y;
+ if (dy < 0 || dy >= _drawSurface.h) {
+ continue;
+ }
+
+ for (int x = 0; x < src.width() && src.left + x < _image.w; ++x) {
+ const int dx = dest.x + x;
+ if (dx < 0 || dx >= _drawSurface.w) {
+ continue;
+ }
+
+ byte a, r, g, b;
+ _image.format.colorToARGB(_image.getPixel(src.left + x, src.top + y), a, r, g, b);
+ if (a == 0 || (r == tr && g == tg && b == tb)) {
+ continue;
+ }
+
+ if (a != 255) {
+ // Composite the anti-aliased edges over the painted regions below,
+ // instead of replacing their paint with semi-transparent pixels.
+ byte da, dr, dg, db;
+ _drawSurface.format.colorToARGB(_drawSurface.getPixel(dx, dy), da, dr, dg, db);
+
+ uint destAlpha = da * (255 - a) / 255;
+ uint outAlpha = a + destAlpha;
+ r = (r * a + dr * destAlpha) / outAlpha;
+ g = (g * a + dg * destAlpha) / outAlpha;
+ b = (b * a + db * destAlpha) / outAlpha;
+ a = outAlpha;
+ }
+
+ _drawSurface.setPixel(dx, dy, _drawSurface.format.ARGBToColor(a, r, g, b));
+ }
+ }
+}
+
void PaintPuzzle::redraw() {
_drawSurface.clear(0);
@@ -208,6 +264,8 @@ void PaintPuzzle::redraw() {
}
}
+ drawBrush();
+
_needsRedraw = true;
}
@@ -243,6 +301,20 @@ void PaintPuzzle::handleInput(NancyInput &input) {
return;
}
+ // A held color turns the cursor into its brush sprite, which the puzzle draws itself.
+ if (_heldColor >= 0) {
+ Common::Rect screenPt(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1);
+ Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
+ Common::Point brushPos(vpPt.left, vpPt.top);
+
+ if (brushPos != _brushPos) {
+ _brushPos = brushPos;
+ redraw();
+ }
+
+ g_nancy->_cursor->setCursorType(CursorManager::kNancy13Blank, true, false);
+ }
+
// Give-up hotspot: leave the puzzle.
if (!_exitHotspot.isEmpty() &&
NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
@@ -256,18 +328,16 @@ void PaintPuzzle::handleInput(NancyInput &input) {
int color = colorSwatchAtCursor(input.mousePos);
if (color >= 0) {
- // Over a color swatch: show the blue puzzle-hotspot hand and pick the
- // color on click.
- g_nancy->_cursor->setCursorType(CursorManager::kPuzzleArrow);
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
if (input.input & NancyInput::kLeftMouseButtonUp) {
_heldColor = color;
+ redraw();
}
input.eatMouseInput();
return;
}
- // Over a paintable region with a color picked: paint it on click. The
- // cursor is left as the held paintbrush item.
+ // Over a paintable region with a color picked: paint it on click.
int region = regionAtCursor(input.mousePos);
if (region >= 0 && _heldColor >= 0) {
if (input.input & NancyInput::kLeftMouseButtonUp) {
diff --git a/engines/nancy/action/puzzle/paintpuzzle.h b/engines/nancy/action/puzzle/paintpuzzle.h
index 6a272db9464..0cce3fd9f17 100644
--- a/engines/nancy/action/puzzle/paintpuzzle.h
+++ b/engines/nancy/action/puzzle/paintpuzzle.h
@@ -79,14 +79,15 @@ protected:
byte shapeAlpha(const Graphics::ManagedSurface &img, int x, int y) const;
void paintRegion(uint regionIndex, int colorIndex);
void drawRegion(uint regionIndex);
+ void drawBrush();
void redraw();
bool isSolved() const;
void applyOutcome(const SceneOutcome &outcome);
// -- File data --
Common::Path _imageName; // 0x3d
- int16 _field5e = 0; // 0x5e
- Common::Point _offset; // 0x60 (two int32)
+ uint16 _hoverCursorType = 0; // 0x5e - raw Nancy14 cursor type, shown over a color swatch
+ Common::Point _offset; // 0x60 (two int32) - brush sprite offset from the cursor
Common::Rect _canvasRect; // 0x68
Common::Array<PaintColor> _colors; // 0x78
@@ -107,6 +108,7 @@ protected:
Graphics::ManagedSurface _image;
Common::Array<Graphics::ManagedSurface> _regionImages;
int _heldColor = -1;
+ Common::Point _brushPos;
int _hoverRegion = -1;
int _hoverColor = -1;
bool _solved = false;
Commit: 8255622eee37a6d39d5f23676767c7697d01b961
https://github.com/scummvm/scummvm/commit/8255622eee37a6d39d5f23676767c7697d01b961
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:19+03:00
Commit Message:
NANCY: Add video names to debug info for secondary movies and videos
Changed paths:
engines/nancy/action/secondarymovie.h
engines/nancy/action/secondaryvideo.h
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index a711b717f27..3bab9d33ff7 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -200,7 +200,9 @@ public:
CursorManager::CursorType getHoverCursor() const override;
bool cursorSetFromScript() const override { return _isRandom && _talkSceneID != kNoScene; }
- Common::String getRecordExtraInfo() const override { return Common::String::format("Scene %d", _sceneChange.sceneID); }
+ Common::String getRecordExtraInfo() const override {
+ return Common::String::format("Scene %d, file %s", _sceneChange.sceneID, _videoName.baseName().c_str());
+ }
protected:
Common::String getRecordTypeName() const override {
diff --git a/engines/nancy/action/secondaryvideo.h b/engines/nancy/action/secondaryvideo.h
index 77a9c187c85..0457c1aaaa0 100644
--- a/engines/nancy/action/secondaryvideo.h
+++ b/engines/nancy/action/secondaryvideo.h
@@ -90,7 +90,9 @@ public:
return g_nancy->getGameType() >= kGameTypeNancy10 ? CursorManager::kHotspotTalk : CursorManager::kHotspot;
}
- Common::String getRecordExtraInfo() const override { return Common::String::format("Scene %d", _sceneChange.sceneID); }
+ Common::String getRecordExtraInfo() const override {
+ return Common::String::format("Scene %d, file %s", _sceneChange.sceneID, _filename.baseName().c_str());
+ }
protected:
Common::String getRecordTypeName() const override { return "PlaySecondaryVideo"; }
Commit: 8a7ef2dd5a4fe976d17c6ae749cb5b913ff5955a
https://github.com/scummvm/scummvm/commit/8a7ef2dd5a4fe976d17c6ae749cb5b913ff5955a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:20+03:00
Commit Message:
NANCY: NANCY14: Implement RolloverOverlay (AR 53)
This is a rollover label: an image that is only drawn while the mouse
is inside its hotspot. Entering the hotspot plays a sound and sets an
event flag, and clicking it plays a second sound before changing the
scene.
Fixes reading the manual on Nancy's desk when starting a new game
Changed paths:
engines/nancy/action/arfactory.cpp
engines/nancy/action/overlay.cpp
engines/nancy/action/overlay.h
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index f744bec24c2..60fbf64a745 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -230,7 +230,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
case 52:
return new PlaySecondaryVideo();
case 53:
- return new PlaySecondaryMovie();
+ if (g_nancy->getGameType() >= kGameTypeNancy14)
+ return new RolloverOverlay();
+ else
+ return new PlaySecondaryMovie();
case 54:
if (g_nancy->getGameType() <= kGameTypeNancy1)
return new Overlay(false); // PlayStaticBitmapAnimation
diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 11fae30bb30..c2d1d809ec5 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -31,6 +31,7 @@
#include "engines/nancy/state/scene.h"
+#include "common/random.h"
#include "common/serializer.h"
#include "graphics/font.h"
@@ -558,5 +559,125 @@ void TextLineOverlay::execute() {
_isDone = true;
}
+void RolloverOverlay::init() {
+ g_nancy->_resource->loadImage(_imageName, _fullSurface);
+
+ RenderObject::init();
+}
+
+void RolloverOverlay::readData(Common::SeekableReadStream &stream) {
+ readFilename(stream, _imageName);
+ _transparency = stream.readUint16LE();
+ _z = stream.readUint16LE();
+ _hoverCursor = stream.readUint16LE();
+
+ readRect(stream, _hotspotRect);
+ readRect(stream, _srcRect);
+ readRect(stream, _destRect);
+
+ _flagOnHover.label = stream.readSint16LE();
+ _flagOnHover.flag = stream.readByte();
+ stream.skip(1);
+
+ _hoverSound.readData(stream);
+ _hoverSoundOnce = stream.readUint16LE();
+
+ _sceneChange.sceneID = stream.readUint16LE();
+ _sceneChange.frameID = stream.readUint16LE();
+ int16 verticalOffset = stream.readSint16LE();
+ _sceneChange.verticalOffset = verticalOffset >= 0 ? verticalOffset : 0;
+
+ _sceneChange.continueSceneSound = stream.readByte();
+
+ _clickSound.readData(stream);
+}
+
+void RolloverOverlay::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 RolloverOverlay::handleInput(NancyInput &input) {
+ if (_state != kRun) {
+ return;
+ }
+
+ bool hovered = NancySceneState.getViewport().convertViewportToScreen(_hotspot).contains(input.mousePos);
+ if (hovered == _isHovered) {
+ return;
+ }
+
+ _isHovered = hovered;
+ setVisible(hovered);
+
+ if (!hovered) {
+ return;
+ }
+
+ if (_hoverSoundOnce == 0 || !_hoverSoundPlayed) {
+ playSoundBlock(_hoverSound);
+ _hoverSoundPlayed = true;
+ }
+
+ NancySceneState.setEventFlag(_flagOnHover);
+}
+
+void RolloverOverlay::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+
+ _drawSurface.create(_fullSurface, _srcRect);
+ setTransparent(_transparency >= kPlayOverlayTransparent);
+ moveTo(_destRect);
+ setVisible(false);
+ registerGraphics();
+
+ _hotspot = _hotspotRect;
+ _hasHotspot = true;
+
+ _state = kRun;
+ break;
+ case kRun:
+ // Visibility follows the mouse, see handleInput()
+ break;
+ case kActionTrigger:
+ if (!_clickSoundStarted) {
+ playSoundBlock(_clickSound);
+ _clickSoundStarted = true;
+ }
+
+ if (!_clickSound.names.empty() && g_nancy->_sound->isSoundPlaying((uint16)_clickSound.channel)) {
+ return;
+ }
+
+ setVisible(false);
+ _hasHotspot = false;
+
+ if (_sceneChange.sceneID != kNoScene) {
+ NancySceneState.changeScene(_sceneChange);
+ }
+
+ finishExecution();
+ break;
+ }
+}
+
} // End of namespace Action
} // End of namespace Nancy
diff --git a/engines/nancy/action/overlay.h b/engines/nancy/action/overlay.h
index 7ae0724c569..88f9f145ab9 100644
--- a/engines/nancy/action/overlay.h
+++ b/engines/nancy/action/overlay.h
@@ -156,6 +156,52 @@ protected:
int16 _tableIndex = 0;
};
+// Nancy14 AR 53. A rollover label: an image that is only drawn while the mouse
+// is inside its hotspot. Entering the hotspot plays a sound and sets an event
+// flag, and clicking it plays a second sound before changing the scene.
+class RolloverOverlay : public RenderActionRecord {
+public:
+ RolloverOverlay() : RenderActionRecord(7) {}
+ virtual ~RolloverOverlay() { _fullSurface.free(); }
+
+ void init() override;
+ void readData(Common::SeekableReadStream &stream) override;
+ void execute() override;
+ void handleInput(NancyInput &input) override;
+
+ bool isViewportRelative() const override { return true; }
+ bool canHaveHotspot() const override { return true; }
+ CursorManager::CursorType getHoverCursor() const override { return (CursorManager::CursorType)_hoverCursor; }
+ bool cursorSetFromScript() const override { return true; }
+ Common::String getRecordExtraInfo() const override { return Common::String::format("Scene %d", _sceneChange.sceneID); }
+
+protected:
+ Common::String getRecordTypeName() const override { return "RolloverOverlay"; }
+
+ void playSoundBlock(const RandomSoundBlock &block);
+
+ Common::Path _imageName;
+ uint16 _transparency = kPlayOverlayPlain;
+ uint16 _hoverCursor = 0;
+ Common::Rect _hotspotRect;
+ Common::Rect _srcRect;
+ Common::Rect _destRect;
+ // Set every time the mouse enters the hotspot
+ FlagDescription _flagOnHover;
+ // When nonzero the hover sound is only played the first time; otherwise it
+ // plays on every hover
+ uint16 _hoverSoundOnce = 0;
+ RandomSoundBlock _hoverSound;
+ SceneChangeDescription _sceneChange;
+ RandomSoundBlock _clickSound;
+
+ bool _isHovered = false;
+ bool _hoverSoundPlayed = false;
+ bool _clickSoundStarted = false;
+
+ Graphics::ManagedSurface _fullSurface;
+};
+
} // End of namespace Action
} // End of namespace Nancy
Commit: aa8a45bc4d6512ca00cb21e9e3c77150fb0131e7
https://github.com/scummvm/scummvm/commit/aa8a45bc4d6512ca00cb21e9e3c77150fb0131e7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:21+03:00
Commit Message:
NANCY: NANCY13: Fix winning PachinkoPuzzle
- Handle win scene and flags
- Add a cheat - Ctrl+Shift+P pushes the prospector one catch further up
the mountain, gaining ground on the Yeti
- Cleanup comments
- Remove unused flag
Changed paths:
engines/nancy/action/puzzle/pachinkopuzzle.cpp
engines/nancy/action/puzzle/pachinkopuzzle.h
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
index 98ebbe51700..974bcde66a4 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.cpp
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -40,17 +40,13 @@ static const double kGravity = 50.0; // downward accel (px/s^2)
static const double kRestitution = 0.85; // bounce energy retained
static const double kTwoPi = 6.283185307179586;
static const double kDeg2Rad = 0.017453292519943295;
-// Launch-param jitter: progress*0.95 + (rand%100)*0.002; launch-Y jitter: (rand%100)*0.01.
static const double kSpawnParamMax = 0.95;
static const double kSpawnParamJitter = 0.002;
static const double kSpawnYJitter = 0.01;
-// Per-ball deceleration is not in the chunk (it lives in a runtime-built physics body), so
-// a small constant drag is used here as an approximation.
static const double kDrag = 12.0; // px/s per second
-// Collision geometry. Pins are stored as 1x1 points on a staggered ~30px grid; on screen
-// they are small round bumpers, so the hit distance matches the drawn pin + ball radii (a
-// ball may still thread the wider gaps, which is fine - the stagger catches it a row later).
+// Pins are stored as 1x1 points on a staggered ~30px grid but drawn as small round bumpers,
+// so they collide as circles at the drawn pin + ball radii.
static const double kPinRadius = 4.0;
static const double kBallRadius = 5.0;
static const int kPhysicsSubsteps = 4; // per frame, to avoid tunnelling the pins
@@ -58,8 +54,7 @@ static const int kPhysicsSubsteps = 4; // per frame, to avoid tunnelling the p
static const uint32 kHoleLitMs = 400; // how long a hole stays lit after a catch
// A climber reaches the pot once its accumulated steps hit this goal. moverSpeed per catch
-// is 2 (Miner) / 3 (Yeti), so the Yeti climbs faster - matching the game's "the Yeti
-// usually wins". The exact goal is not recovered from the chunk; this is a tuned value.
+// is 2 (Miner) / 3 (Yeti), so the Yeti climbs faster.
static const int kClimbGoal = 30;
static double wrapAngle(double a) {
@@ -73,9 +68,8 @@ static double wrapAngle(double a) {
}
void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
- readFilename(stream, _imageName); // 0x00 - board overlay
+ readFilename(stream, _imageName); // board overlay
- // 167-byte header blob.
readRect(stream, _ballSrc); // ball sprite source
readRect(stream, _ballEntry); // top-right entry chute
_velMin = stream.readSint32LE(); // launch-speed floor
@@ -110,12 +104,10 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
}
}
- // The polymorphic Nancy13 ActionZone array (bumpers / walls / overlays). The shared
- // ActionZone reader handles the Nancy13 layout when told to.
+ // The bumpers / walls / overlays, in the Nancy13 zone layout.
readActionZoneArray(stream, _zones, true);
- // The base trailer: a count-prefixed array of 23-byte hotspot records. The sample
- // carries the single "give up / exit" hotspot.
+ // The base trailer's hotspot records; the first is the give-up exit.
int16 numExit = stream.readSint16LE();
for (int16 i = 0; i < numExit; ++i) {
Common::Rect r;
@@ -129,7 +121,6 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
_exitHotspot = r;
_exitCursorType = cursorType;
_exitScene.sceneID = sceneID;
- // The field after the scene id is a flag label (set on give-up), not a frame.
_exitScene.frameID = 0;
_exitFlag.label = exitFlagLabel;
_exitFlag.flag = exitFlagValue;
@@ -138,10 +129,9 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
}
void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m) {
- readFilename(stream, m.imageName); // the ANIM_OVL sprite strip
+ readFilename(stream, m.imageName); // the sprite strip
m.animRate = stream.readSint32LE(); // frames per second
- // Sprite-strip source rects (an embedded "sprite container": int16 count + rects).
int16 numFrames = stream.readSint16LE();
if (numFrames > 0) {
m.frames.resize(numFrames);
@@ -150,27 +140,30 @@ void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m)
}
}
- // The slide "mover": a start rect, an end/catch rect, and a speed.
readRect(stream, m.moverStart);
readRect(stream, m.moverEnd);
m.moverSpeed = stream.readSint32LE();
- m.winchSound.readData(stream); // snd1 - the winch-up cue
+ m.winchSound.readData(stream); // the winch-up cue
- stream.skip(1); // per-machine flag byte
+ stream.skip(1);
- // The 55-byte blob: a filename[33] (the result movie - MUS_PachinkoWinANIM /
- // MUS_PachinkoLoseANIM), then its 16-byte destination rect, then int32 + int16.
+ // The result movie, then the scene and event flag the puzzle exits through when this
+ // climber is the one that wins.
readFilename(stream, m.movieName);
readRect(stream, m.movieDest);
- stream.skip(6);
+ stream.skip(1);
+ m.resultScene.sceneID = stream.readUint16LE();
+ m.resultScene.frameID = 0;
+ m.resultFlag.label = stream.readSint16LE();
+ m.resultFlag.flag = stream.readByte();
- m.resultSound.readData(stream); // snd2 - the win/lose voice cue
+ m.resultSound.readData(stream); // the win/lose voice cue
- stream.skip(1); // byte (unused here)
- stream.skip(4); // int32 (unused here)
+ stream.skip(1);
+ stream.skip(4);
- m.fastSound.readData(stream); // snd3 - the fast-winch cue
+ m.fastSound.readData(stream); // the fast-winch cue
}
void PachinkoPuzzle::loadMachineImage(Machine &m) {
@@ -340,8 +333,7 @@ void PachinkoPuzzle::spawnBall() {
playSoundBlock(_plinkSounds);
}
-// Reflect the ball off any pin it now overlaps (pins collide as circles). Returns true if a
-// bounce happened.
+// Reflect the ball off any pin it now overlaps. Returns true if a bounce happened.
bool PachinkoPuzzle::collidePins(Ball &ball) const {
const double hitDist = kPinRadius + kBallRadius;
for (const Common::Rect &pin : _pins) {
@@ -354,8 +346,7 @@ bool PachinkoPuzzle::collidePins(Ball &ball) const {
continue;
}
- // Reflect the velocity about the surface normal (pin centre -> ball) and push the
- // ball back out to the contact distance.
+ // Reflect about the surface normal and push the ball back out to contact distance.
double d = sqrt(d2);
double nxn = dx / d;
double nyn = dy / d;
@@ -398,8 +389,7 @@ void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
ball.speed = 0.0;
}
- // Advance in small sub-steps so the ball cannot tunnel between the pins, bouncing off a
- // pin or a side wall on the way.
+ // Advance in sub-steps so the ball cannot tunnel between the pins.
double subDt = dt / kPhysicsSubsteps;
for (int s = 0; s < kPhysicsSubsteps; ++s) {
ball.x += cos(ball.angle) * ball.speed * subDt;
@@ -409,8 +399,8 @@ void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
continue;
}
- // Side walls of the panel keep the ball in play; the ball enters from the right of
- // the right wall, so that wall only reflects once the ball is inside.
+ // The side walls keep the ball in play; the ball enters from the right of the right
+ // wall, so that wall only reflects once the ball is inside.
double vx2 = cos(ball.angle) * ball.speed;
double vy2 = -sin(ball.angle) * ball.speed;
bool reflected = false;
@@ -543,11 +533,9 @@ void PachinkoPuzzle::execute() {
// The first climber to reach the pot ends the game (Gold Digger = win, Yeti = lose).
if (_winMachine.climbSteps >= kClimbGoal) {
- _solved = true;
_activeMachine = &_winMachine;
_pzState = kPlayResult;
} else if (_loseMachine.climbSteps >= kClimbGoal) {
- _solved = false;
_activeMachine = &_loseMachine;
_pzState = kPlayResult;
}
@@ -596,10 +584,15 @@ void PachinkoPuzzle::execute() {
break;
}
case kActionTrigger:
- // The give-up hotspot and the completion path both route to the exit scene; the
- // win/lose branch is driven downstream by the solved flag and the puzzle event flag.
- NancySceneState.setEventFlag(_exitFlag);
- NancySceneState.changeScene(_exitScene);
+ // Each climber carries its own exit scene and event flag, so the winner decides where
+ // the puzzle leaves off; only giving up uses the trailer's exit.
+ if (_activeMachine) {
+ NancySceneState.setEventFlag(_activeMachine->resultFlag);
+ NancySceneState.changeScene(_activeMachine->resultScene);
+ } else {
+ NancySceneState.setEventFlag(_exitFlag);
+ NancySceneState.changeScene(_exitScene);
+ }
finishExecution();
break;
}
@@ -610,6 +603,18 @@ void PachinkoPuzzle::handleInput(NancyInput &input) {
return;
}
+ // Cheat: Ctrl+Shift+P pushes the prospector one catch further up the mountain, gaining
+ // ground on the Yeti.
+ 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_p) {
+ _winMachine.climbSteps += _winMachine.moverSpeed > 0 ? _winMachine.moverSpeed : 1;
+ playSoundBlock(_winMachine.winchSound);
+ debug("Pachinko cheat: prospector pushed up to %d/%d", _winMachine.climbSteps, kClimbGoal);
+ }
+ }
+
const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
if (!_launcherHotspot.isEmpty() &&
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
index 1040628d73e..fa55b8ad901 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.h
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -30,24 +30,13 @@
namespace Nancy {
namespace Action {
-// Pachinko / pinball ball-drop puzzle, new in Nancy13 (AR 175.
+// Pachinko / pinball ball-drop puzzle, new in Nancy13 (AR 175).
//
-// The player clicks a spring launcher (a fixed hotspot on the right of the board) to
-// fire a ball leftward across a pin field. The ball falls under gravity and bounces off
-// pins and bumper zones (restitution ~0.85) until it settles into one of two catch
-// "machines": the Miner (a win) or the Yeti (a loss). Each machine then plays its own
-// result animation (MUS_PachinkoWinANIM for the Miner) before the puzzle finishes.
-//
-// The chunk is a 167-byte header, a random "plink" sound block, two machine sub-objects
-// (each: an ANIM sprite strip + a slide "mover" + three sound blocks + a 55-byte blob
-// whose leading filename is the result movie), a pin-rect array, a polymorphic Nancy13
-// ActionZone array (the bumpers/walls/overlays), and the give-up exit hotspot. The parse
-// is byte-exact (verified to consume the whole chunk).
-//
-// The physics uses a polar-coordinate integrator: velocity as speed + heading, gravity
-// added in cartesian, per-frame heading recomputed with atan2, sub-stepped rectangle
-// collision with restitution. Per-ball deceleration is not in the chunk, so it is
-// approximated (see kDrag).
+// The player clicks a spring launcher on the right of the board to fire a ball leftward
+// across a pin field. The ball falls under gravity, bouncing off the pins and the bumper
+// zones, until it drops into one of four holes. Each hole feeds one of two climbers racing
+// up the mountain to the pot: the Miner (a win) or the Yeti (a loss). The first to reach
+// the pot plays its result animation, then exits through its own scene and event flag.
class PachinkoPuzzle : public RenderActionRecord {
public:
PachinkoPuzzle() : RenderActionRecord(7) {}
@@ -64,21 +53,22 @@ public:
protected:
Common::String getRecordTypeName() const override { return "PachinkoPuzzle"; }
- // One of the two mountain climbers (Miner/Gold Digger = win, Yeti = lose). Each is an
- // animated sprite that climbs from its start anchor (bottom of the mountain) up to its
- // end anchor (the pot at the top) as balls fall into its holes.
+ // One of the two mountain climbers (Miner = win, Yeti = lose). An animated sprite that
+ // climbs from the bottom of the mountain up to the pot as balls fall into its holes.
struct Machine {
- Common::Path imageName; // the ANIM_OVL sprite strip
+ Common::Path imageName; // the sprite strip
int32 animRate = 0; // frames per second
Common::Array<Common::Rect> frames; // sprite-strip source rects
- Common::Rect moverStart; // climb-path bottom anchor (2x2 point rect)
+ Common::Rect moverStart; // climb-path bottom anchor
Common::Rect moverEnd; // climb-path top anchor (the pot)
int32 moverSpeed = 0; // climb steps gained per ball caught
- RandomSoundBlock winchSound; // [snd1] the winch-up cue
- RandomSoundBlock resultSound; // [snd2] the win/lose voice cue (MinerWin*/PachinkoLose*)
- RandomSoundBlock fastSound; // [snd3] the fast-winch cue
- Common::Path movieName; // result animation (blob[0], "" == none)
+ RandomSoundBlock winchSound; // the winch-up cue
+ RandomSoundBlock resultSound; // the win/lose voice cue
+ RandomSoundBlock fastSound; // the fast-winch cue
+ Common::Path movieName; // result animation ("" == none)
Common::Rect movieDest; // where the result animation is drawn
+ SceneChangeDescription resultScene; // where the puzzle exits when this climber wins
+ FlagDescription resultFlag; // the event flag it sets on the way out
Graphics::ManagedSurface image;
uint frame = 0; // current animation frame
@@ -86,8 +76,7 @@ protected:
int climbSteps = 0; // accumulated climb (moverSpeed per catch)
};
- // A single launched ball. Physics run in viewport space; the heading is stored as a
- // scalar speed plus a heading angle in radians.
+ // A single launched ball, moving in viewport space.
struct Ball {
double x = 0.0;
double y = 0.0;
@@ -98,11 +87,11 @@ protected:
};
// One of the four holes on the panel. A ball that drops in advances its climber and
- // briefly lights the hole (a sprite from the "lit" overlay).
+ // briefly lights the hole.
struct Hole {
Common::Rect rect;
Machine *climber = nullptr;
- RandomSoundBlock sound; // the bell cue (PinballBell_*)
+ RandomSoundBlock sound; // the bell cue
Common::Rect litSrc; // lit-hole sprite source (in _litImage)
Common::Rect litDest; // where it is drawn
uint32 litUntil = 0; // keep it lit until this time
@@ -123,10 +112,10 @@ protected:
// Zone cursors take the idle sprite of their type, hover/drag cursors the hotspot one.
void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
- // -- File data (167-byte header, in stream order) --
- Common::Path _imageName; // 0x00 - board overlay (MUS_PachinkoPUZ02_OVL)
+ // -- File data --
+ Common::Path _imageName; // board overlay
- Common::Rect _ballSrc; // ball sprite source in the overlay
+ Common::Rect _ballSrc; // ball sprite source
Common::Rect _ballEntry; // top-right entry chute (where balls appear)
int32 _velMin = 0; // launch-speed floor
int32 _velMax = 0; // launch-speed ceiling
@@ -142,16 +131,16 @@ protected:
int32 _spawnWindowMin = 0;
int32 _spawnWindowMax = 0; // spawn window (ms)
- RandomSoundBlock _plinkSounds; // random ball-launch cues (LeverPull*)
+ RandomSoundBlock _plinkSounds; // random ball-launch cues
Machine _winMachine; // the Miner
Machine _loseMachine; // the Yeti
Common::Array<Common::Rect> _pins; // static pin collision rects
- Common::Array<ActionZone> _zones; // bumpers / walls / overlays (Nancy13 layout)
+ Common::Array<ActionZone> _zones; // bumpers / walls / overlays
Common::Array<Hole> _holes; // the four catch holes (built from _zones)
- // The give-up / exit hotspot (the base trailer's 23-byte record).
+ // The give-up / exit hotspot.
Common::Rect _exitHotspot;
uint16 _exitCursorType = 0;
SceneChangeDescription _exitScene;
@@ -169,8 +158,7 @@ protected:
Common::Array<Ball> _balls;
bool _spawnPending = false; // a launcher click awaiting a spawn
uint32 _spawnClickTime = 0;
- Machine *_activeMachine = nullptr; // the machine that caught the ball
- bool _solved = false;
+ Machine *_activeMachine = nullptr; // the climber that reached the pot
bool _exitRequested = false;
uint32 _lastUpdate = 0;
uint32 _resultTime = 0;
Commit: 7cdb76f1ecc139a99099d2d596e6d7714ce8b547
https://github.com/scummvm/scummvm/commit/7cdb76f1ecc139a99099d2d596e6d7714ce8b547
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:22+03:00
Commit Message:
NANCY: NANCY13: Implement differences in PlaySecondaryMovie ARs 41, 46
- PlaySecondaryMovie (AR 41): Compacted the fields of the non-random
variant
- PlayRandomMovieControl (AR 46): Handle variant which doesn't include
a scene change
Fixes talking to Buddy Taffy at the Copper Gorge Museum
Changed paths:
engines/nancy/action/secondarymovie.cpp
engines/nancy/action/secondarymovie.h
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 7dc72fb281d..146d0bed8a8 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -348,6 +348,33 @@ void PlaySecondaryMovie::resolveSentinelFrames() {
}
}
+void PlaySecondaryMovie::stopRandomNow() {
+ if (!_isRandom) {
+ return;
+ }
+
+ _randomStopRequested = true;
+
+ if (_state == kRun) {
+ if (!_isFinished) {
+ _decoder.pauseVideo(true);
+ _isFinished = true;
+ }
+
+ _state = kActionTrigger;
+ }
+}
+
+void PlaySecondaryMovie::pauseRandom(bool pause) {
+ // The decoder counts pause levels, so only follow an actual change.
+ if (!_isRandom || _randomPaused == pause) {
+ return;
+ }
+
+ _randomPaused = pause;
+ _decoder.pauseVideo(pause);
+}
+
void PlaySecondaryMovie::playRandomSequence() {
if (!_isRandom || _sequences.empty()) {
return;
@@ -448,6 +475,60 @@ int PlaySecondaryMovie::rollNextSequence() {
return -1;
}
+// Orders a frame field that may still hold a sentinel: -1 is the movie's own
+// first frame, -2 its last.
+static int32 orderSentinelFrame(uint16 frame) {
+ switch ((int16)frame) {
+ case -1:
+ return -1;
+ case -2:
+ return 0x7FFFFFFF;
+ default:
+ return frame;
+ }
+}
+
+void PlaySecondaryMovie::readDataNancy13(Common::Serializer &ser, Common::SeekableReadStream &stream) {
+ readFilename(ser, _videoName);
+
+ ser.skip(2); // Z order
+
+ // 2 selects an alpha plane, 1 no alpha; transparency comes from the
+ // decoded pixel format instead.
+ ser.syncAsUint16LE(_videoFormat);
+ _videoFormat = kLargeVideoFormat;
+
+ ser.syncAsUint16LE(_playerCursorAllowed);
+ ser.syncAsUint16LE(_numLoops);
+ ser.syncAsUint16LE(_firstFrame);
+ ser.syncAsUint16LE(_lastFrame);
+ ser.syncAsSint16LE(_sceneChange.sceneID);
+ ser.syncAsUint16LE(_sceneChange.frameID);
+
+ _playDirection = orderSentinelFrame(_lastFrame) < orderSentinelFrame(_firstFrame) ?
+ kPlayMovieReverse : kPlayMovieForward;
+
+ _videoSceneChange = _sceneChange.sceneID != kNoScene ? kMovieSceneChange : kMovieNoSceneChange;
+
+ uint16 numFrameFlags = 0;
+ ser.syncAsUint16LE(numFrameFlags);
+ _frameFlags.resize(numFrameFlags);
+ for (uint i = 0; i < numFrameFlags; ++i) {
+ ser.syncAsSint16LE(_frameFlags[i].frameID);
+ ser.syncAsSint16LE(_frameFlags[i].flagDesc.label);
+ ser.syncAsUint16LE(_frameFlags[i].flagDesc.flag);
+ }
+
+ uint16 numVideoDescs = 0;
+ ser.syncAsUint16LE(numVideoDescs);
+ _videoDescs.resize(numVideoDescs);
+ for (uint i = 0; i < numVideoDescs; ++i) {
+ _videoDescs[i].readData(stream);
+ }
+
+ _sound.name = "NO SOUND";
+}
+
// Nancy14 compacted the non-random layout: the videoSceneChange 5/6 flag is
// gone (a scene change is now requested via the sceneID sentinel), playDirection
// moved after lastFrame, and a "hide on finish" flag was added. AR 44 matches
@@ -559,6 +640,12 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
return;
}
+ // Nancy13's AR 53 shares this class but carries a different chunk.
+ if (g_nancy->getGameType() == kGameTypeNancy13 && _type == 41) {
+ readDataNancy13(ser, stream);
+ return;
+ }
+
readFilename(ser, _videoName);
readFilename(ser, _paletteName, kGameTypeVampire, kGameTypeVampire);
readFilename(ser, _bitmapOverlayName, kGameTypeVampire, kGameTypeNancy9);
@@ -708,6 +795,11 @@ void PlaySecondaryMovie::execute() {
// fall through
case kRun: {
+ // Frozen by a PlayRandomMovieControl until it resumes the movie.
+ if (_randomPaused) {
+ break;
+ }
+
// Random-movie chain: while paused, wait for the pause to expire
// then re-roll. The roll itself may set up another pause, swap to
// the next sequence, or finish the AR if stop was requested.
@@ -891,6 +983,12 @@ void PlaySecondaryMovie::execute() {
// Otherwise the chain entered the paused state; no
// state-trigger transition.
}
+ } else if (_numLoops == 0 || _playCount + 1 < _numLoops) {
+ // More plays to go; restart in place.
+ ++_playCount;
+ _isFinished = false;
+ _decoder.seekToFrame(_playDirection == kPlayMovieReverse ? _lastFrame : _firstFrame);
+ _decoder.pauseVideo(false);
} else if (!g_nancy->_sound->isSoundPlaying(_sound)) {
// Stop the video and block it from starting again, but also wait for
// sound to end before changing state
@@ -913,6 +1011,7 @@ void PlaySecondaryMovie::execute() {
// Allow looping
if (!_isDone) {
_isFinished = false;
+ _playCount = 0;
_decoder.seek(0);
_decoder.pauseVideo(false);
} else if (_playerCursorAllowed == kNoPlayerCursorAllowed) {
@@ -950,16 +1049,39 @@ void PlaySecondaryMovie::skip() {
void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
_mode = stream.readByte();
- _sceneChange.readData(stream, true, true);
+
+ _hasSceneChange = g_nancy->getGameType() < kGameTypeNancy13;
+ if (_hasSceneChange) {
+ _sceneChange.readData(stream, true, true);
+ }
}
void PlayRandomMovieControl::execute() {
PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
if (target && target->_isRandom) {
- target->stopRandom();
+ if (_hasSceneChange) {
+ target->stopRandom();
+ } else {
+ switch (_mode) {
+ case kStopNow:
+ target->stopRandomNow();
+ break;
+ case kPauseMovie:
+ target->pauseRandom(true);
+ break;
+ case kResumeMovie:
+ target->pauseRandom(false);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ if (_hasSceneChange) {
+ _sceneChange.execute();
}
- _sceneChange.execute();
finishExecution();
}
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 3bab9d33ff7..0ef9a4dadc7 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -115,6 +115,11 @@ public:
// unused by playback.
uint16 _playStyle = 1;
+ // How many times the movie plays before the record finishes; 0 loops
+ // for as long as the scene lasts.
+ uint16 _numLoops = 1;
+ uint16 _playCount = 0;
+
// Volume of the movie's audio track, as a percentage. Carried by Nancy14+
// AR 44/47 only; every other record plays at full volume. SetMovieVolume
// (AR 150) can change it later.
@@ -167,6 +172,7 @@ public:
RandomChainState _randomChainState = kRandomPlaying;
uint32 _randomPauseEndTime = 0;
bool _randomStopRequested = false;
+ bool _randomPaused = false;
// Talkable-character hover state: whether the mouse is over the character,
// and whether the recognition (secondary) movie is currently playing.
@@ -183,8 +189,13 @@ public:
uint32 _rewindLastFrameTime = 0;
uint32 _rewindFrameDelay = 66;
- // Called by PlayRandomMovieControl::execute() to wind down the AR.
+ // Called by PlayRandomMovieControl::execute(). stopRandom() winds the AR
+ // down once the sequence that's playing finishes; stopRandomNow() ends it
+ // on the spot; pauseRandom() freezes the movie with the record still
+ // running.
void stopRandom() { _randomStopRequested = true; }
+ void stopRandomNow();
+ void pauseRandom(bool pause);
// Pick & start a fresh random sequence. No-op when not a random AR.
void playRandomSequence();
@@ -224,6 +235,12 @@ protected:
// (random or by name) and seed the flat playback fields from it.
void applyStartingRandomSequence();
+ // Nancy13 compacted the non-random layout: a z-order, an alpha selector,
+ // the cursor flag, a loop count, the frame range and the scene change.
+ // Direction follows from lastFrame preceding firstFrame, and a scene
+ // change is requested through the sceneID sentinel.
+ void readDataNancy13(Common::Serializer &ser, Common::SeekableReadStream &stream);
+
void readDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream);
// Apply a RandomSequence's playback config to the PSM flat fields
@@ -276,8 +293,8 @@ public:
enum RandomMovieControlMode : byte {
kStopNow = 0,
- kStopAfterSequence = 1,
- kResume = 2
+ kPauseMovie = 1,
+ kResumeMovie = 2
};
protected:
@@ -285,6 +302,9 @@ protected:
byte _mode = kStopNow;
SceneChangeWithFlag _sceneChange;
+ // Nancy13's record is the mode byte alone; earlier games append a scene
+ // change to it.
+ bool _hasSceneChange = true;
};
} // End of namespace Action
Commit: e5ebcdc2f29d0a3cf1b9ee68797acc8c1aba8a1d
https://github.com/scummvm/scummvm/commit/e5ebcdc2f29d0a3cf1b9ee68797acc8c1aba8a1d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:24+03:00
Commit Message:
NANCY: NANCY14: Implement DecoderPuzzle (AR 182)
A puzzle with a typewriter, where all keys entered are decoded into
words (like Enigma)
Changed paths:
A engines/nancy/action/puzzle/decoderpuzzle.cpp
A engines/nancy/action/puzzle/decoderpuzzle.h
engines/nancy/action/arfactory.cpp
engines/nancy/module.mk
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 60fbf64a745..5b856f5656c 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -47,6 +47,7 @@
#include "engines/nancy/action/puzzle/collisionpuzzle.h"
#include "engines/nancy/action/puzzle/cubepuzzle.h"
#include "engines/nancy/action/puzzle/cuttingpuzzle.h"
+#include "engines/nancy/action/puzzle/decoderpuzzle.h"
#include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
#include "engines/nancy/action/puzzle/drivingpuzzle.h"
#include "engines/nancy/action/puzzle/dropsortpuzzle.h"
@@ -517,9 +518,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
return nullptr;
case 181:
return new PaintPuzzle();
- case 182: // DecoderPuzzle
- // TODO: not yet implemented
- return nullptr;
+ case 182:
+ return new DecoderPuzzle();
// -- Nancy15 new puzzles (types 183-185) --
case 183: // MagicBoxPuzzle
// TODO: not yet implemented
diff --git a/engines/nancy/action/puzzle/decoderpuzzle.cpp b/engines/nancy/action/puzzle/decoderpuzzle.cpp
new file mode 100644
index 00000000000..7825416dd1a
--- /dev/null
+++ b/engines/nancy/action/puzzle/decoderpuzzle.cpp
@@ -0,0 +1,411 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/decoderpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint kWordSize = 100; // stride of a target word
+static const uint kSubstitutionSize = 4; // stride of either half of a substitution
+
+static const byte kEnterKey = '\r';
+static const uint kNumRandomLetters = 25; // never picks 'Z'
+
+// Backspace, return and the punctuation the original accepts - a narrower set
+// than Common::isPunct()
+static const byte kAcceptedSymbols[] = {
+ '\b', '\r', ' ', '.', ',', '?', '!', '/', '$', '(', ')', '&', ':', ';', '+', '%', '=', '-'
+};
+
+// Accented characters accepted alongside them
+static const byte kAcceptedHighKeys[] = {
+ 0x80, 0x9c, 0xa1, 0xbf, 0xc0, 0xc4, 0xc7, 0xc9, 0xd1, 0xd6, 0xdc, 0xdf,
+ 0xe0, 0xe1, 0xe2, 0xe4, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xed, 0xee, 0xef,
+ 0xf1, 0xf3, 0xf4, 0xf6, 0xf9, 0xfa, 0xfb, 0xfc
+};
+
+// Reads a NUL-padded string of exactly size bytes
+static Common::String readFixedString(Common::SeekableReadStream &stream, uint size) {
+ char *buf = new char[size + 1];
+ stream.read(buf, size);
+ buf[size] = '\0';
+ Common::String ret(buf);
+ delete[] buf;
+ return ret;
+}
+
+static bool isInKeySet(byte key, const byte *set, uint size) {
+ for (uint i = 0; i < size; ++i) {
+ if (key == set[i]) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+bool DecoderPuzzle::isAcceptedKey(byte key) {
+ // Common::isAlnum() and friends are ASCII-only, so the rest is listed out
+ return Common::isAlnum(key) ||
+ isInKeySet(key, kAcceptedSymbols, ARRAYSIZE(kAcceptedSymbols)) ||
+ isInKeySet(key, kAcceptedHighKeys, ARRAYSIZE(kAcceptedHighKeys));
+}
+
+DecoderData *DecoderPuzzle::getPuzzleData() const {
+ DecoderData *data = (DecoderData *)NancySceneState.getPuzzleData(DecoderData::getTag());
+ assert(data);
+ return data;
+}
+
+void DecoderPuzzle::readData(Common::SeekableReadStream &stream) {
+ _saveOutput = stream.readByte(); // 0x3d
+ _restoreOutput = stream.readByte(); // 0x3e
+ _fontID = stream.readUint16LE(); // 0x3f
+ _textX = stream.readSint32LE(); // 0x41
+ _textY = stream.readSint32LE(); // 0x45
+ _maxLength = stream.readSint16LE(); // 0x5d
+
+ // Target words and the typed half of every substitution are uppercased on
+ // load, making both comparisons case-insensitive
+ int16 numWords = stream.readSint16LE();
+ for (int16 i = 0; i < numWords; ++i) {
+ Common::String word = readFixedString(stream, kWordSize);
+ word.toUppercase();
+ _words.push_back(word);
+ }
+
+ // A count of halves, not of pairs
+ int16 numHalves = stream.readSint16LE();
+ _substitutions.resize(numHalves / 2);
+ for (uint i = 0; i < _substitutions.size(); ++i) {
+ Substitution &sub = _substitutions[i];
+ sub.keys = readFixedString(stream, kSubstitutionSize);
+ sub.keys.toUppercase();
+ sub.output = readFixedString(stream, kSubstitutionSize);
+ }
+
+ _typeSound.readData(stream); // 0x6f
+ _decodeSound.readData(stream); // 0xc5
+
+ readFilename(stream, _resetMovieName); // 0x11b
+ readRect(stream, _resetMovieRect); // 0x11f
+
+ _resetSound.readData(stream); // 0x12f
+
+ _solveScene.sceneID = stream.readUint16LE(); // 0x1db
+ _solveScene.frameID = stream.readUint16LE();
+ _solveScene.continueSceneSound = kContinueSceneSound;
+ _solveFlag.label = stream.readSint16LE();
+ _solveFlag.flag = stream.readByte();
+
+ _solveSound.readData(stream); // 0x185
+
+ // Count-prefixed 23-byte hotspot records; the first is the "give up" hotspot
+ int16 numZones = stream.readSint16LE();
+ for (int16 i = 0; i < numZones; ++i) {
+ Common::Rect r;
+ readRect(stream, r);
+ uint16 cursorType = stream.readUint16LE();
+ uint16 sceneID = stream.readUint16LE();
+ int16 flagLabel = stream.readSint16LE();
+ byte flagValue = stream.readByte();
+
+ if (i == 0) {
+ _exitHotspot = r;
+ _exitCursorType = cursorType;
+ _exitScene.sceneID = sceneID;
+ _exitScene.frameID = 0;
+ _exitScene.continueSceneSound = kContinueSceneSound;
+ _exitFlag.label = flagLabel;
+ _exitFlag.flag = flagValue;
+ }
+ }
+}
+
+void DecoderPuzzle::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);
+
+ // The decoded line is shared between a scene's records
+ DecoderData *data = getPuzzleData();
+ uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
+ if (_restoreOutput && data->sceneID == sceneID) {
+ _output = data->text;
+ } else {
+ _output.clear();
+ }
+
+ if (_saveOutput) {
+ data->sceneID = sceneID;
+ }
+
+ _pending.clear();
+ _hasPendingKey = false;
+ _decodeSoundPending = false;
+ _resetting = false;
+ _solved = false;
+
+ if (!_resetMovieName.empty()) {
+ _resetMovie.loadFile(_resetMovieName);
+ }
+
+ redraw();
+}
+
+void DecoderPuzzle::onPause(bool paused) {
+ g_nancy->_input->setVKEnabled(!paused);
+ RenderActionRecord::onPause(paused);
+}
+
+void DecoderPuzzle::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);
+}
+
+bool DecoderPuzzle::isSoundBlockPlaying(const RandomSoundBlock &block) const {
+ return !block.names.empty() && g_nancy->_sound->isSoundPlaying((uint16)block.channel);
+}
+
+bool DecoderPuzzle::decodePending(bool &noMatch) {
+ noMatch = false;
+
+ // With no substitution table every keystroke produces a random letter
+ if (_substitutions.empty()) {
+ _output += (char)('A' + g_nancy->_randomSource->getRandomNumber(kNumRandomLetters - 1));
+ _pending.clear();
+ return true;
+ }
+
+ for (uint i = 0; i < _substitutions.size(); ++i) {
+ if (_substitutions[i].keys == _pending) {
+ _output += _substitutions[i].output;
+ _pending.clear();
+ return true;
+ }
+ }
+
+ // Still the start of some substitution: keep collecting keys
+ for (uint i = 0; i < _substitutions.size(); ++i) {
+ if (_substitutions[i].keys.hasPrefix(_pending)) {
+ return false;
+ }
+ }
+
+ noMatch = true;
+ _pending.clear();
+ return false;
+}
+
+void DecoderPuzzle::checkSolved() {
+ for (uint i = 0; i < _words.size(); ++i) {
+ if (_words[i] == _output) {
+ _solved = true;
+ return;
+ }
+ }
+}
+
+void DecoderPuzzle::beginReset() {
+ _output.clear();
+ _pending.clear();
+
+ if (_resetMovie.isVideoLoaded() && _resetMovie.getFrameCount() > 0) {
+ _resetMovie.playRange(0, _resetMovie.getFrameCount() - 1);
+ _resetting = true;
+ }
+
+ redraw();
+}
+
+void DecoderPuzzle::redraw() {
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+ if (_resetting) {
+ // The animation covers the line while it plays
+ _resetMovie.drawFrame(_drawSurface, Common::Point(_resetMovieRect.left, _resetMovieRect.top));
+ } else if (!_output.empty()) {
+ const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
+ if (font) {
+ // The stored y is the text baseline
+ font->drawString(&_drawSurface, _output, _textX, _textY - (int)font->getFontHeight(),
+ _drawSurface.w - _textX, 0);
+ }
+ }
+
+ _needsRedraw = true;
+}
+
+void DecoderPuzzle::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+ registerGraphics();
+ _state = kRun;
+ // fall through
+ case kRun:
+ // Both sounds share a channel, so hold the decode one back
+ if (_decodeSoundPending && !isSoundBlockPlaying(_typeSound)) {
+ playSoundBlock(_decodeSound);
+ _decodeSoundPending = false;
+ }
+
+ if (_solved) {
+ _resetMovie.close();
+ _resetting = false;
+ playSoundBlock(_solveSound);
+ _state = kActionTrigger;
+ break;
+ }
+
+ if (_resetting) {
+ if (!isSoundBlockPlaying(_resetSound)) {
+ playSoundBlock(_resetSound);
+ }
+
+ if (_resetMovie.update()) {
+ redraw();
+ }
+
+ if (!_resetMovie.isRangePlaying()) {
+ _resetting = false;
+ redraw();
+ }
+ } else if (_hasPendingKey) {
+ _hasPendingKey = false;
+
+ bool reset = (int)_output.size() > _maxLength || _pendingKey == kEnterKey;
+ if (!reset) {
+ bool noMatch = false;
+ if (decodePending(noMatch)) {
+ if (isSoundBlockPlaying(_typeSound)) {
+ _decodeSoundPending = true;
+ } else {
+ playSoundBlock(_decodeSound);
+ }
+
+ checkSolved();
+ redraw();
+ }
+
+ reset = noMatch;
+ }
+
+ if (reset) {
+ beginReset();
+ }
+ }
+
+ if (_saveOutput) {
+ getPuzzleData()->text = _output;
+ }
+
+ break;
+ case kActionTrigger:
+ // The solve voiceover gets to finish first
+ if (!_exitRequested && isSoundBlockPlaying(_solveSound)) {
+ break;
+ }
+
+ if (_exitRequested) {
+ NancySceneState.setEventFlag(_exitFlag);
+ NancySceneState.changeScene(_exitScene);
+ } else {
+ NancySceneState.setEventFlag(_solveFlag);
+ NancySceneState.changeScene(_solveScene);
+ }
+
+ finishExecution();
+ break;
+ }
+}
+
+void DecoderPuzzle::handleInput(NancyInput &input) {
+ if (_state != kRun || _solved || _resetting || _hasPendingKey) {
+ return;
+ }
+
+ if (!_exitHotspot.isEmpty() &&
+ NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true);
+
+ if (input.input & NancyInput::kLeftMouseButtonUp) {
+ _exitRequested = true;
+ _state = kActionTrigger;
+ return;
+ }
+ }
+
+ for (uint i = 0; i < input.otherKbdInput.size(); ++i) {
+ byte key = (byte)input.otherKbdInput[i].ascii;
+
+ if (!isAcceptedKey(key)) {
+ continue;
+ }
+
+ // The original gets a key code, already uppercase for letters. Accented
+ // characters are left alone, as they are in the loaded substitutions.
+ if (Common::isLower(key)) {
+ key = toupper(key);
+ }
+
+ playSoundBlock(_typeSound);
+ _pending += (char)key;
+ _pendingKey = key;
+ _hasPendingKey = true;
+ break;
+ }
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/decoderpuzzle.h b/engines/nancy/action/puzzle/decoderpuzzle.h
new file mode 100644
index 00000000000..87cf962e7b5
--- /dev/null
+++ b/engines/nancy/action/puzzle/decoderpuzzle.h
@@ -0,0 +1,125 @@
+/* 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_DECODERPUZZLE_H
+#define NANCY_ACTION_DECODERPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+
+struct DecoderData;
+
+namespace Action {
+
+// Keyboard decoding puzzle, new in Nancy14 (AR 182). Keystrokes collect into a
+// pending sequence; when it matches the typed half of a substitution, the other
+// half is appended to the decoded line. A sequence that is still a prefix of some
+// substitution keeps collecting; one that matches nothing - as does Enter, or
+// overflowing the line - wipes both buffers and plays the carriage-return
+// animation. Solved once the line equals one of the target words.
+//
+// Each scene using it holds two records, one with the substitution table and one
+// with none (every keystroke emits a random letter); they share the decoded line
+// through the puzzle data.
+class DecoderPuzzle : public RenderActionRecord {
+public:
+ DecoderPuzzle() : RenderActionRecord(7) {}
+ virtual ~DecoderPuzzle() {}
+
+ void init() override;
+ void onPause(bool paused) 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 "DecoderPuzzle"; }
+
+ // One entry of the substitution table: typing `keys` emits `output`.
+ struct Substitution {
+ Common::String keys;
+ Common::String output;
+ };
+
+ static bool isAcceptedKey(byte key);
+
+ DecoderData *getPuzzleData() const;
+ void playSoundBlock(const RandomSoundBlock &block);
+ bool isSoundBlockPlaying(const RandomSoundBlock &block) const;
+
+ // Consumes the pending sequence. True if it produced output; sets noMatch
+ // when the sequence cannot lead to any substitution.
+ bool decodePending(bool &noMatch);
+ void checkSolved();
+ void beginReset();
+ void redraw();
+
+ // -- File data --
+ byte _saveOutput = 0; // 0x3d
+ byte _restoreOutput = 0; // 0x3e
+ uint16 _fontID = 0; // 0x3f
+ int32 _textX = 0; // 0x41
+ int32 _textY = 0; // 0x45, text baseline
+
+ Common::Array<Common::String> _words; // target words, uppercased
+ Common::Array<Substitution> _substitutions; // empty = emit random letters
+ int16 _maxLength = 0; // 0x5d
+
+ RandomSoundBlock _typeSound; // 0x6f, keystroke
+ RandomSoundBlock _decodeSound; // 0xc5, substitution resolved
+
+ Common::Path _resetMovieName; // 0x11b, carriage-return animation
+ Common::Rect _resetMovieRect; // 0x11f
+ RandomSoundBlock _resetSound; // 0x12f, plays while it runs
+
+ SceneChangeDescription _solveScene; // 0x1db
+ FlagDescription _solveFlag;
+ RandomSoundBlock _solveSound; // 0x185, plays before the scene change
+
+ // Give-up hotspot, from the count-prefixed 23-byte trailer
+ Common::Rect _exitHotspot;
+ uint16 _exitCursorType = 0;
+ SceneChangeDescription _exitScene;
+ FlagDescription _exitFlag;
+
+ // -- Runtime state --
+ MoviePlayer _resetMovie;
+
+ Common::String _output; // the decoded line
+ Common::String _pending; // keys typed since the last substitution
+ byte _pendingKey = 0;
+ bool _hasPendingKey = false;
+ bool _decodeSoundPending = false;
+ bool _resetting = false;
+ bool _solved = false;
+ bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_DECODERPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index b61f8897924..0bdaa9babdf 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -31,6 +31,7 @@ MODULE_OBJS = \
action/puzzle/collisionpuzzle.o \
action/puzzle/cubepuzzle.o \
action/puzzle/cuttingpuzzle.o \
+ action/puzzle/decoderpuzzle.o \
action/puzzle/dotconnectpuzzle.o \
action/puzzle/drivingpuzzle.o \
action/puzzle/dropsortpuzzle.o \
Commit: 8eeca4f05393d9a0bfe309fb9e1890cb70e14409
https://github.com/scummvm/scummvm/commit/8eeca4f05393d9a0bfe309fb9e1890cb70e14409
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:25+03:00
Commit Message:
NANCY: NANCY14: Implement MeterPuzzle
Handles the health meters for the final fight
Changed paths:
A engines/nancy/action/puzzle/meterpuzzle.cpp
A engines/nancy/action/puzzle/meterpuzzle.h
engines/nancy/action/arfactory.cpp
engines/nancy/module.mk
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 5b856f5656c..92949b01e26 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -59,6 +59,7 @@
#include "engines/nancy/action/puzzle/magnetmazepuzzle.h"
#include "engines/nancy/action/puzzle/mazechasepuzzle.h"
#include "engines/nancy/action/puzzle/memorypuzzle.h"
+#include "engines/nancy/action/puzzle/meterpuzzle.h"
#include "engines/nancy/action/puzzle/mindpuzzle.h"
#include "engines/nancy/action/puzzle/minigolfpuzzle.h"
#include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
@@ -510,9 +511,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
return new HangmanPuzzle();
case 178:
return new AdjustPuzzle();
- case 179: // MeterPuzzle
- // TODO: not yet implemented
- return nullptr;
+ case 179:
+ return new MeterPuzzle();
case 180: // BlockingPuzzle
// TODO: not yet implemented
return nullptr;
diff --git a/engines/nancy/action/puzzle/meterpuzzle.cpp b/engines/nancy/action/puzzle/meterpuzzle.cpp
new file mode 100644
index 00000000000..7107af1c52d
--- /dev/null
+++ b/engines/nancy/action/puzzle/meterpuzzle.cpp
@@ -0,0 +1,125 @@
+/* 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 "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/action/puzzle/meterpuzzle.h"
+
+#include "engines/nancy/state/scene.h"
+
+namespace Nancy {
+namespace Action {
+
+void MeterPuzzle::readData(Common::SeekableReadStream &stream) {
+ _mode = stream.readSint16LE(); // 0x00
+ _modeParam = stream.readSint16LE(); // 0x02
+ _modeValue = stream.readSint32LE(); // 0x04
+
+ readFilename(stream, _animName); // 0x08
+
+ _videoFormat = stream.readUint16LE(); // 0x29
+ _value = stream.readSint16LE(); // 0x2b
+ _firstFrame = stream.readSint16LE(); // 0x2d
+ _lastFrame = stream.readSint16LE(); // 0x2f
+
+ readRect(stream, _srcRect); // 0x31
+ readRect(stream, _destRect); // 0x41
+}
+
+void MeterPuzzle::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);
+
+ _animation.loadFile(_animName);
+
+ _displayedValue = -1;
+ redraw();
+}
+
+int32 MeterPuzzle::sampleValue() const {
+ // Modes 1/2 track value-table entry _modeParam; mode 0's source is unused here.
+ if ((_mode == 1 || _mode == 2) && _modeParam != 0xff) {
+ TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ if (table) {
+ int16 value = table->getValue(_modeParam);
+ return value == kNoTableValue ? 0 : value; // unset reads as empty
+ }
+ }
+
+ return _value;
+}
+
+int MeterPuzzle::computeFrame() const {
+ int frameCount = _animation.getFrameCount();
+ if (frameCount <= 0 || _modeValue == 0) {
+ return 0;
+ }
+
+ // frame = round(frameCount * value / modeValue).
+ int frame = (int)((double)frameCount * (double)sampleValue() / (double)_modeValue + 0.5);
+ return CLIP(frame, 0, frameCount - 1);
+}
+
+void MeterPuzzle::redraw() {
+ if (!_animation.isVideoLoaded()) {
+ return;
+ }
+
+ int frame = computeFrame();
+ if (frame == _displayedValue) {
+ return;
+ }
+
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+ _animation.goToFrame(frame);
+ // _srcRect is unused
+ _animation.drawFrame(_drawSurface, Common::Point(_destRect.left, _destRect.top));
+
+ _displayedValue = frame;
+ _needsRedraw = true;
+}
+
+void MeterPuzzle::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+ registerGraphics();
+ _state = kRun;
+ // fall through
+ case kRun:
+ // Repaint when the tracked value's frame changes.
+ redraw();
+ break;
+ default:
+ break;
+ }
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/meterpuzzle.h b/engines/nancy/action/puzzle/meterpuzzle.h
new file mode 100644
index 00000000000..cf6ecb5a42b
--- /dev/null
+++ b/engines/nancy/action/puzzle/meterpuzzle.h
@@ -0,0 +1,76 @@
+/* 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_METERPUZZLE_H
+#define NANCY_ACTION_METERPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// A meter/gauge display, new in Nancy14 (AR 179): shows one frame of a bar
+// animation. In modes 1/2 the value is a shared value table entry, driven by
+// BlockingPuzzle (AR 180). In Nancy14, it handles the health meters for the
+// final fight.
+class MeterPuzzle : public RenderActionRecord {
+public:
+ MeterPuzzle() : RenderActionRecord(7) {}
+ virtual ~MeterPuzzle() {}
+
+ void init() override;
+
+ void readData(Common::SeekableReadStream &stream) override;
+ void execute() override;
+
+ bool isViewportRelative() const override { return true; }
+
+protected:
+ Common::String getRecordTypeName() const override { return "MeterPuzzle"; }
+
+ void redraw();
+ int32 sampleValue() const; // the tracked value (a value-table entry in modes 1/2)
+ int computeFrame() const; // the frame for the current value
+
+ // -- File data --
+ int16 _mode = 0; // 0x00 - mode (0/1/2)
+ int16 _modeParam = 0; // 0x02
+ int32 _modeValue = 0; // 0x04
+
+ Common::Path _animName; // 0x08 - the bar animation
+ uint16 _videoFormat = 0; // 0x29
+ int16 _value = 0; // 0x2b - fallback value
+ int16 _firstFrame = 0; // 0x2d
+ int16 _lastFrame = 0; // 0x2f
+ Common::Rect _srcRect; // 0x31
+ Common::Rect _destRect; // 0x41 - draw position
+
+ // -- Runtime state --
+ MoviePlayer _animation;
+ int16 _displayedValue = -1; // last drawn frame
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_METERPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 0bdaa9babdf..8e3fc2335c0 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -43,6 +43,7 @@ MODULE_OBJS = \
action/puzzle/mazechasepuzzle.o \
action/puzzle/matchpuzzle.o \
action/puzzle/memorypuzzle.o \
+ action/puzzle/meterpuzzle.o \
action/puzzle/mindpuzzle.o \
action/puzzle/minigolfpuzzle.o \
action/puzzle/mirrorlightpuzzle.o \
Commit: 1807329275bf6f6d5dfaa9fdc34c9830e17e959d
https://github.com/scummvm/scummvm/commit/1807329275bf6f6d5dfaa9fdc34c9830e17e959d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-01T08:38:26+03:00
Commit Message:
NANCY: NANCY14: Add PuzzleData records for DecoderPuzzle
Changed paths:
engines/nancy/puzzledata.cpp
engines/nancy/puzzledata.h
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 43f9006b479..306ed573341 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -514,6 +514,11 @@ void HangmanData::synchronize(Common::Serializer &ser) {
}
}
+void DecoderData::synchronize(Common::Serializer &ser) {
+ ser.syncAsUint16LE(sceneID);
+ ser.syncString(text);
+}
+
void DrivingData::synchronize(Common::Serializer &ser) {
ser.syncAsByte(valid);
ser.syncAsSint32LE(carX);
@@ -534,6 +539,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
return new WordFindPuzzleData();
case HangmanData::getTag():
return new HangmanData();
+ case DecoderData::getTag():
+ return new DecoderData();
case SliderPuzzleData::getTag():
return new SliderPuzzleData();
case RippedLetterPuzzleData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index b3cb0a899e7..e6261e4435d 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -399,6 +399,20 @@ struct HangmanData : public PuzzleData {
Common::Array<Common::String> usedWords;
};
+// Nancy14+ DecoderPuzzle (AR 182). The decoded line typed so far, plus the scene
+// it belongs to. A scene's two records (with and without the substitution table)
+// hand the line to each other through here; other scenes start empty.
+struct DecoderData : public PuzzleData {
+ DecoderData() {}
+ virtual ~DecoderData() {}
+
+ static constexpr uint32 getTag() { return MKTAG('D', 'C', 'D', 'R'); }
+ virtual void synchronize(Common::Serializer &ser);
+
+ uint16 sceneID = kNoScene;
+ Common::String text;
+};
+
// Nancy12 DrivingPuzzle (AR 160). The car's position, heading and tire state persist
// across visits to the driving map (driving into a location, then coming back), matching
// the original's retainState mechanism, which saves the car to globals every frame and
More information about the Scummvm-git-logs
mailing list