[Scummvm-git-logs] scummvm master -> 8a854b3f18a13a2999242a336508a67ab12b04a2
bluegr
noreply at scummvm.org
Tue Jul 21 00:56:07 UTC 2026
This automated email contains information about 6 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .
Summary:
579f26a0a3 NANCY: NANCY13: Implement the MMIX (MusicMix) functionality
dcb90ead28 NANCY: NANCY14: Implement chunk loader for the UICM (camera) chunk
5d68a280fa NANCY: NANCY15: Add names to Nancy14 ARs, and map ARs used in Nancy15
5507fb29d8 NANCY: NANCY13: Initial implementation of PachinkoPuzzle
8541ea72ec NANCY: NANCY14: Implement new SecondaryMovie functionality for N14/N15
8a854b3f18 NANCY: NANCY15: Initial handling for the per-character UI functionality
Commit: 579f26a0a3ec51584cd039e4f9cd038f387475cb
https://github.com/scummvm/scummvm/commit/579f26a0a3ec51584cd039e4f9cd038f387475cb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:49+03:00
Commit Message:
NANCY: NANCY13: Implement the MMIX (MusicMix) functionality
Now, the overriden random scene music, specified in the new MMIX chunk,
is working
Changed paths:
engines/nancy/sound.cpp
engines/nancy/sound.h
diff --git a/engines/nancy/sound.cpp b/engines/nancy/sound.cpp
index f7ac6110013..e58d16b1b95 100644
--- a/engines/nancy/sound.cpp
+++ b/engines/nancy/sound.cpp
@@ -262,6 +262,27 @@ SoundManager::~SoundManager() {
stopAllSounds();
}
+Common::String SoundManager::resolveMusicMix(const Common::String &name) const {
+ auto *mmix = GetEngineData(MMIX);
+ if (!mmix) {
+ // Pre-Nancy13, or no mix table present
+ return name;
+ }
+
+ for (const MMIX::Record &record : mmix->records) {
+ if (record.name.equalsIgnoreCase(name)) {
+ if (record.musicNames.empty()) {
+ return "NO SOUND";
+ }
+
+ uint pick = g_nancy->_randomSource->getRandomNumber(record.musicNames.size() - 1);
+ return record.musicNames[pick];
+ }
+ }
+
+ return name;
+}
+
void SoundManager::loadSound(const SoundDescription &description, SoundEffectDescription **effectData, bool forceReload) {
if (description.name == "NO SOUND") {
return;
@@ -307,7 +328,15 @@ void SoundManager::loadSound(const SoundDescription &description, SoundEffectDes
*effectData = nullptr;
}
- Common::Path path(description.name + (g_nancy->getGameType() == kGameTypeVampire ? ".dwd" : ".his"));
+ // Resolve the sound name through the music mix table (Nancy 13+). Location
+ // codes get swapped for a randomly-picked track; unmapped names pass through.
+ Common::String soundName = resolveMusicMix(description.name);
+ if (soundName.empty() || soundName == "NO SOUND") {
+ // The mix maps this location to silence
+ return;
+ }
+
+ Common::Path path(soundName + (g_nancy->getGameType() == kGameTypeVampire ? ".dwd" : ".his"));
Common::SeekableReadStream *file = SearchMan.createReadStreamForMember(path);
if (file) {
uint numLoops = chan.numLoops;
diff --git a/engines/nancy/sound.h b/engines/nancy/sound.h
index f195e10c42a..01594f92e11 100644
--- a/engines/nancy/sound.h
+++ b/engines/nancy/sound.h
@@ -160,6 +160,13 @@ protected:
void soundEffectMaintenance(uint16 channelID, bool force = false);
+ // Nancy 13 introduced the MMIX ("music mix") boot chunk, a table mapping a
+ // short location code (e.g. "CAM", "BRI") to a set of interchangeable music /
+ // ambience tracks. When a sound is loaded by such a code, the engine picks one
+ // of the mapped tracks at random and plays that instead. Returns the resolved
+ // filename, or the input name unchanged when it doesn't match any mix record.
+ Common::String resolveMusicMix(const Common::String &name) const;
+
// Returns the listener position override if one is active, otherwise
// the scene summary's listener position
Math::Vector3d getListenerPosition() const;
Commit: dcb90ead284e758aa510686bfc4b2f1b1d667f68
https://github.com/scummvm/scummvm/commit/dcb90ead284e758aa510686bfc4b2f1b1d667f68
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:50+03:00
Commit Message:
NANCY: NANCY14: Implement chunk loader for the UICM (camera) chunk
Changed paths:
engines/nancy/enginedata.cpp
engines/nancy/enginedata.h
engines/nancy/nancy.cpp
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index bbcbfb30482..0c2c0f20a45 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1167,6 +1167,28 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
}
}
+UICM::UICM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+ readFilename(*chunkStream, overlayImageName);
+
+ readRect(*chunkStream, viewRect);
+ maxPictures = chunkStream->readUint16LE();
+ pictureCount = chunkStream->readByte();
+ chunkStream->skip(2);
+
+ shutterSound.readData(*chunkStream);
+
+ const uint16 count = chunkStream->readUint16LE();
+ subjects.resize(count);
+ for (uint i = 0; i < count; ++i) {
+ CameraSubject &s = subjects[i];
+ s.hotspot.readData(*chunkStream);
+ s.subjectID = chunkStream->readSint16LE();
+ s.flag.label = chunkStream->readSint16LE();
+ s.flag.flag = chunkStream->readByte();
+ chunkStream->skip(1);
+ }
+}
+
UICO::UICO(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
readUIPopupHeader(*chunkStream, header);
readRect(*chunkStream, textRect);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index f3edb1fca3f..c5320e45ac8 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -711,6 +711,32 @@ struct UICL : public EngineData {
Common::Array<PictureRecord> pictures; // captured-picture slots (up to 50)
};
+// Camera UI, added in Nancy 14. This is a standalone camera. While it is active,
+// the cursor becomes a large viewfinder rectangle that the player aims at the
+// scene; clicking photographs every subject whose region falls inside the framed
+// area.
+struct UICM : public EngineData {
+ UICM(Common::SeekableReadStream *chunkStream);
+
+ // One photographable region. When a picture is taken, every subject whose
+ // frameID matches the current scene view and whose coords lie within the
+ // viewfinder rectangle is captured: its subjectID is recorded in the picture
+ // and its flag (if any) is set.
+ struct CameraSubject {
+ HotspotDescription hotspot; // frameID + region that can be photographed
+ int16 subjectID = -1; // identifies what was photographed
+ FlagDescription flag; // event flag set on capture (often unset)
+ };
+
+ Common::Path overlayImageName; // "PHO_CameraView"
+ Common::Rect viewRect; // captured-picture bounds
+ uint16 maxPictures = 0; // most pictures the camera can hold
+ byte pictureCount = 0; // current picture count (0xff = none)
+ RandomSoundBlock shutterSound; // picture-capture cue
+
+ Common::Array<CameraSubject> subjects;
+};
+
// New conversation popup UI (the text strip that appears above the taskbar
// when a character is speaking). Introduced in Nancy 10.
// Note: response hotspots are NOT in this chunk â they live in a separate
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 01630698704..8361b859b8b 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -574,7 +574,7 @@ void NancyEngine::bootGameEngine() {
LOAD_BOOT(MMIX) // Music mix table (location code -> music tracks)
// Nancy 14
- // LOAD_BOOT(UICM)
+ LOAD_BOOT(UICM) // Camera UI
// Nancy 15+
// TASK, UIRC, UIIV, UICO, UICM, UICL,
Commit: 5d68a280faf7e789110e48e3cd5f6a06ed21d186
https://github.com/scummvm/scummvm/commit/5d68a280faf7e789110e48e3cd5f6a06ed21d186
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:51+03:00
Commit Message:
NANCY: NANCY15: Add names to Nancy14 ARs, and map ARs used in Nancy15
Changed paths:
engines/nancy/action/arfactory.cpp
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 3f9cb7e0b23..24987c0afa9 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -381,9 +381,15 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
return new AddSearchLink();
case 132: // Nancy12
return new ResourceUse();
- case 133: // Nancy14
+ case 133: // Nancy14 - CameraAction
+ // Cell-phone camera action (introduced alongside the UICM camera UI).
// TODO: not yet implemented
return nullptr;
+ case 134: // Nancy15 - PlayCharAR
+ // Switches the active player character (Nancy / Frank / Joe), the
+ // dual-protagonist mechanic new to The Creature of Kapu Cave.
+ // TODO: not yet implemented (depends on the PCUI/LDSN player-char UI)
+ return nullptr;
case 140:
if (g_nancy->getGameType() >= kGameTypeNancy12)
return new SetPlayerClock(); // Moved from 170 in Nancy12
@@ -394,9 +400,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
// Saves a cropped image of the screen to a bitmap/TGA file.
// TODO: debug-only feature, not implemented
return nullptr;
- case 143: // Nancy14
- case 144: // Nancy14
- // TODO: new paired AR, not yet implemented
+ case 143: // Nancy14 - ConcatSound
+ case 144: // Nancy14 - MultiSound (dropped from the Nancy15 dispatch)
+ // Sibling sound ARs. ConcatSound plays a list of named sounds back-to-back;
+ // TODO: not yet implemented
return nullptr;
case 145: // Nancy13
return new PlaySound(); // Moved from 150 in Nancy13
@@ -448,7 +455,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
return new PlayRandomSound();
case 159:
if (g_nancy->getGameType() >= kGameTypeNancy14)
- return nullptr; // Nancy14: new AR here, not PlaySoundTerse. TODO.
+ return new GridMapPuzzle(); // moved from 244
return new PlaySoundTerse();
case 160:
if (g_nancy->getGameType() >= kGameTypeNancy12)
@@ -498,19 +505,29 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
case 177: // HangmanPuzzle
// TODO: not yet implemented
return nullptr;
- case 178: // logic-grid / crossword-like puzzle, unconfirmed
+ case 178: // AdjustPuzzle
+ // TODO: not yet implemented
+ return nullptr;
+ case 179: // MeterPuzzle
+ // TODO: not yet implemented
+ return nullptr;
+ case 180: // BlockingPuzzle
+ // TODO: not yet implemented
+ return nullptr;
+ case 181: // PaintPuzzle
// TODO: not yet implemented
return nullptr;
- case 179: // small utility AR, unconfirmed
+ case 182: // DecoderPuzzle
// TODO: not yet implemented
return nullptr;
- case 180: // movie-driven puzzle, unconfirmed
+ // -- Nancy15 new puzzles (types 183-185) --
+ case 183: // MagicBoxPuzzle
// TODO: not yet implemented
return nullptr;
- case 181: // image/hotspot puzzle, unconfirmed
+ case 184: // EscapeGridPuzzle
// TODO: not yet implemented
return nullptr;
- case 182: // word puzzle, unconfirmed
+ case 185: // WeightSortPuzzle
// TODO: not yet implemented
return nullptr;
case 200:
Commit: 5507fb29d8693eb73e15000453db00e5e8795098
https://github.com/scummvm/scummvm/commit/5507fb29d8693eb73e15000453db00e5e8795098
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:54+03:00
Commit Message:
NANCY: NANCY13: Initial implementation of PachinkoPuzzle
Still needs fine tuning.
Pachinko / pinball ball-drop puzzle. 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 before the puzzle finishes.
Changed paths:
A engines/nancy/action/puzzle/pachinkopuzzle.cpp
A engines/nancy/action/puzzle/pachinkopuzzle.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 24987c0afa9..5902def9e5d 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -63,6 +63,7 @@
#include "engines/nancy/action/puzzle/onebuildpuzzle.h"
#include "engines/nancy/action/puzzle/orderingpuzzle.h"
#include "engines/nancy/action/puzzle/overridelockpuzzle.h"
+#include "engines/nancy/action/puzzle/pachinkopuzzle.h"
#include "engines/nancy/action/puzzle/passwordpuzzle.h"
#include "engines/nancy/action/puzzle/peepholepuzzle.h"
#include "engines/nancy/action/puzzle/pegspuzzle.h"
@@ -496,9 +497,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
case 174:
return new ScalePuzzle(); // balance scale
case 175:
- // PachinkoPuzzle (ball drop)
- // TODO: not yet implemented
- return nullptr;
+ return new PachinkoPuzzle(); // ball drop / pinball
case 176:
return new DropSortPuzzle(); // conveyor-belt candy sorting
// -- Nancy14 new puzzles (types 177-182) --
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
new file mode 100644
index 00000000000..40f3392cbd0
--- /dev/null
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -0,0 +1,532 @@
+/* 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/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/pachinkopuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// -- Physics constants --
+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
+
+static double wrapAngle(double a) {
+ while (a < 0.0) {
+ a += kTwoPi;
+ }
+ while (a >= kTwoPi) {
+ a -= kTwoPi;
+ }
+ return a;
+}
+
+void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
+ readFilename(stream, _imageName); // 0x00 - board overlay
+
+ // 167-byte header blob.
+ readRect(stream, _ballSrc); // ball sprite source
+ stream.skip(16); // four unidentified int32
+ _velMin = stream.readSint32LE(); // launch-speed floor
+ _velMax = stream.readSint32LE(); // launch-speed ceiling
+ _spawnYMin = stream.readSint32LE(); // launch-heading (deg) range
+ _spawnYMax = stream.readSint32LE();
+ stream.skip(4);
+ _launchVecLen = stream.readSint32LE();
+ stream.skip(4);
+ stream.skip(16); // four unidentified int32
+ _eventFlag = stream.readSint16LE(); // "in progress" flag
+ readFilename(stream, _ballImageName); // ball sprite sheet
+ readRect(stream, _launcherBallSrc);
+ readRect(stream, _launcherBallDest);
+ readRect(stream, _launcherHotspot); // the clickable launcher
+ _spawnWindowMin = stream.readSint32LE();
+ _spawnWindowMax = stream.readSint32LE(); // spawn window (ms)
+
+ // The random "plink" cues played each time a ball is launched (LeverPull*).
+ _plinkSounds.readData(stream);
+
+ // The two catch machines: Miner (win) then Yeti (lose).
+ readMachine(stream, _winMachine);
+ readMachine(stream, _loseMachine);
+
+ // Static pin collision rects.
+ int16 numPins = stream.readSint16LE();
+ if (numPins > 0) {
+ _pins.resize(numPins);
+ for (int16 i = 0; i < numPins; ++i) {
+ readRect(stream, _pins[i]);
+ }
+ }
+
+ // The polymorphic Nancy13 ActionZone array (bumpers / walls / overlays). The shared
+ // ActionZone reader handles the Nancy13 layout when told to.
+ 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.
+ int16 numExit = stream.readSint16LE();
+ for (int16 i = 0; i < numExit; ++i) {
+ Common::Rect r;
+ readRect(stream, r);
+ uint16 cursorType = stream.readUint16LE();
+ uint16 sceneID = stream.readUint16LE();
+ int16 exitFlagLabel = stream.readSint16LE();
+ byte exitFlagValue = stream.readByte();
+
+ if (i == 0) {
+ _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;
+ }
+ }
+}
+
+void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m) {
+ readFilename(stream, m.imageName); // the ANIM_OVL 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);
+ for (int16 i = 0; i < numFrames; ++i) {
+ readRect(stream, m.frames[i]);
+ }
+ }
+
+ // 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
+
+ // The 55-byte blob: its leading filename is the result movie (MUS_PachinkoWinANIM for
+ // the Miner); an empty filename means no movie.
+ byte blob[55];
+ stream.read(blob, sizeof(blob));
+ int len = 0;
+ while (len < 33 && blob[len] != 0) {
+ ++len;
+ }
+ m.movieName = Common::String((const char *)blob, len);
+
+ m.resultSound.readData(stream); // snd2 - the win/lose voice cue
+
+ stream.skip(1); // byte (unused here)
+ stream.skip(4); // int32 (unused here)
+
+ m.fastSound.readData(stream); // snd3 - the fast-winch cue
+}
+
+void PachinkoPuzzle::loadMachineImage(Machine &m) {
+ if (!m.imageName.empty()) {
+ g_nancy->_resource->loadImage(m.imageName, m.image);
+ m.image.setTransparentColor(_drawSurface.getTransparentColor());
+ }
+}
+
+void PachinkoPuzzle::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);
+ _image.setTransparentColor(_drawSurface.getTransparentColor());
+ if (!_ballImageName.empty()) {
+ g_nancy->_resource->loadImage(_ballImageName, _ballImage);
+ _ballImage.setTransparentColor(_drawSurface.getTransparentColor());
+ }
+
+ loadMachineImage(_winMachine);
+ loadMachineImage(_loseMachine);
+
+ NancySceneState.setNoHeldItem();
+
+ // In-progress marker: set on entry, cleared once a ball is caught.
+ if (_eventFlag > 0) {
+ NancySceneState.setEventFlag(_eventFlag, g_nancy->_true);
+ }
+
+ _pzState = kRunning;
+ _lastUpdate = g_nancy->getTotalPlayTime();
+
+ redraw();
+ registerGraphics();
+}
+
+SoundDescription PachinkoPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+ SoundDescription desc;
+ if (block.names.empty()) {
+ return desc;
+ }
+
+ 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 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);
+ return desc;
+}
+
+void PachinkoPuzzle::setDataCursor(uint16 cursorType) const {
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true);
+}
+
+void PachinkoPuzzle::spawnBall() {
+ uint32 now = g_nancy->getTotalPlayTime();
+ uint32 elapsed = now - _spawnClickTime;
+
+ // The launch parameter grows across the spawn window, plus a small random jitter.
+ double launchParam = kSpawnParamMax;
+ int span = _spawnWindowMax - _spawnWindowMin;
+ if ((int)elapsed <= _spawnWindowMax && span > 0) {
+ int rel = (int)elapsed - _spawnWindowMin;
+ if (rel < 0) {
+ rel = 0;
+ }
+ launchParam = ((double)rel / (double)span) * kSpawnParamMax;
+ }
+ launchParam += (double)(g_nancy->_randomSource->getRandomNumber(99)) * kSpawnParamJitter;
+
+ // The launch heading (degrees) comes from the [_spawnYMin,_spawnYMax] range.
+ double headingDeg = (double)_spawnYMin +
+ (double)(_spawnYMax - _spawnYMin) * (double)(g_nancy->_randomSource->getRandomNumber(99)) * kSpawnYJitter;
+
+ Ball ball;
+ ball.speed = (double)(_velMax - _velMin) * launchParam + (double)_velMin;
+ ball.angle = wrapAngle(headingDeg * kDeg2Rad);
+ ball.x = (_launcherHotspot.left + _launcherHotspot.right) / 2.0;
+ ball.y = (_launcherBallDest.top + _launcherBallDest.bottom) / 2.0;
+ ball.active = true;
+ _balls.push_back(ball);
+
+ playSoundBlock(_plinkSounds);
+}
+
+bool PachinkoPuzzle::collideBall(Ball &ball, double nx, double ny) const {
+ // Reflect off any pin or bumper rect the ball would enter. The axis of the smaller
+ // penetration decides which velocity component flips (an AABB response).
+ Common::Point p((int16)nx, (int16)ny);
+
+ const Common::Rect *hit = nullptr;
+ for (const Common::Rect &r : _pins) {
+ if (r.contains(p)) {
+ hit = &r;
+ break;
+ }
+ }
+ if (!hit) {
+ for (const ActionZone &z : _zones) {
+ // Walls (type 1/0x14) and bumpers (0x16) are solid; overlays are not.
+ if ((z.type == 0x01 || z.type == 0x14 || z.type == 0x16) && z.rect.contains(p)) {
+ hit = &z.rect;
+ if (!z._sound.names.empty()) {
+ const_cast<PachinkoPuzzle *>(this)->playSoundBlock(z._sound);
+ }
+ break;
+ }
+ }
+ }
+
+ if (!hit) {
+ return false;
+ }
+
+ double vx = cos(ball.angle) * ball.speed;
+ double vy = -sin(ball.angle) * ball.speed;
+
+ // Penetration depth on each axis; flip the axis with the shallower overlap.
+ double penLeft = nx - hit->left;
+ double penRight = hit->right - nx;
+ double penTop = ny - hit->top;
+ double penBottom = hit->bottom - ny;
+ double penX = MIN(penLeft, penRight);
+ double penY = MIN(penTop, penBottom);
+
+ if (penX < penY) {
+ vx = -vx;
+ } else {
+ vy = -vy;
+ }
+
+ ball.speed = sqrt(vx * vx + vy * vy) * kRestitution;
+ ball.angle = wrapAngle(atan2(-vy, vx));
+ return true;
+}
+
+void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
+ // Gravity: convert the polar velocity to cartesian, add gravity, convert back.
+ double vx = cos(ball.angle) * ball.speed;
+ double vy = -sin(ball.angle) * ball.speed;
+ vy += kGravity * dt;
+ ball.speed = sqrt(vx * vx + vy * vy);
+ ball.angle = wrapAngle(atan2(-vy, vx));
+
+ // Deceleration (drag).
+ ball.speed -= kDrag * dt;
+ if (ball.speed < 0.0) {
+ ball.speed = 0.0;
+ }
+
+ // Move, reflecting on a collision (single sub-step is sufficient at this frame rate).
+ double dist = ball.speed * dt;
+ double nx = ball.x + cos(ball.angle) * dist;
+ double ny = ball.y - sin(ball.angle) * dist;
+ if (!collideBall(ball, nx, ny)) {
+ ball.x = nx;
+ ball.y = ny;
+ }
+}
+
+bool PachinkoPuzzle::ballSettled(const Ball &ball, const Machine &m) const {
+ Common::Point c = m.catchPoint();
+ double dx = ball.x - c.x;
+ double dy = ball.y - c.y;
+ // A generous catch radius (the machine's mover end rect half-size).
+ int r = MAX(m.moverEnd.width(), m.moverEnd.height()) / 2 + 8;
+ return (dx * dx + dy * dy) <= (double)(r * r);
+}
+
+void PachinkoPuzzle::advanceMachine(Machine &m, uint32 now) {
+ if (m.frames.empty() || m.animRate <= 0) {
+ return;
+ }
+ uint32 period = 1000 / (uint32)m.animRate;
+ if (now >= m.nextFrameTime) {
+ m.frame = (m.frame + 1) % m.frames.size();
+ m.nextFrameTime = now + period;
+ }
+}
+
+void PachinkoPuzzle::redraw() {
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+ // The two machines (their current animation frame), drawn at their mover start.
+ const Machine *machines[2] = { &_winMachine, &_loseMachine };
+ for (int i = 0; i < 2; ++i) {
+ const Machine &m = *machines[i];
+ if (m.frames.empty() || m.image.empty()) {
+ continue;
+ }
+ const Common::Rect &src = m.frames[m.frame % m.frames.size()];
+ Common::Point pos(m.moverStart.left, m.moverStart.top);
+ _drawSurface.blitFrom(m.image, src, pos);
+ }
+
+ // The balls in flight.
+ if (!_ballImage.empty() && !_ballSrc.isEmpty()) {
+ int w = _ballSrc.width();
+ int h = _ballSrc.height();
+ for (const Ball &ball : _balls) {
+ if (!ball.active) {
+ continue;
+ }
+ Common::Point pos((int16)ball.x - w / 2, (int16)ball.y - h / 2);
+ _drawSurface.blitFrom(_ballImage, _ballSrc, pos);
+ }
+ }
+
+ // The result animation, when playing.
+ if (_pzState == kWaitResult && _resultMovie.isVideoLoaded()) {
+ _resultMovie.drawFrame(_drawSurface, Common::Point(0, 0));
+ }
+
+ _needsRedraw = true;
+}
+
+void PachinkoPuzzle::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+ _state = kRun;
+ // fall through
+ case kRun: {
+ uint32 now = g_nancy->getTotalPlayTime();
+ double dt = (now - _lastUpdate) / 1000.0;
+ _lastUpdate = now;
+ if (dt > 0.1) {
+ dt = 0.1; // clamp long stalls
+ }
+
+ if (_exitRequested) {
+ _pzState = kComplete;
+ }
+
+ switch (_pzState) {
+ case kRunning: {
+ if (_spawnPending) {
+ spawnBall();
+ _spawnPending = false;
+ }
+
+ advanceMachine(_winMachine, now);
+ advanceMachine(_loseMachine, now);
+
+ for (uint i = 0; i < _balls.size(); ++i) {
+ Ball &ball = _balls[i];
+ if (!ball.active) {
+ continue;
+ }
+ stepBall(ball, dt);
+
+ // A caught ball decides the outcome and starts that machine's result.
+ if (ballSettled(ball, _winMachine)) {
+ ball.active = false;
+ _solved = true;
+ _activeMachine = &_winMachine;
+ _pzState = kPlayResult;
+ break;
+ }
+ if (ballSettled(ball, _loseMachine)) {
+ ball.active = false;
+ _solved = false;
+ _activeMachine = &_loseMachine;
+ _pzState = kPlayResult;
+ break;
+ }
+
+ // A ball that leaves the board is discarded.
+ if (ball.y > _drawSurface.h + 64 || ball.x < -64 || ball.x > _drawSurface.w + 64) {
+ ball.active = false;
+ }
+ }
+
+ redraw();
+ break;
+ }
+ case kPlayResult: {
+ if (_eventFlag > 0) {
+ NancySceneState.setEventFlag(_eventFlag, g_nancy->_false);
+ }
+ if (_activeMachine) {
+ _resultSoundDesc = playSoundBlock(_activeMachine->resultSound);
+ if (!_activeMachine->movieName.empty() &&
+ _resultMovie.loadFile(_activeMachine->movieName) &&
+ _resultMovie.getFrameCount() > 0) {
+ _resultMovie.playRange(0, _resultMovie.getFrameCount() - 1);
+ }
+ }
+ _resultTime = now;
+ _pzState = kWaitResult;
+ redraw();
+ break;
+ }
+ case kWaitResult: {
+ bool movieDone = !_resultMovie.isVideoLoaded() ||
+ (!_resultMovie.isRangePlaying());
+ if (_resultMovie.isVideoLoaded() && _resultMovie.update()) {
+ redraw();
+ }
+ bool soundDone = _resultSoundDesc.name.empty() ||
+ !g_nancy->_sound->isSoundPlaying(_resultSoundDesc);
+ if (movieDone && soundDone && now - _resultTime > 500) {
+ if (_resultMovie.isVideoLoaded()) {
+ _resultMovie.close();
+ }
+ _pzState = kComplete;
+ }
+ break;
+ }
+ case kComplete:
+ _state = kActionTrigger;
+ break;
+ }
+
+ 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);
+ if (_exitScene.sceneID != kNoScene) {
+ NancySceneState.changeScene(_exitScene);
+ }
+ finishExecution();
+ break;
+ }
+}
+
+void PachinkoPuzzle::handleInput(NancyInput &input) {
+ if (_state != kRun || _pzState != kRunning) {
+ return;
+ }
+
+ const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+ if (!_launcherHotspot.isEmpty() &&
+ NancySceneState.getViewport().convertViewportToScreen(_launcherHotspot).contains(input.mousePos)) {
+ setDataCursor(_exitCursorType);
+ if (click) {
+ // One launcher click queues one ball.
+ _spawnPending = true;
+ _spawnClickTime = g_nancy->getTotalPlayTime();
+ }
+ input.eatMouseInput();
+ return;
+ }
+
+ if (!_exitHotspot.isEmpty() &&
+ NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+ setDataCursor(_exitCursorType);
+ if (click) {
+ _exitRequested = true;
+ }
+ }
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
new file mode 100644
index 00000000000..77f6afc876c
--- /dev/null
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -0,0 +1,172 @@
+/* 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_PACHINKOPUZZLE_H
+#define NANCY_ACTION_PACHINKOPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/action/actionzone.h"
+
+namespace Nancy {
+namespace Action {
+
+// 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).
+class PachinkoPuzzle : public RenderActionRecord {
+public:
+ PachinkoPuzzle() : RenderActionRecord(7) {}
+ virtual ~PachinkoPuzzle() {}
+
+ 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 "PachinkoPuzzle"; }
+
+ // One of the two "machine" sub-objects (Miner = win, Yeti = lose). Each is an
+ // animated sprite whose frames slide from a start point to an end (catch) point.
+ struct Machine {
+ Common::Path imageName; // the ANIM_OVL sprite strip
+ int32 animRate = 0; // frames per second
+ Common::Array<Common::Rect> frames; // sprite-strip source rects
+ Common::Rect moverStart; // slide path start rect (its centre is the anchor)
+ Common::Rect moverEnd; // slide path end / catch rect
+ int32 moverSpeed = 0;
+ 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)
+
+ Graphics::ManagedSurface image;
+ uint frame = 0; // current animation frame
+ uint32 nextFrameTime = 0;
+
+ Common::Point catchPoint() const {
+ return Common::Point((moverEnd.left + moverEnd.right) / 2,
+ (moverEnd.top + moverEnd.bottom) / 2);
+ }
+ };
+
+ // A single launched ball. Physics run in viewport space; the heading is stored as a
+ // scalar speed plus a heading angle in radians.
+ struct Ball {
+ double x = 0.0;
+ double y = 0.0;
+ double speed = 0.0; // px / second
+ double angle = 0.0; // radians; velocity = (cos a, -sin a) * speed
+ uint frame = 0;
+ bool active = false;
+ };
+
+ void readMachine(Common::SeekableReadStream &stream, Machine &m);
+ void loadMachineImage(Machine &m);
+
+ void redraw();
+ void spawnBall();
+ void stepBall(Ball &ball, double dt);
+ bool collideBall(Ball &ball, double nx, double ny) const;
+ bool ballSettled(const Ball &ball, const Machine &m) const;
+ void advanceMachine(Machine &m, uint32 now);
+ SoundDescription playSoundBlock(const RandomSoundBlock &block);
+ void setDataCursor(uint16 cursorType) const;
+
+ // -- File data (167-byte header, in stream order) --
+ Common::Path _imageName; // 0x00 - board overlay (MUS_PachinkoPUZ02_OVL)
+
+ Common::Rect _ballSrc; // ball sprite source in the overlay
+ int32 _velMin = 0; // launch-speed floor
+ int32 _velMax = 0; // launch-speed ceiling
+ int32 _spawnYMin = 0; // launch-heading (deg) range
+ int32 _spawnYMax = 0;
+ int32 _launchVecLen = 0; // decorative launch nub length
+ int16 _eventFlag = 0; // "in progress" flag id
+ Common::Path _ballImageName; // the ball sprite sheet
+ Common::Rect _launcherBallSrc; // ball-in-launcher sprite src
+ Common::Rect _launcherBallDest; // ball-in-launcher dest
+ Common::Rect _launcherHotspot; // the clickable launcher
+ int32 _spawnWindowMin = 0;
+ int32 _spawnWindowMax = 0; // spawn window (ms)
+
+ RandomSoundBlock _plinkSounds; // random ball-launch cues (LeverPull*)
+
+ 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)
+
+ // The give-up / exit hotspot (the base trailer's 23-byte record).
+ Common::Rect _exitHotspot;
+ uint16 _exitCursorType = 0;
+ SceneChangeDescription _exitScene;
+ FlagDescription _exitFlag; // set on give-up
+
+ // -- Runtime state --
+ enum State {
+ kRunning, // launcher live, balls in flight
+ kPlayResult, // a ball was caught; start the machine's result movie
+ kWaitResult, // wait for the result movie / cue
+ kComplete // finish -> scene change
+ };
+ State _pzState = kRunning;
+
+ 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;
+ bool _exitRequested = false;
+ uint32 _lastUpdate = 0;
+ uint32 _resultTime = 0;
+ SoundDescription _resultSoundDesc;
+
+ Graphics::ManagedSurface _image; // board overlay
+ Graphics::ManagedSurface _ballImage; // ball sprite sheet
+ MoviePlayer _resultMovie;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_PACHINKOPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 424915e92c4..0a2c3644ef5 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -47,6 +47,7 @@ MODULE_OBJS = \
action/puzzle/onebuildpuzzle.o \
action/puzzle/orderingpuzzle.o \
action/puzzle/overridelockpuzzle.o \
+ action/puzzle/pachinkopuzzle.o \
action/puzzle/passwordpuzzle.o \
action/puzzle/peepholepuzzle.o \
action/puzzle/pegspuzzle.o \
Commit: 8541ea72ec3431fb34f091934dead67e94c96104
https://github.com/scummvm/scummvm/commit/8541ea72ec3431fb34f091934dead67e94c96104
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:55+03:00
Commit Message:
NANCY: NANCY14: Implement new SecondaryMovie functionality for N14/N15
Changed paths:
engines/nancy/action/actionzone.cpp
engines/nancy/action/arfactory.cpp
engines/nancy/action/secondarymovie.cpp
engines/nancy/action/secondarymovie.h
diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index c7eb6066ccd..6a2d59f0c78 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -28,7 +28,7 @@ namespace Nancy {
namespace Action {
void ActionZone::readData(Common::SeekableReadStream &stream, bool isNancy13) {
- // Base ActionZone (matches the original "Action Zone Boundary OVL" reader).
+ // Base ActionZone fields, shared by every subtype.
typeField = stream.readSint32LE();
type = typeField & 0xFF;
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 5902def9e5d..437943d42aa 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -216,9 +216,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
return new PlaySecondaryMovie(true);
case 46: // Nancy11
return new PlayRandomMovieControl();
- case 47: // Nancy14 - PlaySecondaryMovie subclass with a per-frame flag list
- // TODO: not yet implemented
- return nullptr;
+ case 47: // Nancy14
+ // A PlaySecondaryMovie subclass that appends a named {value, flag} list.
+ // Handled inside PlaySecondaryMovie via _type == 47.
+ return new PlaySecondaryMovie();
case 50:
return new ConversationVideo(); // PlayPrimaryVideoChan0
case 51:
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index bbf06540bab..a8779baeaf1 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -174,6 +174,58 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
_videoDescs[i].readData(stream);
}
+ applyStartingRandomSequence();
+}
+
+// Nancy14+ random-movie layout (verified byte-identical in Nancy14 and Nancy15).
+// The header grew to mirror the non-random AR (videoFormat / visibility / cursor
+// / sceneID / frameID, plus two currently unmapped u16s and a per-movie volume
+// byte). The sequence records are unchanged. The tail is a blt-descriptor list
+// for the main movie, then the recognition ("secondary") movie's name and its
+// own blt-descriptor list, in place of Nancy13's secondaryMovie record + hotspot
+// list.
+void PlaySecondaryMovie::readRandomMovieDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream) {
+ readFilename(ser, _startingSequenceName);
+
+ ser.syncAsUint16LE(_videoFormat);
+ _videoFormat = kLargeVideoFormat;
+ ser.skip(2); // Visibility frame ID; ScummVM drives visibility from the videoDescs
+ ser.syncAsUint16LE(_randomPlayerCursorAllowed);
+ ser.skip(4); // Two u16s (object offsets 0x8c / 0xe7); purpose not yet mapped
+ ser.syncAsSint16LE(_sceneChange.sceneID);
+ ser.syncAsUint16LE(_sceneChange.frameID);
+ ser.skip(1); // Per-movie volume byte (movie sound off since Nancy6)
+
+ uint16 sequenceCount = 0;
+ ser.syncAsUint16LE(sequenceCount);
+ _sequences.resize(sequenceCount);
+ for (uint i = 0; i < sequenceCount; ++i) {
+ readRandomSequence(ser, _sequences[i]);
+ }
+
+ // Main movie blt/hotspot descriptors.
+ uint16 numVideoDescs = 0;
+ ser.syncAsUint16LE(numVideoDescs);
+ _videoDescs.resize(numVideoDescs);
+ for (uint i = 0; i < numVideoDescs; ++i) {
+ _videoDescs[i].readData(stream);
+ }
+
+ // Recognition ("secondary") movie: its name followed by its own blt
+ // descriptors. Stored for future playback; the descriptors are consumed to
+ // keep the stream aligned (no home in the struct yet).
+ readFilename(ser, _secondaryMovie.name);
+ uint16 numSecondaryDescs = 0;
+ ser.syncAsUint16LE(numSecondaryDescs);
+ for (uint i = 0; i < numSecondaryDescs; ++i) {
+ SecondaryVideoDescription unused;
+ unused.readData(stream);
+ }
+
+ applyStartingRandomSequence();
+}
+
+void PlaySecondaryMovie::applyStartingRandomSequence() {
// "RandomMovie" picks any sequence; otherwise look up by name.
// Only the first sequence is played; chained playback is TODO.
if (!_sequences.empty()) {
@@ -366,7 +418,14 @@ int PlaySecondaryMovie::rollNextSequence() {
// 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
// AR 41 plus a trailing movie-volume byte.
+//
+// Nancy15 adds two things on top: AR 44 gained a "play style" u16 (1/3) after
+// the hide-on-finish flag, and the firstFrame field can be -1 (LOOP_RANDOM),
+// in which case a min/max loop-count pair follows and a random value in that
+// range is chosen.
void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream) {
+ const bool isNancy15 = g_nancy->getGameType() >= kGameTypeNancy15;
+
readFilename(ser, _videoName);
ser.syncAsUint16LE(_videoFormat);
@@ -375,7 +434,25 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
ser.skip(2); // Visibility frame ID; ScummVM drives visibility from the videoDescs instead
ser.syncAsUint16LE(_playerCursorAllowed);
ser.syncAsUint16LE(_hideOnFinish);
+
+ // AR 44 and its subclass AR 47 read the play-style field (retail "mode 0");
+ // AR 41 ("mode 1") does not.
+ if (isNancy15 && (_type == 44 || _type == 47)) {
+ ser.syncAsUint16LE(_playStyle);
+ }
+
ser.syncAsUint16LE(_firstFrame);
+
+ if (isNancy15 && (int16)_firstFrame == -1) {
+ // LOOP_RANDOM: firstFrame -1 is followed by a min/max loop count; the
+ // game picks a random value in [min, max) (min must be < max).
+ uint16 minLoops = 0, maxLoops = 0;
+ ser.syncAsUint16LE(minLoops);
+ ser.syncAsUint16LE(maxLoops);
+ _firstFrame = maxLoops > minLoops ?
+ (uint16)(minLoops + g_nancy->_randomSource->getRandomNumber(maxLoops - minLoops - 1)) : minLoops;
+ }
+
ser.syncAsUint16LE(_lastFrame);
ser.syncAsUint16LE(_playDirection);
ser.syncAsSint16LE(_sceneChange.sceneID);
@@ -383,8 +460,10 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
_videoSceneChange = _sceneChange.sceneID != kNoScene ? kMovieSceneChange : kMovieNoSceneChange;
- if (_type == 44) {
- // Per-movie volume; consumed but unused (movie sound is off since Nancy6).
+ // Per-movie volume; consumed but unused (movie sound is off since Nancy6).
+ // AR 44 always carries it. AR 47 does too, but only from Nancy15 - the
+ // retail flipped the "mode" convention, and Nancy14's AR 47 omits the byte.
+ if (_type == 44 || (_type == 47 && isNancy15)) {
byte movieVolume = 0;
ser.syncAsByte(movieVolume);
}
@@ -406,6 +485,24 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
}
_sound.name = "NO SOUND";
+
+ // AR 47 ("InteractiveVideo") appends a name, a flag byte, and a list of
+ // named {value, flag} entries on top of the AR-44 movie data.
+ if (_type == 47) {
+ readFilename(ser, _interactiveName);
+ byte flag = 0;
+ ser.syncAsByte(flag);
+ _interactiveFlag = flag != 0;
+
+ uint16 numEntries = 0;
+ ser.syncAsUint16LE(numEntries);
+ _interactiveEntries.resize(numEntries);
+ for (uint i = 0; i < numEntries; ++i) {
+ readFilename(ser, _interactiveEntries[i].name);
+ ser.syncAsUint32LE(_interactiveEntries[i].value);
+ ser.syncAsByte(_interactiveEntries[i].flag);
+ }
+ }
}
void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
@@ -413,7 +510,13 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
ser.setVersion(g_nancy->getGameType());
if (_isRandom) {
- readRandomMovieData(ser, stream);
+ // Nancy14 reworked the random-movie layout (Nancy13 and earlier use the
+ // older secondaryMovie-record + hotspot-list form).
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ readRandomMovieDataNancy14(ser, stream);
+ } else {
+ readRandomMovieData(ser, stream);
+ }
return;
}
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 3073c61f35a..29eccf57c52 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -103,6 +103,21 @@ public:
uint16 _lastFrame = 0;
// Nancy14-only: when non-zero, hide the movie once it reaches its last frame.
uint16 _hideOnFinish = 0;
+ // Nancy15 AR 44: a "play style" selector (1 or 3). Read but currently
+ // unused by playback.
+ uint16 _playStyle = 1;
+
+ // AR 47 "InteractiveVideo" (a PlaySecondaryMovie subclass): after the
+ // normal AR-44-style movie data it carries a name, a flag byte, and a
+ // list of named {value, flag} entries. Read but not yet acted on.
+ struct InteractiveEntry {
+ Common::Path name;
+ uint32 value = 0;
+ byte flag = 0;
+ };
+ Common::Path _interactiveName;
+ bool _interactiveFlag = false;
+ Common::Array<InteractiveEntry> _interactiveEntries;
Common::Array<FlagAtFrame> _frameFlags;
MultiEventFlagDescription _triggerFlags;
FlagDescription _videoStartFlag;
@@ -173,9 +188,18 @@ protected:
// `ser` and `stream` must wrap the same input; `stream` is only
// needed for SecondaryVideoDescription::readData.
void readRandomMovieData(Common::Serializer &ser, Common::SeekableReadStream &stream);
+ // Nancy14 reworked the random-movie layout (confirmed identical in Nancy15):
+ // a larger header (shared with the non-random AR) and a tail of two
+ // blt-descriptor lists separated by the recognition movie's name, in place
+ // of Nancy13's secondaryMovie record + hotspot list.
+ void readRandomMovieDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream);
void readRandomSequence(Common::Serializer &ser, RandomSequence &seq);
void readSecondaryRandomMovie(Common::Serializer &ser, RandomSequence &seq);
+ // Shared tail of the random-movie readers: pick the starting sequence
+ // (random or by name) and seed the flat playback fields from it.
+ void applyStartingRandomSequence();
+
void readDataNancy14(Common::Serializer &ser, Common::SeekableReadStream &stream);
// Apply a RandomSequence's playback config to the PSM flat fields
Commit: 8a854b3f18a13a2999242a336508a67ab12b04a2
https://github.com/scummvm/scummvm/commit/8a854b3f18a13a2999242a336508a67ab12b04a2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-21T03:55:56+03:00
Commit Message:
NANCY: NANCY15: Initial handling for the per-character UI functionality
- Per-character CIF trees and UI chunks are now loaded for the default
character (Nancy). These will need to be reloaded when the character
changes
- Implement loaders for the LVLN, PCUI, LDSN, PUIH, PUIV data chunks
Now, Nancy15 starts, but crashes after the initial new menu video
finishes
Changed paths:
engines/nancy/enginedata.cpp
engines/nancy/enginedata.h
engines/nancy/nancy.cpp
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 0c2c0f20a45..8f0434683cb 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1304,4 +1304,71 @@ MMIX::MMIX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
}
}
+LVLN::LVLN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+ // The count is the total number of strings, which always comes in
+ // (code, name) pairs - hence it must be even.
+ const uint16 count = chunkStream->readUint16LE();
+ const uint16 numPairs = count / 2;
+ levelCodes.resize(numPairs);
+ levelNames.resize(numPairs);
+
+ for (uint16 i = 0; i < numPairs; ++i) {
+ readFilename(*chunkStream, levelCodes[i]);
+ readFilename(*chunkStream, levelNames[i]);
+ }
+}
+
+PCUI::PCUI(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+ flag = chunkStream->readByte();
+ const uint16 count = chunkStream->readUint16LE();
+ characters.resize(count);
+
+ for (uint16 i = 0; i < count; ++i) {
+ // Each entry begins with the slot index it populates.
+ const byte slot = chunkStream->readByte();
+ Character &chr = (slot < characters.size()) ? characters[slot] : characters[i];
+ readFilename(*chunkStream, chr.imageName);
+ readFilename(*chunkStream, chr.defaultImageName);
+ chr.id = chunkStream->readUint16LE();
+ }
+}
+
+LDSN::LDSN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+ readFilename(*chunkStream, backgroundImageName);
+ readFilename(*chunkStream, overlayImageName);
+
+ // The remainder is a run of button/selection rects (16 bytes each),
+ // followed by a short trailer whose fields aren't fully understood yet.
+ while (chunkStream->pos() + 16 <= chunkStream->size()) {
+ Common::Rect rect;
+ readRect(*chunkStream, rect);
+ rects.push_back(rect);
+ }
+}
+
+PUIH::PUIH(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+ flag = chunkStream->readByte();
+ readFilename(*chunkStream, themeName);
+ readFilename(*chunkStream, swatchImageName);
+}
+
+PUIV::PUIV(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
+ readFilename(*chunkStream, name);
+ channelID = chunkStream->readUint16LE();
+ unknown = chunkStream->readUint32LE();
+ volume = chunkStream->readUint16LE();
+
+ const uint16 count = chunkStream->readUint16LE();
+ soundGroups.resize(count);
+ for (uint16 i = 0; i < count; ++i) {
+ SoundGroup &group = soundGroups[i];
+ group.tag = chunkStream->readByte();
+ const uint16 numVariants = chunkStream->readUint16LE();
+ group.variants.resize(numVariants);
+ for (uint16 j = 0; j < numVariants; ++j) {
+ readFilename(*chunkStream, group.variants[j]);
+ }
+ }
+}
+
} // End of namespace Nancy
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index c5320e45ac8..94c8d725c04 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -838,6 +838,71 @@ struct MMIX : public EngineData {
Common::Array<Record> records;
};
+
+// Level name table. Introduced in Nancy 15. Maps a short scene-prefix
+// code (e.g. "KAP") to its human-readable level name (e.g. "Kapu Cave").
+struct LVLN : public EngineData {
+ LVLN(Common::SeekableReadStream *chunkStream);
+
+ Common::Array<Common::String> levelCodes; // e.g. "KAP"
+ Common::Array<Common::String> levelNames; // e.g. "Kapu Cave"
+};
+
+// Player-character selector UI. Introduced in Nancy 15, where the player
+// alternates between Nancy and the Hardy Boys.
+// Each entry supplies the image names for one selectable character.
+struct PCUI : public EngineData {
+ struct Character {
+ Common::String imageName; // e.g. "PUI_CRE_Nancy"
+ Common::String defaultImageName; // e.g. "PUI_CRE_Nancy_Default"
+ uint16 id = 0;
+ };
+
+ PCUI(Common::SeekableReadStream *chunkStream);
+
+ byte flag = 0;
+ Common::Array<Character> characters; // indexed by the on-disk slot byte
+};
+
+// Fixed layout/graphics block for the Nancy 15 player-character ("Design
+// Select") switcher screen. Companion to PCUI. Supplies the background and
+// overlay image names plus the on-screen button/selection rects.
+struct LDSN : public EngineData {
+ LDSN(Common::SeekableReadStream *chunkStream);
+
+ Common::String backgroundImageName; // "UI_DesignSelectBG"
+ Common::String overlayImageName; // "UI_DesignSelect_OVL"
+ Common::Array<Common::Rect> rects; // button + per-character selection rects
+};
+
+// Player-UI header. Introduced in Nancy 15, first chunk of each character's
+// PUI_CRE_<char>_DEFAULT_BOOT file. Names the UI theme and its swatch image.
+struct PUIH : public EngineData {
+ PUIH(Common::SeekableReadStream *chunkStream);
+
+ byte flag = 0;
+ Common::String themeName; // e.g. "Nancy Classic Look (Default)"
+ Common::String swatchImageName; // e.g. "UI_Swatch_ND"
+};
+
+// Player-UI random-sound bank. Introduced in Nancy 15 (per-character boot).
+// Holds a base name plus a channel/volume and a list of sound groups, each
+// group being a set of interchangeable (randomly-picked) sound variants -
+// e.g. the character's "can't do that" response cues.
+struct PUIV : public EngineData {
+ struct SoundGroup {
+ byte tag = 0;
+ Common::Array<Common::String> variants;
+ };
+
+ PUIV(Common::SeekableReadStream *chunkStream);
+
+ Common::String name; // e.g. "DEF_ND_CANT"
+ uint16 channelID = 0; // shared playback channel (inferred)
+ uint32 unknown = 0;
+ uint16 volume = 0; // shared volume (inferred)
+ Common::Array<SoundGroup> soundGroups;
+};
} // End of namespace Nancy
#endif // NANCY_ENGINEDATA_H
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 8361b859b8b..e2579c6b2cd 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -484,6 +484,18 @@ void NancyEngine::bootGameEngine() {
_resource->readCifTree("ciftree", "dat", 1);
_resource->readCifTree("promotree", "dat", 1);
+ if (getGameType() >= kGameTypeNancy15) {
+ _resource->readCifTree("PUI_CRE_Nancy_Default", "dat", 1);
+ // Other player character CIF trees are loaded on demand,
+ // based on the PCUI chunk:
+ // - PUI_CRE_Nancy_Jungle
+ // - PUI_CRE_Nancy_Pink_Hibiscus
+ // - PUI_CRE_Nancy_Teal_Hibiscus
+ // - PUI_CRE_Frank_Default
+ // - PUI_CRE_HB_Default
+ // - PUI_CRE_Joe_Default
+ }
+
// Read the static data. Up to Nancy11 it lives in nancy.dat; from Nancy12
// onwards the game ships it in its own data files, so the engine only needs
// to provide the few remaining hardcoded values itself.
@@ -577,12 +589,13 @@ void NancyEngine::bootGameEngine() {
LOAD_BOOT(UICM) // Camera UI
// Nancy 15+
- // TASK, UIRC, UIIV, UICO, UICM, UICL,
- // UIBW, UINB, SCTB, CURT, TMOD chunks have
- // been removed
- // LOAD_BOOT(LVLN)
- // LOAD_BOOT(PCUI)
- // LOAD_BOOT(LDSN)
+ // The EVNT, UICO, SCTB, TASK, UIRC, UIIV, UICM, UICL, UIBW, UINB chunks, plus two new ones
+ // (PUIH, PUIV) have been moved from the BOOT chunk to different per-character chunks,
+ // based on names specified in the PCUI chunk.
+ // For now, we load Nancy's chunk at boot below, from PUI_CRE_NANCY_DEFAULT_BOOT
+ LOAD_BOOT(LVLN) // Level name table (scene-prefix code -> display name)
+ LOAD_BOOT(PCUI) // Player-character selector (Nancy / Frank / Joe)
+ LOAD_BOOT(LDSN) // Player-character "Design Select" screen layout
_cursor->init(iff->getChunkStream("CURS"));
@@ -605,6 +618,25 @@ void NancyEngine::bootGameEngine() {
delete iff;
+ if (getGameType() >= kGameTypeNancy15) {
+ const PCUI *pcui = GetEngineData(PCUI);
+ // Note: the default character is Nancy, so we load her boot chunks here. Her CIF name is
+ // PUI_CRE_NANCY_DEFAULT_BOOT.
+ iff = _resource->loadIFF(Common::Path(pcui->characters[0].defaultImageName + "_boot"));
+ LOAD_BOOT(TASK)
+ LOAD_BOOT(UIIV)
+ LOAD_BOOT(UICO)
+ LOAD_BOOT(UICL)
+ LOAD_BOOT(UIBW)
+ LOAD_BOOT(UINB)
+ LOAD_BOOT(SCTB)
+ LOAD_BOOT(UIRC)
+ LOAD_BOOT(UICM)
+ LOAD_BOOT(PUIH) // Player-UI header (theme name + swatch image)
+ LOAD_BOOT(PUIV) // Player-UI random-sound bank ("can't" responses)
+ delete iff;
+ }
+
if (getGameType() >= kGameTypeNancy12) {
LOAD_CHUNK("FLAGS", EVNT, "EVNT", "EVNT")
More information about the Scummvm-git-logs
mailing list