[Scummvm-git-logs] scummvm master -> f6315c643fec56c4635a8d72a23ff816f1a9ae48
bluegr
noreply at scummvm.org
Sun Jun 21 22:02:22 UTC 2026
This automated email contains information about 11 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .
Summary:
3c84d71946 NANCY: Data chunk handling updates for Nancy 10 and 11
3f35533111 NANCY: Implement the random movie playing feature for Nancy 11+
33273628ce NANCY: Remove leftover debug code in GridMapPuzzle
940a57fd1b NANCY: Implement TypingQuizPuzzle for Nancy11
10e9821f6b NANCY: Implement new MemoryPuzzle functionality for Nancy11+
979db279fc NANCY: Implement new OrderingPuzzle functionality for Nancy11
6267ae7f62 NANCY: Don't capture events in the text box when it's not visible
cf19cffcfa NANCY: Remove an unneeded assignment in MemoryPuzzle
3f823089d8 NANCY: Implement new functionality for BulPuzzle in Nancy11
b41dc6fb3b NANCY: Add new functionality for CollisionPuzzle in Nancy11
f6315c643f NANCY: Implement the FadeSoundToSilence AR for Nancy11
Commit: 3c84d71946ad5da6f3ea4c270eced4add1ef81d7
https://github.com/scummvm/scummvm/commit/3c84d71946ad5da6f3ea4c270eced4add1ef81d7
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:53+03:00
Commit Message:
NANCY: Data chunk handling updates for Nancy 10 and 11
- Remove the adjustments in the TBOX offsets
- Implement handling for the SCTB (Scrollable text box) chunk for
Nancy 11 and newer
- Skip two new fields added in the UICL chunk. This fixes the
cellphone for Nancy 11
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 482e0c69890..3cd219eb518 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -253,9 +253,10 @@ TBOX::TBOX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
scrollbarDefaultPos.y = chunkStream->readUint16LE();
scrollbarMaxScroll = chunkStream->readUint16LE();
- upOffset = chunkStream->readUint16LE() + 1;
+ uint16 legacyOffsetAdjust = g_nancy->getGameType() < kGameTypeNancy10 ? 1 : 0;
+ upOffset = chunkStream->readUint16LE() + legacyOffsetAdjust;
downOffset = chunkStream->readUint16LE();
- leftOffset = chunkStream->readUint16LE() - 1;
+ leftOffset = chunkStream->readUint16LE() - legacyOffsetAdjust;
rightOffset = chunkStream->readUint16LE();
if (g_nancy->getGameType() <= kGameTypeNancy9) {
@@ -872,8 +873,8 @@ MARK::MARK(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
}
SCTB::SCTB(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
- readFilename(*chunkStream, imageName);
- // TODO
+ readUIPopupHeader(*chunkStream, header);
+ readRect(*chunkStream, restoreSrcRect);
}
SHUI::SHUI(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
@@ -994,6 +995,13 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
readRect(*chunkStream, connectingSpriteSrc);
readRect(*chunkStream, connectingSpriteSrcAlt);
readRect(*chunkStream, connectingSpriteDest);
+
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ // TODO: Looks to be a new coordinate - values (548, 50)
+ chunkStream->skip(4);
+ chunkStream->skip(4);
+ }
+
readRect(*chunkStream, onlineHeading.srcRect);
readRect(*chunkStream, onlineHeading.destRect);
readRect(*chunkStream, fullEmptyScreenSrc);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 11d29695d9d..bf430b4a2e4 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -506,13 +506,13 @@ struct SHUI : public EngineData {
Common::Array<Common::Rect> _sliderRects; // Slider rects
};
-// ScrollTextBox - introduced in Nancy 11. Configuration for the scrollable
-// text-box UI used for long textbox content (e.g. journal / scheduled-talk
-// panels) that needs to scroll within a fixed frame.
+// ScrollTextBox - introduced in Nancy 11. Standard UI popup header,
+// followed by the rect to restore when the text box is closed.
struct SCTB : public EngineData {
SCTB(Common::SeekableReadStream *chunkStream);
- Common::Path imageName;
+ UIPopupHeader header;
+ Common::Rect restoreSrcRect;
};
enum TaskButton {
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index 243c95591d9..ff4da9ff9bb 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -487,12 +487,12 @@ void NancyEngine::bootGameEngine() {
LOAD_BOOT(TASK) // Task bar (main UI)
LOAD_BOOT(UIIV) // Inventory UI
LOAD_BOOT(UICO) // Conversation UI
- LOAD_BOOT(UICL) // Cell phone UI
- LOAD_BOOT(UIBW) // Web browser UI
- LOAD_BOOT(UINB) // Notebook UI
+ LOAD_BOOT(UICL) // Cell phone UI
+ LOAD_BOOT(UIBW) // Web browser UI
+ LOAD_BOOT(UINB) // Notebook UI
// Nancy 11+
- LOAD_BOOT(SCTB) // Scheduled talk (?) UI
+ LOAD_BOOT(SCTB) // Scrollable text box UI
// Nancy 12+
// HINT chunk has been removed
@@ -503,7 +503,7 @@ void NancyEngine::bootGameEngine() {
// RCPR and RCLB chunks have been removed
// LOAD_BOOT(MMIX)
- // Nancy 14+
+ // Nancy 14
// LOAD_BOOT(UICM)
// Nancy 15+
Commit: 3f355331111a51c4ef7bde975791e8e2bcd5c854
https://github.com/scummvm/scummvm/commit/3f355331111a51c4ef7bde975791e8e2bcd5c854
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:55+03:00
Commit Message:
NANCY: Implement the random movie playing feature for Nancy 11+
Nancy 11 and newer games used movies that played randomly when talking
to certain characters. These movies are loaded when a conversation
starts, and are unloaded when it ends by the newly introduced AR
PlayRandomMovieControl
Changed paths:
engines/nancy/action/actionmanager.cpp
engines/nancy/action/actionrecord.h
engines/nancy/action/conversation.cpp
engines/nancy/action/secondarymovie.cpp
engines/nancy/action/secondarymovie.h
engines/nancy/state/scene.cpp
engines/nancy/state/scene.h
diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 7cd07675549..8802874824f 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -568,10 +568,14 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
}
void ActionManager::clearActionRecords() {
- for (auto &r : _records) {
- delete r;
+ for (auto it = _records.begin(); it != _records.end(); ) {
+ if ((*it)->isPersistentAcrossScenes()) {
+ ++it;
+ continue;
+ }
+ delete *it;
+ it = _records.erase(it);
}
- _records.clear();
_activatedRecordsThisFrame.clear();
_previousRecordWasExecuted = false;
}
diff --git a/engines/nancy/action/actionrecord.h b/engines/nancy/action/actionrecord.h
index 887fbc02afa..5b18d164f0e 100644
--- a/engines/nancy/action/actionrecord.h
+++ b/engines/nancy/action/actionrecord.h
@@ -123,6 +123,9 @@ public:
// Used for handling kCursorType dependency
virtual bool canHaveHotspot() const { return false; }
+ // Records returning true survive Scene::clearSceneData / ActionManager::clearActionRecords.
+ virtual bool isPersistentAcrossScenes() const { return false; }
+
protected:
void finishExecution();
diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 4c4a4a609e8..1fc655a56e8 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -32,6 +32,7 @@
#include "engines/nancy/resource.h"
#include "engines/nancy/action/conversation.h"
+#include "engines/nancy/action/secondarymovie.h"
#include "engines/nancy/state/scene.h"
@@ -383,6 +384,13 @@ void ConversationSound::execute() {
g_nancy->_sound->playSound(_responseGenericSound);
}
+ // Nancy 11+: play a fresh random sequence as the character's response anim.
+ if (PlaySecondaryMovie *active = NancySceneState.getActiveMovie()) {
+ if (active->_isRandom) {
+ active->playRandomSequence();
+ }
+ }
+
_state = kActionTrigger;
}
}
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 9e450a4e1e8..15b9e5dfe0d 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -30,12 +30,28 @@
#include "common/random.h"
#include "common/serializer.h"
+#include "common/system.h"
#include "video/bink_decoder.h"
namespace Nancy {
namespace Action {
+PlaySecondaryMovie::PlaySecondaryMovie(bool isRandom)
+ : RenderActionRecord(8), _isRandom(isRandom) {
+ if (_isRandom) {
+ NancySceneState.notifyRandomMovieARLoaded();
+ }
+}
+
+void PlaySecondaryMovie::resetDecoder() {
+ if (_videoType == kVideoPlaytypeAVF) {
+ _decoder.reset(new AVFDecoder());
+ } else {
+ _decoder.reset(new Video::BinkDecoder());
+ }
+}
+
PlaySecondaryMovie::~PlaySecondaryMovie() {
if (NancySceneState.getActiveMovie() == this) {
NancySceneState.setActiveMovie(nullptr);
@@ -49,10 +65,10 @@ PlaySecondaryMovie::~PlaySecondaryMovie() {
void PlaySecondaryMovie::readRandomSequence(Common::Serializer &ser, RandomSequence &seq) {
readFilename(ser, seq.name);
ser.syncAsUint16LE(seq.startFrame);
- ser.syncAsUint16LE(seq.unknown_0x23);
+ ser.syncAsUint16LE(seq.lastFrame);
ser.syncAsSint32LE(seq.minPauseMs);
ser.syncAsSint32LE(seq.maxPauseMs);
- ser.syncAsUint16LE(seq.unknown_0x2D);
+ ser.syncAsUint16LE(seq.stayWeight);
uint16 nextCount = 0;
ser.syncAsUint16LE(nextCount);
@@ -60,7 +76,7 @@ void PlaySecondaryMovie::readRandomSequence(Common::Serializer &ser, RandomSeque
seq.nextSequences.resize(nextCount);
for (uint i = 0; i < nextCount; ++i) {
readFilename(ser, seq.nextSequences[i].name);
- ser.syncAsUint16LE(seq.nextSequences[i].unknown);
+ ser.syncAsUint16LE(seq.nextSequences[i].weight);
}
}
@@ -103,8 +119,10 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
if (startIdx >= 0) {
const RandomSequence &src = _sequences[startIdx];
+ _activeSequenceIndex = startIdx;
_videoName = src.name;
_firstFrame = src.startFrame;
+ _lastFrame = src.lastFrame;
_videoType = kVideoPlaytypeBink;
_videoFormat = kLargeVideoFormat;
_videoSceneChange = kMovieNoSceneChange;
@@ -116,6 +134,102 @@ void PlaySecondaryMovie::readRandomMovieData(Common::Serializer &ser, Common::Se
_sound.name = "NO SOUND";
}
+bool PlaySecondaryMovie::activateRandomSequence(int index) {
+ if (index < 0 || index >= (int)_sequences.size()) {
+ return false;
+ }
+
+ const RandomSequence &src = _sequences[index];
+ _activeSequenceIndex = index;
+ _videoName = src.name;
+ _firstFrame = src.startFrame;
+ _lastFrame = src.lastFrame;
+
+ // Reload the decoder with the new movie. The original engine
+ // auto-detects AVF vs Bink from disk; we honour the existing
+ // _videoType but fall back to the other if needed.
+ resetDecoder();
+
+ Common::Path withExt = _videoName.append(_videoType == kVideoPlaytypeAVF ? ".avf" : ".bik");
+ if (!_decoder->loadFile(withExt)) {
+ _videoType = _videoType == kVideoPlaytypeAVF ? kVideoPlaytypeBink : kVideoPlaytypeAVF;
+ resetDecoder();
+ withExt = _videoName.append(_videoType == kVideoPlaytypeAVF ? ".avf" : ".bik");
+ if (!_decoder->loadFile(withExt)) {
+ warning("PlayRandomMovie: couldn't load %s", _videoName.toString().c_str());
+ return false;
+ }
+ }
+
+ _isFinished = false;
+ _curViewportFrame = -1; // force visibility re-evaluation next tick
+ return true;
+}
+
+void PlaySecondaryMovie::playRandomSequence() {
+ if (!_isRandom || _sequences.empty()) {
+ return;
+ }
+ int picked = g_nancy->_randomSource->getRandomNumber(_sequences.size() - 1);
+ _randomChainState = kRandomPlaying;
+ _randomStopRequested = false;
+ activateRandomSequence(picked);
+}
+
+int PlaySecondaryMovie::rollNextSequence() {
+ if (_activeSequenceIndex < 0 || _activeSequenceIndex >= (int)_sequences.size()) {
+ return -1;
+ }
+
+ const RandomSequence &seq = _sequences[_activeSequenceIndex];
+
+ uint32 totalWeight = seq.stayWeight;
+ for (const NextSequenceRef &ns : seq.nextSequences) {
+ totalWeight += ns.weight;
+ }
+
+ if (totalWeight == 0) {
+ // No weights at all: stay indefinitely without a pause.
+ _randomChainState = kRandomPaused;
+ _randomPauseEndTime = g_system->getMillis() + 1000; // re-check in 1s
+ return -1;
+ }
+
+ uint32 roll = g_nancy->_randomSource->getRandomNumber(totalWeight - 1);
+
+ if (roll < seq.stayWeight) {
+ int32 pauseMs = seq.minPauseMs;
+ if (seq.maxPauseMs > seq.minPauseMs) {
+ pauseMs += g_nancy->_randomSource->getRandomNumber(seq.maxPauseMs - seq.minPauseMs - 1);
+ }
+ _randomPauseEndTime = g_system->getMillis() + (uint32)MAX<int32>(0, pauseMs);
+ _randomChainState = kRandomPaused;
+ setVisible(false);
+ if (_decoder) {
+ _decoder->pauseVideo(true);
+ }
+ return -1;
+ }
+
+ uint32 cumulative = seq.stayWeight;
+ for (uint i = 0; i < seq.nextSequences.size(); ++i) {
+ cumulative += seq.nextSequences[i].weight;
+ if (roll < cumulative) {
+ // Look up the named sequence in _sequences[].
+ for (uint j = 0; j < _sequences.size(); ++j) {
+ if (_sequences[j].name == seq.nextSequences[i].name) {
+ return (int)j;
+ }
+ }
+ warning("PlayRandomMovie: next-sequence \"%s\" not part of this AR",
+ seq.nextSequences[i].name.toString().c_str());
+ return -1;
+ }
+ }
+
+ return -1;
+}
+
void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
Common::Serializer ser(&stream, nullptr);
ser.setVersion(g_nancy->getGameType());
@@ -187,10 +301,7 @@ void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
void PlaySecondaryMovie::init() {
if (!_decoder) {
- if (_videoType == kVideoPlaytypeAVF)
- _decoder.reset(new AVFDecoder());
- else
- _decoder.reset(new Video::BinkDecoder());
+ resetDecoder();
}
if (!_decoder->isVideoLoaded()) {
@@ -220,10 +331,7 @@ void PlaySecondaryMovie::init() {
void PlaySecondaryMovie::onPause(bool pause) {
if (!_decoder) {
- if (_videoType == kVideoPlaytypeAVF)
- _decoder.reset(new AVFDecoder());
- else
- _decoder.reset(new Video::BinkDecoder());
+ resetDecoder();
}
_decoder->pauseVideo(pause);
@@ -274,6 +382,26 @@ void PlaySecondaryMovie::execute() {
// fall through
case kRun: {
+ // 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.
+ if (_isRandom && _randomChainState == kRandomPaused) {
+ if (_randomStopRequested) {
+ _state = kActionTrigger;
+ break;
+ }
+ if (g_system->getMillis() < _randomPauseEndTime) {
+ break;
+ }
+ _randomChainState = kRandomPlaying;
+ int picked = rollNextSequence();
+ if (picked >= 0) {
+ activateRandomSequence(picked);
+ }
+ // If picked < 0, rollNextSequence() set up another pause.
+ break;
+ }
+
int newFrame = NancySceneState.getSceneInfo().frameID;
if (newFrame != _curViewportFrame) {
@@ -289,8 +417,9 @@ void PlaySecondaryMovie::execute() {
if (activeFrame != -1) {
_screenPosition = _videoDescs[activeFrame].destRect;
setVisible(true);
- } else if (_isRandom && _videoDescs.empty()) {
- // No hotspots: play full-viewport instead of gating on a frame match.
+ } else if (_isRandom) {
+ // Random movies aren't gated on hotspot/viewport-frame
+ // matches the way regular PSMs are: play full viewport.
_screenPosition = NancySceneState.getViewport().getBounds();
setVisible(true);
} else {
@@ -340,12 +469,25 @@ void PlaySecondaryMovie::execute() {
(_decoder->getCurFrame() == _firstFrame && _playDirection == kPlayMovieReverse) ||
_decoder->endOfVideo()) {
- // Stop the video and block it from starting again, but also wait for
- // sound to end before changing state
_decoder->pauseVideo(true);
_isFinished = true;
- if (!g_nancy->_sound->isSoundPlaying(_sound)) {
+ if (_isRandom) {
+ // Sequence finished: roll for next. If stop was requested
+ // by a PlayRandomMovieControl, wind the AR down normally.
+ if (_randomStopRequested) {
+ _state = kActionTrigger;
+ } else {
+ int picked = rollNextSequence();
+ if (picked >= 0) {
+ activateRandomSequence(picked);
+ }
+ // Otherwise the chain entered the paused state; no
+ // state-trigger transition.
+ }
+ } 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
g_nancy->_sound->stopSound(_sound);
_state = kActionTrigger;
}
@@ -381,10 +523,10 @@ void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
}
void PlayRandomMovieControl::execute() {
- //PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
- //if (target && target->_isRandom) {
- // target->stopRandom();
- //}
+ PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
+ if (target && target->_isRandom) {
+ target->stopRandom();
+ }
_sceneChange.execute();
finishExecution();
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index 0c8f35ad3e7..a251bde1060 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -62,24 +62,28 @@ public:
FlagDescription flagDesc;
};
- // Name of the next sequence to chain to once the current one finishes.
+ // Name of the next sequence to chain to once the current one finishes,
+ // plus its selection weight in the weighted random pick.
struct NextSequenceRef {
Common::Path name;
- uint16 unknown = 0;
+ uint16 weight = 0;
};
// `name` is both the sequence id and the movie filename.
struct RandomSequence {
Common::Path name;
uint16 startFrame = 0;
- uint16 unknown_0x23 = 0;
+ uint16 lastFrame = 0;
int32 minPauseMs = 0;
int32 maxPauseMs = 0;
- uint16 unknown_0x2D = 0;
+ // Weight assigned to "stay on this sequence" in the weighted random
+ // pick. A roll inside [0, stayWeight) means "don't transition";
+ // instead pause for [minPauseMs, maxPauseMs] and re-roll.
+ uint16 stayWeight = 0;
Common::Array<NextSequenceRef> nextSequences;
};
- PlaySecondaryMovie(bool isRandom = false) : RenderActionRecord(8), _isRandom(isRandom) {}
+ PlaySecondaryMovie(bool isRandom = false);
virtual ~PlaySecondaryMovie();
void init() override;
@@ -119,8 +123,27 @@ public:
uint16 _randomPlayerCursorAllowed = kPlayerCursorAllowed;
Common::Array<RandomSequence> _sequences;
+ // Chain state. After a sequence's movie finishes the engine rolls a
+ // weighted pick: "stay" -> enter pause for a random duration and
+ // re-roll; valid next-sequence -> swap to that sequence's movie.
+ enum RandomChainState { kRandomPlaying, kRandomPaused };
+ int _activeSequenceIndex = -1;
+ RandomChainState _randomChainState = kRandomPlaying;
+ uint32 _randomPauseEndTime = 0;
+ bool _randomStopRequested = false;
+
+ // Called by PlayRandomMovieControl::execute() to wind down the AR.
+ void stopRandom() { _randomStopRequested = true; }
+
+ // Pick & start a fresh random sequence. No-op when not a random AR.
+ void playRandomSequence();
+
bool isViewportRelative() const override { return true; }
+ bool isPersistentAcrossScenes() const override {
+ return _isRandom && !_isDone && !_randomStopRequested;
+ }
+
protected:
Common::String getRecordTypeName() const override {
return _isRandom ? "PlayRandomMovie" : "PlaySecondaryMovie";
@@ -131,6 +154,18 @@ protected:
void readRandomMovieData(Common::Serializer &ser, Common::SeekableReadStream &stream);
void readRandomSequence(Common::Serializer &ser, RandomSequence &seq);
+ // (Re)create _decoder as an AVFDecoder or BinkDecoder matching _videoType.
+ void resetDecoder();
+
+ // Apply a RandomSequence's playback config to the PSM flat fields
+ // and reload the decoder. Returns true on success.
+ bool activateRandomSequence(int index);
+
+ // Pick the next sequence (or "stay") per the weighted random rules.
+ // Returns -1 if "stay" was picked (and sets up the pause state),
+ // or the chosen sequence index otherwise.
+ int rollNextSequence();
+
Graphics::ManagedSurface _fullFrame;
int _curViewportFrame = -1;
bool _isFinished = false;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 2c268d40fdd..503c202f03d 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -36,6 +36,8 @@
#include "engines/nancy/state/scene.h"
#include "engines/nancy/state/map.h"
+#include "engines/nancy/action/secondarymovie.h"
+
#include "engines/nancy/ui/button.h"
#include "engines/nancy/ui/ornaments.h"
#include "engines/nancy/ui/clock.h"
@@ -1398,6 +1400,16 @@ void Scene::clearSceneData() {
}
clearLogicConditions();
+
+ // Stop a leftover random movie if the outgoing scene didn't include
+ // its own PSM(isRandom) AR (so it doesn't bleed into the next scene).
+ if (_activeMovie && _activeMovie->isPersistentAcrossScenes() && !_hadRandomMovieARThisScene) {
+ _activeMovie->stopRandom();
+ }
+ _hadRandomMovieARThisScene = false;
+
+ bool clearActiveMovie = _activeMovie && !_activeMovie->isPersistentAcrossScenes();
+
_actionManager.clearActionRecords();
if (_lightning) {
@@ -1413,7 +1425,10 @@ void Scene::clearSceneData() {
}
_activeConversation = nullptr;
- _activeMovie = nullptr;
+
+ if (clearActiveMovie) {
+ _activeMovie = nullptr;
+ }
}
void Scene::clearPuzzleData() {
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 8a685f55be8..049557d6f61 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -193,6 +193,9 @@ public:
void setActiveMovie(Action::PlaySecondaryMovie *activeMovie);
Action::PlaySecondaryMovie *getActiveMovie();
+
+ // Called when a PSM(isRandom) AR is loaded â drives stale-chain cleanup.
+ void notifyRandomMovieARLoaded() { _hadRandomMovieARThisScene = true; }
void setActiveConversation(Action::ConversationSound *activeConversation);
Action::ConversationSound *getActiveConversation();
@@ -320,6 +323,10 @@ private:
Action::PlaySecondaryMovie *_activeMovie;
Action::ConversationSound *_activeConversation;
+ // Set by notifyRandomMovieARLoaded; checked in clearSceneData to wind
+ // down a persistent random-movie whose scene chain is over.
+ bool _hadRandomMovieARThisScene = false;
+
// Contains a screenshot of the Scene state from the last time it was exited
Graphics::ManagedSurface _lastScreenshot;
Commit: 33273628ce509e43c8b2dd4d8a566e8f2bea4084
https://github.com/scummvm/scummvm/commit/33273628ce509e43c8b2dd4d8a566e8f2bea4084
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:56+03:00
Commit Message:
NANCY: Remove leftover debug code in GridMapPuzzle
Changed paths:
engines/nancy/action/puzzle/gridmappuzzle.cpp
diff --git a/engines/nancy/action/puzzle/gridmappuzzle.cpp b/engines/nancy/action/puzzle/gridmappuzzle.cpp
index 21d1ccbc0f4..bb1659be529 100644
--- a/engines/nancy/action/puzzle/gridmappuzzle.cpp
+++ b/engines/nancy/action/puzzle/gridmappuzzle.cpp
@@ -34,13 +34,6 @@ namespace Nancy {
namespace Action {
void GridMapPuzzle::readData(Common::SeekableReadStream &stream) {
- uint pos = stream.pos();
- Common::DumpFile d;
- d.open("nancy10_gridmappuzzle.dat");
- d.writeStream(&stream, stream.size());
- d.close();
- stream.seek(pos);
-
readFilename(stream, _boardImageName);
readFilename(stream, _cursorImageName);
Commit: 940a57fd1be1a9c335afedf097d3377d86428a5d
https://github.com/scummvm/scummvm/commit/940a57fd1be1a9c335afedf097d3377d86428a5d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:57+03:00
Commit Message:
NANCY: Implement TypingQuizPuzzle for Nancy11
In this puzzle, lettered balloons appear at fixed positions; the
player pops a balloon by typing its character before it floats away.
The puzzle runs for a fixed time limit; the score is a typing rate
(characters per minute).If the final rate meets the target, the win
scene fires; otherwise a default scene fires (with a different event
flag depending on whether a partial threshold was reached).
Unrelated to the text-entry QuizPuzzle (231).
Changed paths:
A engines/nancy/action/puzzle/typingquizpuzzle.cpp
A engines/nancy/action/puzzle/typingquizpuzzle.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 c0efd860108..506541bbbe7 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -74,6 +74,7 @@
#include "engines/nancy/action/puzzle/towerpuzzle.h"
#include "engines/nancy/action/puzzle/turningpuzzle.h"
#include "engines/nancy/action/puzzle/twodialpuzzle.h"
+#include "engines/nancy/action/puzzle/typingquizpuzzle.h"
#include "engines/nancy/action/puzzle/whalesurvivorpuzzle.h"
#include "engines/nancy/state/scene.h"
@@ -502,11 +503,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
case 244:
return new GridMapPuzzle();
// -- Nancy 11 and up --
- case 245: // Nancy11
- warning("TypingQuizPuzzle"); // TODO
- return nullptr;
- case 246: // Nancy11
- warning("MatchPuzzle246"); // TODO
+ case 245:
+ return new TypingQuizPuzzle();
+ case 246:
+ warning("CardGamePuzzle"); // TODO
return nullptr;
default:
warning("Unknown action record type %d", type);
diff --git a/engines/nancy/action/puzzle/typingquizpuzzle.cpp b/engines/nancy/action/puzzle/typingquizpuzzle.cpp
new file mode 100644
index 00000000000..24a6c5f2d4c
--- /dev/null
+++ b/engines/nancy/action/puzzle/typingquizpuzzle.cpp
@@ -0,0 +1,464 @@
+/* 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/util.h"
+#include "engines/nancy/font.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/typingquizpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+// Persists the previous typing rate so the adaptive target can scale across plays.
+static int s_lastScore = 0;
+
+static char toAsciiUpper(char c) {
+ return (c >= 'a' && c <= 'z') ? (char)(c - ('a' - 'A')) : c;
+}
+
+static bool caseFoldEquals(char a, char b) {
+ return toAsciiUpper(a) == toAsciiUpper(b);
+}
+
+void TypingQuizPuzzle::readData(Common::SeekableReadStream &stream) {
+ readFilename(stream, _imageName); // 0x000
+
+ _numImageRects = stream.readUint16LE(); // 0x021
+ for (uint i = 0; i < kMaxImageRects; ++i)
+ readRect(stream, _balloonSrcRects[i]); // 0x023
+
+ readRect(stream, _poppedSrcRect); // 0x163
+
+ _numPositions = stream.readUint16LE(); // 0x173
+ for (uint i = 0; i < kMaxBalloons; ++i)
+ readRect(stream, _positionRects[i]); // 0x175
+
+ for (uint i = 0; i < kNumDigits; ++i)
+ readRect(stream, _scoreDigitRects[i]); // 0x2b5
+ for (uint i = 0; i < kNumDigits; ++i)
+ readRect(stream, _timerDigitRects[i]); // 0x355
+
+ stream.skip(16); // 0x3f5 timer bounding rect (unused)
+
+ _scoreDest.x = stream.readSint32LE(); // 0x405
+ _scoreDest.y = stream.readSint32LE(); // 0x409
+ _timerDest.x = stream.readSint32LE(); // 0x40d
+ _timerDest.y = stream.readSint32LE(); // 0x411
+ stream.skip(8); // 0x415 score/timer clip widths (unused)
+
+ readRect(stream, _passedMsgSrcRect); // 0x41d
+ _passedMsgDuration = stream.readUint32LE();// 0x42d
+
+ _caseSensitive = stream.readByte() != 0; // 0x431
+ _allowDigits = stream.readByte() != 0; // 0x432
+ _keyboardMode = stream.readByte(); // 0x433
+ stream.read(_allowedChars, kAllowedCharsLen); // 0x434
+
+ _balloonLifeMin = stream.readUint16LE(); // 0x452
+ _balloonLifeMax = stream.readUint16LE(); // 0x454
+ _minBalloons = stream.readUint16LE(); // 0x456
+ _maxBalloons = stream.readUint16LE(); // 0x458
+ _timeLimit = stream.readUint16LE(); // 0x45a
+ stream.skip(2); // 0x45c (unused)
+ _scoreThreshold = stream.readUint16LE(); // 0x45e
+ _targetScore = stream.readUint16LE(); // 0x460
+ _targetScorePercent = stream.readUint16LE(); // 0x462
+ _minTargetScore = stream.readUint16LE(); // 0x464
+
+ _popSound.readNormal(stream); // 0x466
+ _wrongSound.readNormal(stream); // 0x497
+ _escapeSound.readNormal(stream); // 0x4c8
+
+ _winScene.readData(stream); // 0x4f9 (20 bytes)
+ _winScene.continueSceneSound = stream.readUint16LE(); // 0x50d
+ _winFlag = stream.readSint16LE(); // 0x50f
+
+ _winSound.readNormal(stream); // 0x511
+
+ _defaultScene.readData(stream); // 0x542 (20 bytes)
+ _defaultScene.continueSceneSound = stream.readUint16LE(); // 0x556
+ _flagThreshold = stream.readSint16LE(); // 0x558
+ _flagFail = stream.readSint16LE(); // 0x55a
+
+ stream.skip(18); // 0x55c (trailing, unused)
+
+ if (_numImageRects > kMaxImageRects)
+ _numImageRects = kMaxImageRects;
+ if (_numPositions > (uint16)kMaxBalloons)
+ _numPositions = kMaxBalloons;
+}
+
+void TypingQuizPuzzle::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(g_nancy->_graphics->getTransColor());
+
+ if (_popSound.name != "NO SOUND")
+ g_nancy->_sound->loadSound(_popSound);
+ if (_wrongSound.name != "NO SOUND")
+ g_nancy->_sound->loadSound(_wrongSound);
+ if (_escapeSound.name != "NO SOUND")
+ g_nancy->_sound->loadSound(_escapeSound);
+
+ // Adaptive target: scale the threshold to the previous run's typing rate
+ if (_targetScorePercent < 1 || s_lastScore < 1) {
+ _effectiveTarget = _targetScore;
+ } else {
+ _effectiveTarget = (uint16)(((int)s_lastScore * (int)_targetScorePercent) / 100);
+ if (_effectiveTarget <= _minTargetScore)
+ _effectiveTarget = _minTargetScore;
+ }
+
+ _startTime = g_nancy->getTotalPlayTime();
+ _gameState = kPlaying;
+ _pops = 0;
+ _score = 0;
+ _reachedTarget = false;
+ _reachedThreshold = false;
+
+ spawnInitialBalloons();
+ redraw();
+}
+
+bool TypingQuizPuzzle::isValidChar(byte c) const {
+ if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))
+ return true;
+ if (_allowDigits && c >= '0' && c <= '9')
+ return true;
+ if (_allowedChars[0] != 0) {
+ for (uint i = 0; i < kAllowedCharsLen; ++i) {
+ if (c == _allowedChars[i])
+ return true;
+ }
+ }
+ return false;
+}
+
+char TypingQuizPuzzle::pickRandomChar() {
+ Common::RandomSource &rnd = *g_nancy->_randomSource;
+ int maxChar = (_keyboardMode != 0) ? 0x100 : (_allowedChars[0] != 0 ? 0x7f : 0x7b);
+
+ for (;;) {
+ int c;
+ if (_allowDigits)
+ c = (int)rnd.getRandomNumber(maxChar - 0x30 - 1) + 0x30;
+ else
+ c = (int)rnd.getRandomNumber(maxChar - 0x41 - 1) + 0x41;
+
+ if (isValidChar((byte)c))
+ return (char)c;
+ }
+}
+
+int TypingQuizPuzzle::pickFreePosition() {
+ Common::RandomSource &rnd = *g_nancy->_randomSource;
+
+ for (;;) {
+ int idx = (int)rnd.getRandomNumber(_numPositions - 1);
+ bool occupied = false;
+ for (uint i = 0; i < _numPositions; ++i) {
+ if (_balloons[i].state != kBalloonInactive && _balloons[i].posIndex == idx) {
+ occupied = true;
+ break;
+ }
+ }
+ if (!occupied)
+ return idx;
+ }
+}
+
+void TypingQuizPuzzle::spawnBalloon(uint slot) {
+ Common::RandomSource &rnd = *g_nancy->_randomSource;
+ Balloon &b = _balloons[slot];
+
+ b.posIndex = pickFreePosition();
+ b.posRect = _positionRects[b.posIndex];
+ b.imgRect = _balloonSrcRects[_numImageRects ? (int)rnd.getRandomNumber(_numImageRects - 1) : 0];
+ b.ch = pickRandomChar();
+ b.spawnTime = g_nancy->getTotalPlayTime();
+ b.popEndTime = 0;
+ b.lifetime = (uint16)((int)rnd.getRandomNumber(_balloonLifeMax - _balloonLifeMin) + _balloonLifeMin);
+ b.state = kBalloonActive;
+}
+
+void TypingQuizPuzzle::spawnInitialBalloons() {
+ for (uint i = 0; i < kMaxBalloons; ++i)
+ _balloons[i].state = kBalloonInactive;
+
+ Common::RandomSource &rnd = *g_nancy->_randomSource;
+ _activeCount = (int)rnd.getRandomNumber(_maxBalloons - _minBalloons) + _minBalloons;
+ if (_activeCount > (int)_numPositions)
+ _activeCount = _numPositions;
+
+ for (int c = 0; c < _activeCount; ++c)
+ spawnBalloon(c);
+}
+
+void TypingQuizPuzzle::respawnBalloons(int maxToAdd) {
+ Common::RandomSource &rnd = *g_nancy->_randomSource;
+
+ int target;
+ if (_activeCount + maxToAdd < _minBalloons) {
+ target = _minBalloons;
+ } else {
+ int add = (int)rnd.getRandomNumber(maxToAdd);
+ while (_activeCount + add < _minBalloons || _activeCount + add > _maxBalloons)
+ add = (int)rnd.getRandomNumber(maxToAdd);
+ target = _activeCount + add;
+ }
+
+ int toSpawn = target - _activeCount;
+ _activeCount = target;
+
+ for (uint slot = 0; slot < _numPositions && toSpawn > 0; ++slot) {
+ if (_balloons[slot].state == kBalloonInactive) {
+ spawnBalloon(slot);
+ --toSpawn;
+ }
+ }
+}
+
+void TypingQuizPuzzle::expireBalloons(uint32 now) {
+ int expired = 0;
+
+ for (uint i = 0; i < _numPositions; ++i) {
+ Balloon &b = _balloons[i];
+
+ // Active balloons float away once their lifetime elapses
+ if (b.state == kBalloonActive && (now - b.spawnTime) / 1000 > b.lifetime) {
+ b.state = kBalloonInactive;
+ --_activeCount;
+ ++expired;
+ }
+
+ // Popped balloons clear once their burst animation finishes
+ if (b.state == kBalloonPopped && now >= b.popEndTime) {
+ b.state = kBalloonInactive;
+ --_activeCount;
+ respawnBalloons(2);
+ }
+ }
+
+ if (expired > 0) {
+ if (_escapeSound.name != "NO SOUND")
+ g_nancy->_sound->playSound(_escapeSound);
+ respawnBalloons(2);
+ }
+}
+
+void TypingQuizPuzzle::processTyping(uint32 now) {
+ for (uint k = 0; k < _pendingChars.size(); ++k) {
+ char typed = _pendingChars[k];
+ bool matched = false;
+
+ for (uint i = 0; i < _numPositions; ++i) {
+ Balloon &b = _balloons[i];
+ if (b.state != kBalloonActive)
+ continue;
+
+ bool isMatch = _caseSensitive ? (b.ch == typed) : caseFoldEquals(b.ch, typed);
+ if (isMatch) {
+ b.state = kBalloonPopped;
+ b.popEndTime = now + 1000;
+ ++_pops;
+ if (_popSound.name != "NO SOUND")
+ g_nancy->_sound->playSound(_popSound);
+ matched = true;
+ break;
+ }
+ }
+
+ if (!matched && _wrongSound.name != "NO SOUND")
+ g_nancy->_sound->playSound(_wrongSound);
+ }
+
+ _pendingChars.clear();
+}
+
+void TypingQuizPuzzle::updateScore(uint32 now) {
+ uint32 elapsedSec = (now - _startTime) / 1000;
+
+ // Score is a typing rate (characters per minute)
+ if (elapsedSec > 0 && _pops > 0)
+ _score = (int)((float)_pops * 60.0f / (float)elapsedSec);
+
+ if (elapsedSec >= _timeLimit)
+ _gameState = kEvaluate;
+}
+
+void TypingQuizPuzzle::drawNumber(int value, const Common::Rect *digitRects, int x, int y) {
+ if (value < 0)
+ value = 0;
+
+ // Render digits most-significant first using each digit sprite's own width
+ Common::String digits = Common::String::format("%d", value);
+ int drawX = x;
+ for (uint i = 0; i < digits.size(); ++i) {
+ int d = digits[i] - '0';
+ const Common::Rect &src = digitRects[d];
+ _drawSurface.blitFrom(_image, src, Common::Point(drawX, y));
+ drawX += src.width();
+ }
+}
+
+void TypingQuizPuzzle::redraw() {
+ _needsRedraw = true;
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+ if (_gameState != kPlaying) {
+ // Result message, centred in the viewport
+ int w = _passedMsgSrcRect.width();
+ int h = _passedMsgSrcRect.height();
+ int x = (_drawSurface.w - w) / 2;
+ int y = (_drawSurface.h - h) / 2;
+ _drawSurface.blitFrom(_image, _passedMsgSrcRect, Common::Point(x, y));
+ return;
+ }
+
+ const Font *font = g_nancy->_graphics->getFont(_fontID);
+
+ // Balloons and their characters
+ for (uint i = 0; i < _numPositions; ++i) {
+ const Balloon &b = _balloons[i];
+
+ if (b.state == kBalloonActive) {
+ _drawSurface.blitFrom(_image, b.imgRect, Common::Point(b.posRect.left, b.posRect.top));
+
+ if (font) {
+ Common::String s(b.ch);
+ int cw = font->getStringWidth(s);
+ int ch = font->getFontHeight();
+ int cx = b.posRect.left + (b.posRect.width() - cw) / 2;
+ int cy = b.posRect.top + (b.posRect.height() - ch) / 2;
+ font->drawString(&_drawSurface, s, cx, cy, b.posRect.width(), 0);
+ }
+ } else if (b.state == kBalloonPopped) {
+ _drawSurface.blitFrom(_image, _poppedSrcRect, Common::Point(b.posRect.left, b.posRect.top));
+ }
+ }
+
+ // Score and remaining-time displays
+ drawNumber(_score, _scoreDigitRects, _scoreDest.x, _scoreDest.y);
+
+ uint32 elapsedSec = (g_nancy->getTotalPlayTime() - _startTime) / 1000;
+ int remaining = (int)_timeLimit - (int)elapsedSec;
+ drawNumber(remaining, _timerDigitRects, _timerDest.x, _timerDest.y);
+}
+
+void TypingQuizPuzzle::triggerSceneChange() {
+ if (_reachedTarget) {
+ if (_winScene.sceneID != 9999)
+ NancySceneState.changeScene(_winScene);
+ if (_winFlag != -1)
+ NancySceneState.setEventFlag(_winFlag, g_nancy->_true);
+ } else {
+ NancySceneState.changeScene(_defaultScene);
+ if (_reachedThreshold && _flagThreshold != -1)
+ NancySceneState.setEventFlag(_flagThreshold, g_nancy->_true);
+ else if (_flagFail != -1)
+ NancySceneState.setEventFlag(_flagFail, g_nancy->_true);
+ }
+}
+
+void TypingQuizPuzzle::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+ registerGraphics();
+ _state = kRun;
+ // fall through
+ case kRun: {
+ uint32 now = g_nancy->getTotalPlayTime();
+
+ switch (_gameState) {
+ case kPlaying:
+ processTyping(now);
+ expireBalloons(now);
+ updateScore(now);
+ redraw();
+ break;
+
+ case kEvaluate:
+ if (_score >= (int)_effectiveTarget) {
+ _reachedTarget = true;
+ if (_winSound.name != "NO SOUND") {
+ g_nancy->_sound->loadSound(_winSound);
+ g_nancy->_sound->playSound(_winSound);
+ }
+ } else {
+ _reachedTarget = false;
+ _reachedThreshold = _score >= (int)_scoreThreshold;
+ }
+
+ _resultEndTime = now + _passedMsgDuration;
+ _gameState = kResult;
+ redraw();
+ break;
+
+ case kResult:
+ redraw();
+ if (now >= _resultEndTime) {
+ s_lastScore = _score;
+ _state = kActionTrigger;
+ }
+ break;
+ }
+ break;
+ }
+ case kActionTrigger:
+ g_nancy->_sound->stopSound(_popSound);
+ g_nancy->_sound->stopSound(_wrongSound);
+ g_nancy->_sound->stopSound(_escapeSound);
+ g_nancy->_sound->stopSound(_winSound);
+
+ triggerSceneChange();
+ finishExecution();
+ break;
+ }
+}
+
+void TypingQuizPuzzle::handleInput(NancyInput &input) {
+ if (_state != kRun || _gameState != kPlaying)
+ return;
+
+ for (auto &key : input.otherKbdInput) {
+ if (key.ascii != 0 && isValidChar((byte)key.ascii))
+ _pendingChars += (char)key.ascii;
+ }
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/typingquizpuzzle.h b/engines/nancy/action/puzzle/typingquizpuzzle.h
new file mode 100644
index 00000000000..e76147fccc8
--- /dev/null
+++ b/engines/nancy/action/puzzle/typingquizpuzzle.h
@@ -0,0 +1,163 @@
+/* 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_TYPINGQUIZPUZZLE_H
+#define NANCY_ACTION_TYPINGQUIZPUZZLE_H
+
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/commontypes.h"
+
+#include "graphics/managed_surface.h"
+
+namespace Nancy {
+namespace Action {
+
+// Real-time "typing quiz" arcade puzzle, new in Nancy 11 (Curse of Blackmoor Manor).
+// Lettered balloons appear at fixed positions; the player pops a balloon by typing its
+// character before it floats away. The puzzle runs for a fixed time limit; the score is
+// a typing rate (characters per minute). If the final rate meets the target, the win
+// scene fires; otherwise a default scene fires (with a different event flag depending on
+// whether a partial threshold was reached). Unrelated to the text-entry QuizPuzzle (231).
+class TypingQuizPuzzle : public RenderActionRecord {
+public:
+ TypingQuizPuzzle() : RenderActionRecord(7) {}
+ virtual ~TypingQuizPuzzle() {}
+
+ 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 "TypingQuizPuzzle"; }
+
+private:
+ static const uint kMaxBalloons = 20; // position/balloon slots
+ static const uint kMaxImageRects = 20; // balloon image variations
+ static const uint kNumDigits = 10; // digit sprites for the score/timer displays
+ static const uint kAllowedCharsLen = 30;
+
+ enum BalloonState {
+ kBalloonInactive = 0,
+ kBalloonActive = 1, // floating, can be popped
+ kBalloonPopped = 2 // popped, showing the burst sprite until _popEndTime
+ };
+
+ struct Balloon {
+ BalloonState state = kBalloonInactive;
+ int posIndex = -1; // index into _positionRects
+ Common::Rect imgRect; // source rect in the puzzle image
+ Common::Rect posRect; // viewport-relative destination
+ char ch = 0;
+ uint32 spawnTime = 0;
+ uint32 popEndTime = 0;
+ uint16 lifetime = 0; // seconds before the balloon floats away
+ };
+
+ // ---- Helpers ----
+ void spawnInitialBalloons();
+ void respawnBalloons(int maxToAdd);
+ void spawnBalloon(uint slot);
+ int pickFreePosition(); // index into _positionRects not currently occupied
+ char pickRandomChar();
+ bool isValidChar(byte c) const;
+ void expireBalloons(uint32 now);
+ void processTyping(uint32 now);
+ void updateScore(uint32 now);
+ void drawNumber(int value, const Common::Rect *digitRects, int x, int y);
+ void redraw();
+ void triggerSceneChange();
+
+ // ---- File data ----
+ Common::Path _imageName; // 0x000 puzzle sprite sheet
+ uint16 _numImageRects = 0; // 0x021
+ Common::Rect _balloonSrcRects[kMaxImageRects]; // 0x023
+ Common::Rect _poppedSrcRect; // 0x163 burst sprite
+ uint16 _numPositions = 0; // 0x173
+ Common::Rect _positionRects[kMaxBalloons]; // 0x175 viewport-relative
+
+ Common::Rect _scoreDigitRects[kNumDigits]; // 0x2b5
+ Common::Rect _timerDigitRects[kNumDigits]; // 0x355
+ Common::Point _scoreDest; // 0x405 viewport-relative
+ Common::Point _timerDest; // 0x40d viewport-relative
+ Common::Rect _passedMsgSrcRect; // 0x41d result message sprite
+ uint32 _passedMsgDuration = 0; // 0x42d ms
+
+ bool _caseSensitive = false; // 0x431 (0 = case-insensitive)
+ bool _allowDigits = false; // 0x432
+ byte _keyboardMode = 0; // 0x433
+ byte _allowedChars[kAllowedCharsLen] = {}; // 0x434
+
+ uint16 _balloonLifeMin = 0; // 0x452 seconds
+ uint16 _balloonLifeMax = 0; // 0x454
+ uint16 _minBalloons = 0; // 0x456
+ uint16 _maxBalloons = 0; // 0x458
+ uint16 _timeLimit = 0; // 0x45a seconds
+ uint16 _scoreThreshold = 0; // 0x45e partial-credit score
+ uint16 _targetScore = 0; // 0x460 unscaled target rate
+ uint16 _targetScorePercent = 0; // 0x462 adaptive scaling percent
+ uint16 _minTargetScore = 0; // 0x464 floor for the scaled target
+
+ SoundDescription _popSound; // 0x466 balloon popped
+ SoundDescription _wrongSound; // 0x497 wrong key
+ SoundDescription _escapeSound; // 0x4c8 balloon floated away
+
+ SceneChangeDescription _winScene; // 0x4f9 (9999 = none)
+ int16 _winFlag = -1; // 0x50f
+
+ SoundDescription _winSound; // 0x511 played when the target is met
+
+ SceneChangeDescription _defaultScene; // 0x542 (did not reach the target)
+ int16 _flagThreshold = -1; // 0x558 set if the partial threshold was reached
+ int16 _flagFail = -1; // 0x55a set otherwise
+
+ // ---- Runtime state ----
+ Graphics::ManagedSurface _image;
+ uint16 _fontID = 0; // game default font for the balloon characters
+
+ Balloon _balloons[kMaxBalloons];
+ int _activeCount = 0;
+
+ enum GameState {
+ kPlaying = 0, // typing against the clock
+ kEvaluate = 1, // time is up: decide win/loss, play win sound
+ kResult = 2 // show the result message, then change scene
+ };
+ GameState _gameState = kPlaying;
+
+ uint32 _startTime = 0;
+ int _pops = 0; // number of balloons popped
+ int _score = 0; // displayed typing rate (characters per minute)
+ uint16 _effectiveTarget = 0;
+ bool _reachedTarget = false;
+ bool _reachedThreshold = false;
+ uint32 _resultEndTime = 0;
+
+ Common::String _pendingChars; // typed characters awaiting a match, filled by handleInput
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_TYPINGQUIZPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index af38b27b11e..d148fc80388 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -57,6 +57,7 @@ MODULE_OBJS = \
action/puzzle/towerpuzzle.o \
action/puzzle/turningpuzzle.o \
action/puzzle/twodialpuzzle.o \
+ action/puzzle/typingquizpuzzle.o \
action/puzzle/whalesurvivorpuzzle.o \
ui/fullscreenimage.o \
ui/animatedbutton.o \
Commit: 10e9821f6b665faec5ae543d1dbcf4c6c333142b
https://github.com/scummvm/scummvm/commit/10e9821f6b665faec5ae543d1dbcf4c6c333142b
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:01:58+03:00
Commit Message:
NANCY: Implement new MemoryPuzzle functionality for Nancy11+
Changed paths:
engines/nancy/action/puzzle/memorypuzzle.cpp
engines/nancy/action/puzzle/memorypuzzle.h
diff --git a/engines/nancy/action/puzzle/memorypuzzle.cpp b/engines/nancy/action/puzzle/memorypuzzle.cpp
index 65ba67ed2bf..41f478231b8 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.cpp
+++ b/engines/nancy/action/puzzle/memorypuzzle.cpp
@@ -34,26 +34,12 @@
namespace Nancy {
namespace Action {
-// Binary layout (offsets from stream start, all LE):
-// 0x000 33 image filename
-// 0x021 576 36 x face src rect (int32 l/t/r/b x 4) [types 0..35]
-// 0x261 48 3 x tab indicator src rect (one per tab)
-// 0x291 384 24 x card screen rect (viewport-relative, int32 x 4)
-// 0x411 16 tab rect (screen rect where the active-tab graphic is drawn)
-// 0x421 144 3 x 3 x 16 tab hotspot rects [currentTab][targetTab]
-// 0x4b1 4 flipDelay (uint32, milliseconds)
-// 0x4b5 4 numPairs (uint32; clamped [4..36] in init)
-// 0x4b9 4 requiredPairs (uint32; clamped [2..36] in init)
-// 0x4bd 1 cursor flag (byte; skip in ScummVM)
-// 0x4be 1 shuffle flag (0 = pairs stay within tab, nonzero = global)
-// 0x4bf 49 match sound (played when a matching pair is found)
-// 0x4f0 49 card flip sound (played when flipping a card face-up)
-// 0x521 25 win SceneChangeWithFlag
-// 0x53a 1 unknown (skip)
-// 0x53b 49 win sound
-// 0x56c 16 exit hotspot rect
-// [total: 0x57c = 1404 bytes]
void MemoryPuzzle::readData(Common::SeekableReadStream &stream) {
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ readDataNancy11(stream);
+ return;
+ }
+
// 0x000: image filename (33 bytes)
readFilename(stream, _imageName);
@@ -104,13 +90,71 @@ void MemoryPuzzle::readData(Common::SeekableReadStream &stream) {
readRect(stream, _exitHotspot);
}
+// Nancy 11 reworked the layout: fewer (12) face rects, a configurable grid/page count,
+// per-card-type voice clips in 27 fixed-size (0xb6) blocks, and two outcome scenes.
+void MemoryPuzzle::readDataNancy11(Common::SeekableReadStream &stream) {
+ readFilename(stream, _imageName); // 0x000
+
+ for (int i = 0; i < 12; ++i) // 0x021 face src rects
+ readRect(stream, _faceSrcRects[i]);
+ for (int i = 0; i < kNumTabs; ++i) // 0x0e1 page-tab indicator src rects
+ readRect(stream, _tabSrcRects[i]);
+ for (int i = 0; i < kCardsPerTab; ++i) // 0x111 card position / back src rects
+ readRect(stream, _cardRects[i]);
+
+ readRect(stream, _tabRect); // 0x291 tab indicator dest
+
+ for (int tab = 0; tab < kNumTabs; ++tab) // 0x2a1 tab hotspots
+ for (int slot = 0; slot < 3; ++slot)
+ readRect(stream, _tabHotspots[tab][slot]);
+
+ _flipDelay = stream.readUint32LE(); // 0x331
+ stream.skip(4); // 0x335 (second timing value, unused)
+ int32 requirePercent = stream.readSint32LE(); // 0x339 (-1 = use the fixed count below)
+ int32 requireCount = stream.readSint32LE(); // 0x33d
+ stream.skip(4); // 0x341 (unused)
+
+ _shuffleGlobal = (stream.readByte() != 0); // 0x345
+ stream.skip(1); // 0x346 (flag, unused)
+
+ int32 pages = stream.readSint32LE(); // 0x347
+ int32 gridsWide = stream.readSint32LE(); // 0x34b
+ int32 gridsTall = stream.readSint32LE(); // 0x34f
+ int32 srcWide = stream.readSint32LE(); // 0x353
+ int32 srcTall = stream.readSint32LE(); // 0x357
+ stream.skip(1); // 0x35b (tabs flag)
+
+ _numTabs = CLIP<int>(pages, 1, kNumTabs);
+ _cardsPerTab = CLIP<int>(gridsWide * gridsTall, 1, kCardsPerTab);
+ _numTypes = CLIP<int>(srcWide * srcTall, 1, kMaxTypes);
+ _numPairs = _numTypes;
+ _requiredPairs = (requirePercent < 0) ? (uint32)requireCount
+ : (uint32)(_numPairs * requirePercent / 100);
+
+ // 27 fixed 0xb6-byte voice-clip blocks: [0] is the card-flip sound, [17] starts the
+ // per-card match sounds (used here as a single match sound; per-type audio is a TODO).
+ _cardFlipSound.readNormal(stream); // block 0 @ 0x35c
+ stream.skip(17 * 0xb6 - 0x31); // advance to block 17 @ 0xf72
+ _matchSound.readNormal(stream); // block 17
+ stream.skip((27 - 17) * 0xb6 - 0x31); // advance to the scenes @ 0x168e
+ _winSound.name = "NO SOUND";
+
+ // Solve scene (0x168e), then an alternate-outcome scene (0x16a8, unused). The event flags
+ // store a 16-bit value rather than a simple on/off.
+ _winScene._sceneChange.readData(stream);
+ _winScene._sceneChange.continueSceneSound = stream.readUint16LE();
+ _winScene._flag.label = stream.readSint16LE();
+ _winScene._flag.flag = stream.readSint16LE() ? g_nancy->_true : g_nancy->_false;
+ stream.skip(26); // alternate scene
+}
+
// Shuffles type IDs (0..numPairs-1) into the 72-card array so that every type
// appears exactly twice. numPairs is clamped to [4, 36]; cards beyond numPairs
// remain typeId -1 (unassigned, unselectable). requiredPairs is clamped to [2, totalCards/2].
void MemoryPuzzle::initCards() {
- _numPairs = CLIP<uint32>(_numPairs, 4, (uint32)kMaxTypes);
+ _numPairs = CLIP<uint32>(_numPairs, 4, (uint32)_numTypes);
- const int totalCards = kNumTabs * kCardsPerTab;
+ const int totalCards = _numTabs * _cardsPerTab;
const uint32 maxRequire = (uint32)(totalCards / 2);
_requiredPairs = CLIP<uint32>(_requiredPairs, 2, maxRequire);
@@ -129,9 +173,9 @@ void MemoryPuzzle::initCards() {
if (!_shuffleGlobal) {
// By-tab: pairs are always within the same tab.
- for (int tab = 0; tab < kNumTabs; ++tab) {
- int base = tab * kCardsPerTab;
- for (int i = 0; i < kCardsPerTab; ++i) {
+ for (int tab = 0; tab < _numTabs; ++tab) {
+ int base = tab * _cardsPerTab;
+ for (int i = 0; i < _cardsPerTab; ++i) {
if (_cards[base + i].typeId != -1)
continue;
if (static_cast<uint32>(nextType) >= _numPairs)
@@ -142,7 +186,7 @@ void MemoryPuzzle::initCards() {
// Find a random unassigned slot in the same tab for the pair
int partner;
do {
- partner = g_nancy->_randomSource->getRandomNumber(kCardsPerTab - 1);
+ partner = g_nancy->_randomSource->getRandomNumber(_cardsPerTab - 1);
} while (_cards[base + partner].typeId != -1);
_cards[base + partner].typeId = nextType;
@@ -256,7 +300,7 @@ void MemoryPuzzle::handleInput(NancyInput &input) {
}
// Tab switching: _tabHotspots[currentTab][slot] where slot is the target tab
- for (int slot = 0; slot < kNumTabs; ++slot) {
+ for (int slot = 0; slot < _numTabs; ++slot) {
if (_tabHotspots[_currentTab][slot].contains(mouseVP)) {
g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
if ((input.input & NancyInput::kLeftMouseButtonUp) && slot != _currentTab) {
@@ -281,8 +325,8 @@ void MemoryPuzzle::handleInput(NancyInput &input) {
if (_flipTimerActive)
return;
- int base = _currentTab * kCardsPerTab;
- for (int i = 0; i < kCardsPerTab; ++i) {
+ int base = _currentTab * _cardsPerTab;
+ for (int i = 0; i < _cardsPerTab; ++i) {
if (!_cardRects[i].contains(mouseVP))
continue;
@@ -356,21 +400,21 @@ void MemoryPuzzle::redrawCards() {
// Draw the active tab indicator over the corresponding tab button.
// The scene background shows inactive tab visuals; the overlay only marks the active one.
- if (_currentTab < kNumTabs && !_tabSrcRects[_currentTab].isEmpty())
+ if (_currentTab < _numTabs && !_tabSrcRects[_currentTab].isEmpty())
_drawSurface.blitFrom(_image, _tabSrcRects[_currentTab],
Common::Point(_tabRect.left, _tabRect.top));
- // Draw face-up and matched cards. Face-down cards are left transparent so
- // the scene background (which carries the card-back visual) shows through.
- int base = _currentTab * kCardsPerTab;
- for (int i = 0; i < kCardsPerTab; ++i) {
+ // Draw face-up and matched cards. Face-down cards are left transparent so the scene
+ // background (which carries the card-back visual) shows through.
+ int base = _currentTab * _cardsPerTab;
+ for (int i = 0; i < _cardsPerTab; ++i) {
int idx = base + i;
const CardState &card = _cards[idx];
const Common::Rect &dest = _cardRects[i];
if (card.matchState != 0 || card.flipState != 0) {
int t = card.typeId;
- if (t >= 0 && t < kMaxTypes && !_faceSrcRects[t].isEmpty())
+ if (t >= 0 && t < _numTypes && !_faceSrcRects[t].isEmpty())
_drawSurface.blitFrom(_image, _faceSrcRects[t],
Common::Point(dest.left, dest.top));
}
diff --git a/engines/nancy/action/puzzle/memorypuzzle.h b/engines/nancy/action/puzzle/memorypuzzle.h
index 5de2b030556..cd26e170b32 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.h
+++ b/engines/nancy/action/puzzle/memorypuzzle.h
@@ -40,6 +40,7 @@ public:
void init() override;
void readData(Common::SeekableReadStream &stream) override;
+ void readDataNancy11(Common::SeekableReadStream &stream);
void execute() override;
void handleInput(NancyInput &input) override;
@@ -68,6 +69,11 @@ protected:
uint32 _requiredPairs = 12; // pairs needed to win (clamped [2..36])
uint32 _flipDelay = 1500; // ms before non-matching cards flip back
+ // Counts are fixed in the Nancy 9 layout but configurable from Nancy 11 onwards
+ int _numTabs = kNumTabs;
+ int _cardsPerTab = kCardsPerTab;
+ int _numTypes = kMaxTypes;
+
bool _shuffleGlobal = false; // false = pairs stay within same tab; true = can cross tabs
SoundDescription _cardFlipSound; // played when a card is flipped face-up
Commit: 979db279fc019ebe7d38be626f0efd67de8a38b0
https://github.com/scummvm/scummvm/commit/979db279fc019ebe7d38be626f0efd67de8a38b0
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:03+03:00
Commit Message:
NANCY: Implement new OrderingPuzzle functionality for Nancy11
This puzzle is used as follows:
- Piano variant: scenes 3394, 3395
- Keypad variant: scenes 3196
- Keypad terse variant: scenes 2702, 2705
Changed paths:
engines/nancy/action/puzzle/orderingpuzzle.cpp
engines/nancy/action/puzzle/orderingpuzzle.h
diff --git a/engines/nancy/action/puzzle/orderingpuzzle.cpp b/engines/nancy/action/puzzle/orderingpuzzle.cpp
index 8270180851b..eedd6ab2ab1 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/orderingpuzzle.cpp
@@ -174,8 +174,20 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
readRectArray(ser, _overlaySrcs, numOverlays);
readRectArray(ser, _overlayDests, numOverlays);
} else if (isPiano && g_nancy->getGameType() >= kGameTypeNancy8) {
- readFilenameArray(stream, _pianoSoundNames, numElements);
- stream.skip((maxNumElements - numElements) * 33);
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ // Nancy 11 stores two interleaved sound names per key (e.g. a press and
+ // a release sound), padded to maxNumElements pairs
+ _pianoSoundNames.resize(numElements);
+ _pianoReleaseSoundNames.resize(numElements);
+ for (uint i = 0; i < numElements; ++i) {
+ readFilename(stream, _pianoSoundNames[i]);
+ readFilename(stream, _pianoReleaseSoundNames[i]);
+ }
+ stream.skip((maxNumElements - numElements) * 2 * 33);
+ } else {
+ readFilenameArray(stream, _pianoSoundNames, numElements);
+ stream.skip((maxNumElements - numElements) * 33);
+ }
}
if (ser.getVersion() > kGameTypeVampire) {
@@ -212,19 +224,61 @@ void OrderingPuzzle::readData(Common::SeekableReadStream &stream) {
if (isKeypad && g_nancy->getGameType() >= kGameTypeNancy7) {
if (_puzzleType == kKeypad) {
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ // Nancy 11 multi-stage keypad: a stage count, per-stage display rects, the codes
+ // for stages 1+, a final code matrix and an alternate scene, then the button rects.
+ _numStages = stream.readUint16LE();
+ stream.skip(20 * 16 + 1); // per-stage display rects + blink flag
+
+ _stageSequences.resize(4);
+ _stageCheckOrder.resize(4);
+ for (uint s = 0; s < 4; ++s) {
+ uint16 len = stream.readUint16LE();
+ _stageCheckOrder[s] = (stream.readByte() != 0);
+ len = MIN<uint16>(len, 30);
+ _stageSequences[s].resize(len);
+ for (uint16 k = 0; k < len; ++k)
+ _stageSequences[s][k] = stream.readByte();
+ stream.skip(30 - len);
+ }
+
+ stream.skip(25 + 25); // final code matrix + alternate scene (unused by the sequential model)
+ }
readRectArray(ser, _down1Rects, numElements, maxNumElements);
readRectArray(ser, _destRects, numElements, maxNumElements);
} else if (_puzzleType == kKeypadTerse) {
- _down1Rects.resize(numElements);
- _destRects.resize(numElements);
-
// Terse elements are the same size & placed on a grid (in the source image AND on screen)
uint16 columns = stream.readUint16LE();
- stream.skip(2); // rows
+
+ // The Nancy 11 multi-stage terse keypad chains scenes: each scene holds one stage and
+ // the grid is preceded by the scene to advance to once this stage's code is entered.
+ // Standalone/final keypads omit it. A grid column count can never exceed maxNumElements,
+ // so a larger leading value is that scene id - it overrides the solve scene's target
+ // (the rest of the solve scene change is reused), then the real column count follows.
+ if (g_nancy->getGameType() >= kGameTypeNancy11 && columns > maxNumElements) {
+ _solveExitScene._sceneChange.sceneID = columns;
+ columns = stream.readUint16LE();
+ }
+
+ uint16 rows = stream.readUint16LE();
uint16 width = stream.readUint16LE();
uint16 height = stream.readUint16LE();
+ // Nancy 11 stores the element count as 0 and derives it from the grid dimensions
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ numElements = columns * rows;
+ }
+
+ // Guard against malformed dimensions (columns is a divisor below)
+ if (columns == 0) {
+ columns = 1;
+ }
+ numElements = MIN<uint16>(numElements, maxNumElements);
+
+ _down1Rects.resize(numElements);
+ _destRects.resize(numElements);
+
Common::Point srcStartPos, srcDist, destStartPos, destDist;
srcStartPos.x = stream.readUint16LE();
@@ -387,7 +441,36 @@ void OrderingPuzzle::execute() {
}
}
- if (_puzzleType == kKeypad && _needButtonToCheckSuccess) {
+ if (_puzzleType == kKeypad && _numStages > 1) {
+ // Nancy 11 multi-stage keypad: every stage's code must be entered in turn.
+ // The check button confirms the current code; a wrong code resets the stage.
+ if (!_checkButtonPressed) {
+ return;
+ }
+
+ if (g_nancy->_sound->isSoundPlaying(_pushDownSound)) {
+ return;
+ }
+
+ _checkButtonPressed = false;
+
+ if (!solved) {
+ clearAllElements();
+ return;
+ }
+
+ if (_currentStage + 1 < (int)_numStages) {
+ ++_currentStage;
+ _correctSequence = _stageSequences[_currentStage - 1];
+ _checkOrder = _stageCheckOrder[_currentStage - 1];
+ clearAllElements();
+ return;
+ }
+
+ // Final stage solved
+ NancySceneState.setEventFlag(_solveExitScene._flag);
+ _currentStage = 0;
+ } else if (_puzzleType == kKeypad && _needButtonToCheckSuccess) {
// KeypadPuzzle moves to the "success" scene regardless whether the puzzle was solved or not,
// provided the check button is pressed.
if (_checkButtonPressed) {
diff --git a/engines/nancy/action/puzzle/orderingpuzzle.h b/engines/nancy/action/puzzle/orderingpuzzle.h
index 3f9dbfa7f99..9d15e954b0d 100644
--- a/engines/nancy/action/puzzle/orderingpuzzle.h
+++ b/engines/nancy/action/puzzle/orderingpuzzle.h
@@ -73,12 +73,21 @@ protected:
Common::Array<Common::Rect> _hotspots;
Common::Array<uint16> _correctSequence;
+ // Nancy 11 multi-stage keypad: a series of codes that must be entered in turn. Stage 0 is
+ // _correctSequence above; the rest are stored here. The puzzle stays in one scene across all
+ // stages, so the same record instance tracks the current stage.
+ uint16 _numStages = 0;
+ Common::Array<Common::Array<uint16> > _stageSequences;
+ Common::Array<bool> _stageCheckOrder;
+ int _currentStage = 0;
+
uint16 _specialCursor1Id = CursorManager::kHotspot;
Common::Rect _specialCursor1Dest;
uint16 _specialCursor2Id = CursorManager::kHotspot;
Common::Rect _specialCursor2Dest;
Common::Array<Common::String> _pianoSoundNames; // nancy8 and up
+ Common::Array<Common::String> _pianoReleaseSoundNames; // nancy11 and up (second, interleaved, sound name per key)
uint16 _state2InvItem = 0;
Common::Array<Common::Rect> _overlaySrcs;
Commit: 6267ae7f621bee1411f85c5f6f9790e0f5ac3131
https://github.com/scummvm/scummvm/commit/6267ae7f621bee1411f85c5f6f9790e0f5ac3131
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:04+03:00
Commit Message:
NANCY: Don't capture events in the text box when it's not visible
Avoids having mouse events eaten when they shouldn't be
Changed paths:
engines/nancy/ui/textbox.cpp
diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index a8248564fe3..270f3c0d3f9 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -196,6 +196,9 @@ void Textbox::setFullMode(bool open, uint32 timeoutMs) {
}
void Textbox::handleInput(NancyInput &input) {
+ if (!isVisible())
+ return;
+
if (_scrollbar)
_scrollbar->handleInput(input);
Commit: cf19cffcfabb4adea942118636fa811435684bd2
https://github.com/scummvm/scummvm/commit/cf19cffcfabb4adea942118636fa811435684bd2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:05+03:00
Commit Message:
NANCY: Remove an unneeded assignment in MemoryPuzzle
Changed paths:
engines/nancy/action/puzzle/memorypuzzle.cpp
diff --git a/engines/nancy/action/puzzle/memorypuzzle.cpp b/engines/nancy/action/puzzle/memorypuzzle.cpp
index 41f478231b8..d70e39ac382 100644
--- a/engines/nancy/action/puzzle/memorypuzzle.cpp
+++ b/engines/nancy/action/puzzle/memorypuzzle.cpp
@@ -137,7 +137,7 @@ void MemoryPuzzle::readDataNancy11(Common::SeekableReadStream &stream) {
stream.skip(17 * 0xb6 - 0x31); // advance to block 17 @ 0xf72
_matchSound.readNormal(stream); // block 17
stream.skip((27 - 17) * 0xb6 - 0x31); // advance to the scenes @ 0x168e
- _winSound.name = "NO SOUND";
+ // Nancy 11 has no win sound; _winSound keeps its default "NO SOUND".
// Solve scene (0x168e), then an alternate-outcome scene (0x16a8, unused). The event flags
// store a 16-bit value rather than a simple on/off.
Commit: 3f823089d8eccfb461dec5c73df92abe507c3ff5
https://github.com/scummvm/scummvm/commit/3f823089d8eccfb461dec5c73df92abe507c3ff5
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:06+03:00
Commit Message:
NANCY: Implement new functionality for BulPuzzle in Nancy11
Changed paths:
engines/nancy/action/puzzle/bulpuzzle.cpp
engines/nancy/action/puzzle/bulpuzzle.h
diff --git a/engines/nancy/action/puzzle/bulpuzzle.cpp b/engines/nancy/action/puzzle/bulpuzzle.cpp
index 0ef5047be48..49f0ab17fa2 100644
--- a/engines/nancy/action/puzzle/bulpuzzle.cpp
+++ b/engines/nancy/action/puzzle/bulpuzzle.cpp
@@ -177,6 +177,10 @@ void BulPuzzle::updateGraphics() {
// This was the last move, go to next turn
g_nancy->_sound->playSound(_moveSound);
_currentAction = kNone;
+ // The voiced "move" line plays at the end of a player's turn (its second roll)
+ if (g_nancy->getGameType() >= kGameTypeNancy11 && (_turn == 1 || _turn == 3)) {
+ playVoiceLine(_turn / _numRolls, 3);
+ }
_turn = _turn + 1 > 3 ? 0 : _turn + 1;
_changeLight = true;
}
@@ -195,6 +199,10 @@ void BulPuzzle::updateGraphics() {
return;
case kPass:
_currentAction = kNone;
+ // The voiced "transition" line plays when a player passes their turn
+ if (g_nancy->getGameType() >= kGameTypeNancy11 && (_turn == 1 || _turn == 3)) {
+ playVoiceLine(_turn / _numRolls, 1);
+ }
_turn = (_turn + 1 > _numRolls * 2) ? 0 : _turn + 1;
return;
@@ -213,6 +221,11 @@ void BulPuzzle::readData(Common::SeekableReadStream &stream) {
_numCells = stream.readUint16LE();
_numPieces = stream.readUint16LE();
_numRolls = stream.readUint16LE();
+
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ stream.skip(2); // game mode + a flag byte
+ }
+
_playerStart = stream.readUint16LE();
_enemyStart = stream.readUint16LE();
@@ -256,23 +269,92 @@ void BulPuzzle::readData(Common::SeekableReadStream &stream) {
readRect(stream, _enemyLightSrc);
readRect(stream, _passButtonDisabledSrc);
- _moveSound.readNormal(stream);
- _enemyCapturedSound.readNormal(stream);
- _playerCapturedSound.readNormal(stream);
- _rollSound.readNormal(stream);
- _passSound.readNormal(stream);
- _resetSound.readNormal(stream);
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ // Nancy 11 keeps four fixed sounds, then per-event random voice-clip tables, then the scenes.
+ _moveSound.readNormal(stream);
+ _rollSound.readNormal(stream);
+ _passSound.readNormal(stream);
+ _resetSound.readNormal(stream);
+
+ // Captures are unused here and win/loss is voiced from the tables below, so the
+ // capture/solve/lose SoundDescriptions keep their default "NO SOUND".
+
+ // Two per-player blocks: a 6-byte header (channel + two unused shorts) then seven tables
+ static const int tableSizes[7] = { 1, 1, 4, 4, 4, 4, 4 };
+ for (int pl = 0; pl < 2; ++pl) {
+ _voiceChannel[pl] = stream.readUint16LE();
+ stream.skip(4);
+ for (int t = 0; t < 7; ++t) {
+ for (int e = 0; e < tableSizes[t]; ++e) {
+ Common::String name;
+ readFilename(stream, name);
+ if (!name.empty() && name != "NO SOUND")
+ _voiceLines[pl][t].push_back(name);
+ }
+ }
+ }
- _solveScene.readData(stream);
- _solveSoundDelay = stream.readUint16LE();
- _solveSound.readNormal(stream);
+ // Loss scene (two possible flags) then the solve scene (one flag). The event flags
+ // store a value rather than a simple on/off.
+ _exitScene._sceneChange.readData(stream);
+ _exitScene._sceneChange.continueSceneSound = stream.readUint16LE();
+ _exitScene._flag.label = stream.readSint16LE();
+ _exitScene._flag.flag = stream.readByte();
+ _loseFlag2.label = stream.readSint16LE();
+ _loseFlag2.flag = stream.readByte();
+
+ _solveScene._sceneChange.readData(stream);
+ _solveScene._sceneChange.continueSceneSound = stream.readUint16LE();
+ _solveScene._flag.label = stream.readSint16LE();
+ _solveScene._flag.flag = stream.readByte();
+ } else {
+ _moveSound.readNormal(stream);
+ _enemyCapturedSound.readNormal(stream);
+ _playerCapturedSound.readNormal(stream);
+ _rollSound.readNormal(stream);
+ _passSound.readNormal(stream);
+ _resetSound.readNormal(stream);
+
+ _solveScene.readData(stream);
+ _solveSoundDelay = stream.readUint16LE();
+ _solveSound.readNormal(stream);
+
+ _exitScene.readData(stream);
+ _loseSoundDelay = stream.readUint16LE();
+ _loseSound.readNormal(stream);
+ }
- _exitScene.readData(stream);
- _loseSoundDelay = stream.readUint16LE();
- _loseSound.readNormal(stream);
readRect(stream, _exitHotspot);
}
+Common::String BulPuzzle::pickVoiceLine(int player, int table) {
+ if (player < 0 || player > 1 || table < 0 || table > 6) {
+ return Common::String();
+ }
+
+ const Common::Array<Common::String> &lines = _voiceLines[player][table];
+ if (lines.empty()) {
+ return Common::String();
+ }
+
+ return lines[g_nancy->_randomSource->getRandomNumber(lines.size() - 1)];
+}
+
+void BulPuzzle::playVoiceLine(int player, int table) {
+ Common::String line = pickVoiceLine(player, table);
+ if (line.empty()) {
+ return;
+ }
+
+ g_nancy->_sound->stopSound(_voiceSound);
+ _voiceSound.name = line;
+ _voiceSound.channelID = _voiceChannel[player];
+ _voiceSound.numLoops = 1;
+ _voiceSound.volume = _moveSound.volume;
+ g_nancy->_sound->loadSound(_voiceSound);
+ g_nancy->_sound->playSound(_voiceSound);
+}
+
void BulPuzzle::execute() {
switch (_state) {
case kBegin:
@@ -284,26 +366,60 @@ void BulPuzzle::execute() {
g_nancy->_sound->loadSound(_passSound);
g_nancy->_sound->loadSound(_moveSound);
+ if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ _prevSide = (_numRolls > 0 && _turn >= _numRolls) ? 1 : 0;
+ playVoiceLine(1, 0); // opponent's opening line
+ }
+
_state = kRun;
// fall through
- case kRun:
+ case kRun: {
+ const bool isNancy11 = g_nancy->getGameType() >= kGameTypeNancy11;
+
+ // Voice the start of a turn when control changes hands
+ if (isNancy11 && _numRolls > 0) {
+ int side = (_turn >= _numRolls) ? 1 : 0;
+ if (side != _prevSide) {
+ _prevSide = side;
+ playVoiceLine(side, 2);
+ }
+ }
+
if (_playerPieces == 0) {
_state = kActionTrigger;
+ if (isNancy11) {
+ Common::String line = pickVoiceLine(1, 5); // opponent's victory line
+ if (!line.empty()) {
+ _loseSound.name = line;
+ _loseSound.channelID = _voiceChannel[1];
+ _loseSound.numLoops = 1;
+ _loseSound.volume = _moveSound.volume;
+ }
+ }
_nextMoveTime = g_nancy->getTotalPlayTime() + _loseSoundDelay * 1000;
}
if (_enemyPieces == 0) {
_playerWon = true;
_state = kActionTrigger;
+ if (isNancy11) {
+ Common::String line = pickVoiceLine(0, 5); // player's victory line
+ if (!line.empty()) {
+ _solveSound.name = line;
+ _solveSound.channelID = _voiceChannel[0];
+ _solveSound.numLoops = 1;
+ _solveSound.volume = _moveSound.volume;
+ }
+ }
_nextMoveTime = g_nancy->getTotalPlayTime() + _solveSoundDelay * 1000;
}
if (_state == kRun) {
break;
}
-
+ }
// fall through
- case kActionTrigger:
+ case kActionTrigger: {
SoundDescription &sound = _playerWon ? _solveSound : _loseSound;
if (g_nancy->getTotalPlayTime() >= _nextMoveTime) {
@@ -315,6 +431,10 @@ void BulPuzzle::execute() {
if (_nextMoveTime == 0 && !g_nancy->_sound->isSoundPlaying(sound)) {
if (_playerWon) {
_solveScene.execute();
+ } else if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ // The second flag marks giving up via the exit hotspot rather than being beaten
+ NancySceneState.changeScene(_exitScene._sceneChange);
+ NancySceneState.setEventFlag(_gaveUp ? _loseFlag2 : _exitScene._flag);
} else {
_exitScene.execute();
}
@@ -322,6 +442,7 @@ void BulPuzzle::execute() {
break;
}
+ }
}
void BulPuzzle::handleInput(NancyInput &input) {
@@ -331,6 +452,7 @@ void BulPuzzle::handleInput(NancyInput &input) {
if (input.input & NancyInput::kLeftMouseButtonUp) {
_state = kActionTrigger;
_nextMoveTime = 0;
+ _gaveUp = true;
}
return;
diff --git a/engines/nancy/action/puzzle/bulpuzzle.h b/engines/nancy/action/puzzle/bulpuzzle.h
index c6df9d9f143..22c08e1324d 100644
--- a/engines/nancy/action/puzzle/bulpuzzle.h
+++ b/engines/nancy/action/puzzle/bulpuzzle.h
@@ -48,6 +48,10 @@ protected:
void movePiece(bool player);
void reset(bool capture);
+ // Nancy 11: pick (and optionally play) a random clip from one of the per-player voice tables
+ Common::String pickVoiceLine(int player, int table);
+ void playVoiceLine(int player, int table);
+
Common::String getRecordTypeName() const override { return "BulPuzzle"; }
Common::Path _imageName;
@@ -110,8 +114,17 @@ protected:
SceneChangeWithFlag _exitScene; // also when losing
uint16 _loseSoundDelay = 0;
SoundDescription _loseSound;
+ FlagDescription _loseFlag2; // nancy11: event flag set when the player gives up rather than loses
Common::Rect _exitHotspot;
+ // Nancy 11 voice clips: two players, seven tables each (entry counts 1,1,4,4,4,4,4).
+ // Table 0 = opening line, 2 = turn line, 5 = end-of-game line.
+ Common::Array<Common::String> _voiceLines[2][7];
+ uint16 _voiceChannel[2] = {};
+ SoundDescription _voiceSound;
+ int _prevSide = -1;
+ bool _gaveUp = false;
+
Graphics::ManagedSurface _image;
int16 _playerPos = 0;
Commit: b41dc6fb3b2daeb1ba9e8fbf90d1e00150e94d04
https://github.com/scummvm/scummvm/commit/b41dc6fb3b2daeb1ba9e8fbf90d1e00150e94d04
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:07+03:00
Commit Message:
NANCY: Add new functionality for CollisionPuzzle in Nancy11
Changed paths:
engines/nancy/action/puzzle/collisionpuzzle.cpp
engines/nancy/action/puzzle/collisionpuzzle.h
diff --git a/engines/nancy/action/puzzle/collisionpuzzle.cpp b/engines/nancy/action/puzzle/collisionpuzzle.cpp
index 60e73452dc6..27f78648859 100644
--- a/engines/nancy/action/puzzle/collisionpuzzle.cpp
+++ b/engines/nancy/action/puzzle/collisionpuzzle.cpp
@@ -187,9 +187,15 @@ void CollisionPuzzle::readData(Common::SeekableReadStream &stream) {
readFilename(stream, _imageName);
uint16 numPieces = 0;
- // Nancy 10+ TileMove stores rows then cols (was width then height for square grids)
+ // Nancy 11 raised the Collision piece limit from 5 to 10 and added a leading flag
+ const bool collisionNancy11 = (_puzzleType == kCollision && g_nancy->getGameType() >= kGameTypeNancy11);
+ if (collisionNancy11) {
+ stream.skip(1); // pieces are randomized when this is 0
+ }
+
+ // Nancy 10+ TileMove (and Nancy 11+ Collision) store rows then cols (was width then height for square grids)
uint16 width, height;
- if (_puzzleType == kTileMove && g_nancy->getGameType() >= kGameTypeNancy10) {
+ if ((_puzzleType == kTileMove && g_nancy->getGameType() >= kGameTypeNancy10) || collisionNancy11) {
height = stream.readUint16LE();
width = stream.readUint16LE();
} else {
@@ -217,16 +223,29 @@ void CollisionPuzzle::readData(Common::SeekableReadStream &stream) {
}
stream.skip((maxGridSize - height) * maxGridSize * 2);
+ // Earlier Collision puzzles store wall/block markers as 6-10, which Nancy 11 reuses for
+ // home IDs. Shift them up to the internal 16-20 range.
+ if (_puzzleType == kCollision && g_nancy->getGameType() < kGameTypeNancy11) {
+ for (uint y = 0; y < height; ++y) {
+ for (uint x = 0; x < width; ++x) {
+ if (_grid[y][x] >= 6 && _grid[y][x] <= 10)
+ _grid[y][x] += 10;
+ }
+ }
+ }
+
if (_puzzleType == kCollision) {
+ const uint maxPieces = collisionNancy11 ? 10 : 5;
+
_startLocations.resize(numPieces);
for (uint i = 0; i < numPieces; ++i) {
_startLocations[i].x = stream.readUint16LE();
_startLocations[i].y = stream.readUint16LE();
}
- stream.skip((5 - numPieces) * 4);
+ stream.skip((maxPieces - numPieces) * 4);
- readRectArray(stream, _pieceSrcs, numPieces, 5);
- readRectArray(stream, _homeSrcs, numPieces, 5);
+ readRectArray(stream, _pieceSrcs, numPieces, maxPieces);
+ readRectArray(stream, _homeSrcs, numPieces, maxPieces);
readRect(stream, _verticalWallSrc);
readRect(stream, _horizontalWallSrc);
diff --git a/engines/nancy/action/puzzle/collisionpuzzle.h b/engines/nancy/action/puzzle/collisionpuzzle.h
index ab8c29638fe..de69f379c50 100644
--- a/engines/nancy/action/puzzle/collisionpuzzle.h
+++ b/engines/nancy/action/puzzle/collisionpuzzle.h
@@ -52,8 +52,9 @@ public:
bool isViewportRelative() const override { return true; }
protected:
- // numbers 1-5 are home IDs, 0 is empty cell
- enum WallType { kWallLeft = 6, kWallUp = 7, kWallDown = 8, kWallRight = 9, kBlock = 10 };
+ // 0 is an empty cell, positive numbers are home IDs. Wall/block markers use Nancy 11's
+ // values (16-20); earlier games store 6-10 and are remapped to these on load.
+ enum WallType { kWallLeft = 16, kWallUp = 17, kWallDown = 18, kWallRight = 19, kBlock = 20 };
class Piece : public RenderObject {
public:
Commit: f6315c643fec56c4635a8d72a23ff816f1a9ae48
https://github.com/scummvm/scummvm/commit/f6315c643fec56c4635a8d72a23ff816f1a9ae48
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-06-22T01:02:08+03:00
Commit Message:
NANCY: Implement the FadeSoundToSilence AR for Nancy11
Changed paths:
engines/nancy/action/arfactory.cpp
engines/nancy/action/soundrecords.cpp
engines/nancy/action/soundrecords.h
diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 506541bbbe7..d8fd412d6ae 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -373,8 +373,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
case 140:
return new SetVolume();
case 147: // Nancy11
- warning("FadeSoundToSilence"); // TODO
- return nullptr;
+ return new FadeSoundToSilence();
case 148:
// MakeScreenFile - seems to save a cropped image of the screen in a bitmap file?
// TODO: Used in Nancy 9, sand castle puzzle
diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index 43559183a8c..9a84bbbfe5d 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -21,6 +21,7 @@
#include "common/random.h"
#include "common/config-manager.h"
+#include "common/system.h"
#include "engines/nancy/nancy.h"
#include "engines/nancy/sound.h"
@@ -43,6 +44,36 @@ void SetVolume::execute() {
_isDone = true;
}
+void FadeSoundToSilence::readData(Common::SeekableReadStream &stream) {
+ channel = stream.readUint16LE();
+ stream.skip(2); // pad / flag
+ fadeTimeMs = stream.readUint32LE();
+}
+
+void FadeSoundToSilence::execute() {
+ switch (_state) {
+ case kBegin:
+ _startVolume = g_nancy->_sound->getVolume(channel);
+ _startTime = g_system->getMillis();
+ _state = kRun;
+ break;
+ case kRun: {
+ const uint32 elapsed = g_system->getMillis() - _startTime;
+ if (fadeTimeMs == 0 || elapsed >= fadeTimeMs) {
+ g_nancy->_sound->setVolume(channel, 0);
+ _state = kActionTrigger;
+ break;
+ }
+ const uint16 v = (uint16)((uint32)_startVolume * (fadeTimeMs - elapsed) / fadeTimeMs);
+ g_nancy->_sound->setVolume(channel, v);
+ break;
+ }
+ case kActionTrigger:
+ finishExecution();
+ break;
+ }
+}
+
void PlaySound::readData(Common::SeekableReadStream &stream) {
_sound.readDIGI(stream);
diff --git a/engines/nancy/action/soundrecords.h b/engines/nancy/action/soundrecords.h
index f1bf6703b23..02f5d34cc5a 100644
--- a/engines/nancy/action/soundrecords.h
+++ b/engines/nancy/action/soundrecords.h
@@ -40,6 +40,24 @@ protected:
Common::String getRecordTypeName() const override { return "SetVolume"; }
};
+// Nancy 11+ AR 147. Linearly ramps a channel's volume down to 0 over
+// the given time, then stops execution.
+class FadeSoundToSilence : public ActionRecord {
+public:
+ void readData(Common::SeekableReadStream &stream) override;
+ void execute() override;
+
+ uint16 channel = 0;
+ uint32 fadeTimeMs = 0;
+
+protected:
+ Common::String getRecordTypeName() const override { return "FadeSoundToSilence"; }
+
+private:
+ uint32 _startTime = 0;
+ uint16 _startVolume = 0;
+};
+
// Used for sound effects. From nancy3 up it includes 3D sound data, which lets
// the sound move in 3D space as the player rotates/changes scenes. Also supports
// changing the scene and/or setting a flag
More information about the Scummvm-git-logs
mailing list