[Scummvm-git-logs] scummvm master -> 4f437e92102edeaf8efce270fc62046ffc2203ac
bluegr
noreply at scummvm.org
Fri Sep 18 01:00:08 UTC 2026
This automated email contains information about 13 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .
Summary:
3d76e87d17 NANCY: Implement end of day / sleep, plus extra single values
1634a263dd NANCY: Update handling of dependencies and time in the debug console
b43c76f39a NANCY: Add further extra info to debug output
af36a934aa NANCY: NANCY13+: Keep sounds playing on sound-triggered scene changes
b52790e7be NANCY: NANCY14: Fixes for secondary movies
278b44e872 NANCY: NANCY14: Fixes for cake baking puzzle (BuildPuzzle)
1e2fc7008b NANCY: NANCY14: Implement digit images in TextLineOverlay
5886eb87ce NANCY: NANCY10+: Fixes for MazeChasePuzzle
e5e15baee1 NANCY: NANCY15: Implement the solar panel cells puzzle (MagicBoxPuzzle)
c6c543dd9b NANCY: NANCY14: Add new functionality for TurningPuzzle
2fbe61c225 NANCY: NANCY14: Handle scene background videos with an alpha plane
fc6963582e NANCY: NANCY14: Fixes for the camera
4f437e9210 NANCY: NANCY13-14: Implement new functionality for RotatingLockPuzzle
Commit: 3d76e87d174d6110ff95848a35147f06c5711483
https://github.com/scummvm/scummvm/commit/3d76e87d174d6110ff95848a35147f06c5711483
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:32+03:00
Commit Message:
NANCY: Implement end of day / sleep, plus extra single values
- Extend the value table to 100 single values, like the original engine
- Implement the end of day and sleep functionality
- Read correct values from the BSUM chunk - also fixes the "More Nancy
Drew!" menu option in Nancy11+
- Store the current day as a separate value, like in the original.
Scripts in Nancy14 and later games may alter this variable directly,
instead of setting a new time
- Trigger the "late night" flag properly when the day should be changed
- Stop evaluating dependencies once they're satisfied for Vampire,
Nancy1 and Nancy2, like the original engine does
- Treat hours and minutes as signed integers, like in the original
Fixes the time of day and late night handling in Nancy11+. Savegame
version is bumped, but earlier saved games are still compatible, and
are given sane values for the time of day
Changed paths:
engines/nancy/action/actionmanager.cpp
engines/nancy/action/actionmanager.h
engines/nancy/action/actionrecord.h
engines/nancy/action/datarecords.cpp
engines/nancy/action/miscrecords.cpp
engines/nancy/action/miscrecords.h
engines/nancy/commontypes.h
engines/nancy/enginedata.cpp
engines/nancy/enginedata.h
engines/nancy/nancy.h
engines/nancy/puzzledata.cpp
engines/nancy/puzzledata.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 0f388cd1d35..d95daa58bc0 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -126,6 +126,12 @@ void ActionManager::handleInput(NancyInput &input) {
}
}
+// Timer dependencies that can take their seconds from the BSUM timer durations table
+static bool isTimerDurationDependency(DependencyType type) {
+ return g_nancy->getGameType() >= kGameTypeNancy9 && g_nancy->getGameType() <= kGameTypeNancy11 &&
+ (type == DependencyType::kTimerLessThanDependencyTime || type == DependencyType::kTimerGreaterThanDependencyTime);
+}
+
void ActionManager::addNewActionRecord(Common::SeekableReadStream &inputData) {
ActionRecord *newRecord = createAndLoadNewRecord(inputData);
if (!newRecord) {
@@ -208,15 +214,8 @@ ActionRecord *ActionManager::createAndLoadNewRecord(Common::SeekableReadStream &
switch (dep.type) {
case DependencyType::kElapsedPlayerTime:
- dep.timeData = dep.hours * 3600000 + dep.minutes * 60000;
-
- if (g_nancy->getGameType() < kGameTypeNancy3) {
- // Older titles only checked if the time is less than the one in the dependency
- dep.condition = 0;
- }
-
- break;
case DependencyType::kSceneCount:
+ // These keep their own data in the time fields, which isn't a duration
break;
case DependencyType::kOpenParenthesis:
depStack.push(&dep);
@@ -227,7 +226,18 @@ ActionRecord *ActionManager::createAndLoadNewRecord(Common::SeekableReadStream &
break;
default:
if (dep.hours != -1 || dep.minutes != -1 || dep.seconds != -1) {
- dep.timeData = ((dep.hours * 60 + dep.minutes) * 60 + dep.seconds) * 1000 + dep.milliseconds;
+ int16 seconds = dep.seconds;
+ if (seconds >= kTimerDurationIndexBase && isTimerDurationDependency(dep.type)) {
+ auto *bootSummary = GetEngineData(BSUM);
+ assert(bootSummary);
+
+ uint index = seconds - kTimerDurationIndexBase;
+ if (index < bootSummary->timerDurations.size()) {
+ seconds = bootSummary->timerDurations[index];
+ }
+ }
+
+ dep.timeData = ((dep.hours * 60 + dep.minutes) * 60 + seconds) * 1000 + dep.milliseconds;
}
break;
@@ -255,6 +265,8 @@ void ActionManager::processActionRecords() {
record->_isActive = _previousRecordWasExecuted = record->_dependencies.satisfied;
if (record->_isActive) {
+ _executedRecordTypes[record->_type] = true;
+
if(record->_state == ActionRecord::kBegin) {
_activatedRecordsThisFrame.push_back(record);
}
@@ -273,6 +285,15 @@ void ActionManager::processActionRecords() {
debugDrawHotspots();
}
+// How a player time dependency compares the clock against its time. Titles
+// before nancy3 have no condition, and always wait for the time to pass.
+enum PlayerTimeComparison {
+ kPlayerTimeAfter = 0,
+ kPlayerTimeBefore = 1,
+ kPlayerTimeEqual = 2,
+ kPlayerTimeBetween = 3 // Nancy11+
+};
+
// How a value-table test dependency (see below) compares the value against its
// threshold. Matches the Nancy14 comparator's condition encoding.
enum ValueTestComparison {
@@ -283,6 +304,28 @@ enum ValueTestComparison {
kValueLessOrEqual = 4
};
+// A value-table test dependency with this in its hours field compares against
+// another value instead of a constant
+static const int16 kValueTestAgainstValue = 1;
+
+// A value index that doesn't point to any value
+static const int16 kNoValueIndex = 0xFF;
+
+// Reads a value for a value-table test dependency. Combo values are truncated,
+// and an invalid index leaves the given default in place.
+static int32 getValueTestValue(const TableData &table, int16 index, int32 defaultValue) {
+ if (index == kNoValueIndex) {
+ return defaultValue;
+ }
+
+ uint numSingleValues = table.getNumSingleValues();
+ if ((uint16)index < numSingleValues) {
+ return table.getSingleValue(index);
+ }
+
+ return (int32)table.getComboValue(index - numSingleValues);
+}
+
// Nancy14 repurposed dependency type 13 as a value-table test: the label is a
// value index, the milliseconds field the threshold, and the condition the
// comparison (value OP threshold). The rooftop fight's win/lose scene changes use
@@ -291,18 +334,23 @@ enum ValueTestComparison {
// unused from Nancy12 on (the original aborts on it).
static bool evaluateValueTestDependency(const DependencyRecord &dep) {
TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
- if (!table) {
- return false;
+ assert(table);
+
+ // The threshold is the raw milliseconds field, matching the type-10 resource
+ // test (kElapsedPlayerDay) that shares this layout. When the hours field is
+ // kValueTestAgainstValue, the milliseconds field is a value index instead.
+ int32 threshold = dep.milliseconds;
+ if (dep.hours == kValueTestAgainstValue) {
+ threshold = getValueTestValue(*table, dep.milliseconds, threshold);
}
- int32 value = table->getValue(dep.label);
- if (value == kNoTableValue) {
+ int32 value = getValueTestValue(*table, dep.label, 0);
+
+ // Nancy14 compares unset values as they are, Nancy15 fails the test
+ if (g_nancy->getGameType() >= kGameTypeNancy15 && (value == kNoTableValue || threshold == kNoTableValue)) {
return false;
}
- // The threshold is the raw milliseconds field, matching the type-10 resource
- // test (kElapsedPlayerDay) that shares this layout.
- int32 threshold = dep.milliseconds;
switch (dep.condition) {
case kValueEqual:
return value == threshold;
@@ -378,6 +426,12 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
}
}
} else {
+ // Vampire, nancy1 and nancy2 stop evaluating a dependency once it's satisfied.
+ // Cursor dependencies are exempt, since they're handled when clicking on a hotspot
+ if (g_nancy->getGameType() <= kGameTypeNancy2 && dep.satisfied && dep.type != DependencyType::kCursorType) {
+ return;
+ }
+
switch (dep.type) {
case DependencyType::kNone:
dep.satisfied = true;
@@ -435,17 +489,42 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
break;
case DependencyType::kElapsedPlayerTime: {
// We're only interested in the hours and minutes
- Time playerTime = NancySceneState.getPlayerTime().getHours() * 3600000 +
- NancySceneState.getPlayerTime().getMinutes() * 60000;
+ int32 playerMinutes = NancySceneState.getPlayerTimeMinutes();
+ int32 depMinutes = dep.hours * 60 + dep.minutes;
+
+ if (g_nancy->getGameType() <= kGameTypeNancy2) {
+ // Hours and minutes are compared separately
+ Time playerTime = NancySceneState.getPlayerTime();
+ dep.satisfied = dep.hours <= playerTime.getHours() && dep.minutes <= playerTime.getMinutes();
+ break;
+ }
+
switch (dep.condition) {
- case 0:
- dep.satisfied = dep.timeData < playerTime;
+ case kPlayerTimeAfter:
+ dep.satisfied = depMinutes <= playerMinutes;
break;
- case 1:
- dep.satisfied = dep.timeData > playerTime;
+ case kPlayerTimeBefore:
+ dep.satisfied = depMinutes >= playerMinutes;
+ break;
+ case kPlayerTimeEqual:
+ dep.satisfied = depMinutes == playerMinutes;
+ break;
+ case kPlayerTimeBetween: {
+ // The end time is stored in the seconds and milliseconds fields.
+ // A range that ends before it starts wraps around midnight.
+ int32 endMinutes = dep.seconds * 60 + dep.milliseconds;
+ if (depMinutes <= endMinutes) {
+ dep.satisfied = playerMinutes >= depMinutes && playerMinutes <= endMinutes;
+ } else {
+ dep.satisfied = (playerMinutes >= depMinutes && playerMinutes < 24 * 60) ||
+ (playerMinutes >= 0 && playerMinutes <= endMinutes);
+ }
+
+ break;
+ }
+ default:
+ dep.satisfied = false;
break;
- case 2:
- dep.satisfied = dep.timeData == playerTime;
}
break;
@@ -516,24 +595,11 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
break;
}
- if (record._days == -1) {
- record._days = NancySceneState.getPlayerTime().getDays();
- dep.satisfied = true;
- break;
- }
-
- if (record._days < NancySceneState.getPlayerTime().getDays()) {
- record._days = NancySceneState.getPlayerTime().getDays();
-
- // This is not used in nancy3 and up, so it's a safe assumption that we
- // do not need to check types recursively
- for (uint j = 0; j < record._dependencies.children.size(); ++j) {
- if (record._dependencies.children[j].type == DependencyType::kElapsedPlayerTime) {
- record._dependencies.children[j].satisfied = false;
- }
- }
- }
-
+ // Satisfied as soon as it's evaluated. Vampire, nancy1 and nancy2 also reset
+ // player time dependencies on a day change, but that code can't run, since this
+ // dependency is never evaluated again once it's satisfied. Nancy7 to nancy11
+ // don't support this dependency at all.
+ dep.satisfied = true;
break;
case DependencyType::kCursorType: {
if (doNotCheckCursor) {
@@ -677,7 +743,23 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
break;
case DependencyType::kDefaultAR:
- dep.satisfied = !_previousRecordWasExecuted;
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ // Satisfied while no record of this record's own type, nor of any of
+ // the (up to four) extra types listed in the time fields, has executed
+ // in the current scene. Once satisfied, it stays satisfied.
+ if (!dep.stopEvaluating) {
+ const int16 extraTypes[] = { dep.hours, dep.minutes, dep.seconds, dep.milliseconds };
+ dep.satisfied = !_executedRecordTypes[record._type];
+ for (uint i = 0; i < ARRAYSIZE(extraTypes) && dep.satisfied && extraTypes[i] != 0; ++i) {
+ dep.satisfied = !_executedRecordTypes[(byte)extraTypes[i]];
+ }
+
+ dep.stopEvaluating = dep.satisfied;
+ }
+ } else {
+ dep.satisfied = !_previousRecordWasExecuted;
+ }
+
break;
case DependencyType::kPlayerCharacter:
// Nancy15+ only: gates a record on who is being played, so the
@@ -703,6 +785,7 @@ void ActionManager::clearActionRecords(bool nextIsNoArt) {
}
_activatedRecordsThisFrame.clear();
_previousRecordWasExecuted = false;
+ memset(_executedRecordTypes, 0, sizeof(_executedRecordTypes));
}
void ActionManager::onPause(bool pause) {
diff --git a/engines/nancy/action/actionmanager.h b/engines/nancy/action/actionmanager.h
index 171c01ec9db..25735c9a679 100644
--- a/engines/nancy/action/actionmanager.h
+++ b/engines/nancy/action/actionmanager.h
@@ -81,6 +81,9 @@ protected:
bool _previousRecordWasExecuted = false;
Common::Array<ActionRecord *> _activatedRecordsThisFrame;
+
+ // Nancy14+: which action record types have executed in the current scene
+ bool _executedRecordTypes[256] = {};
};
} // End of namespace Action
diff --git a/engines/nancy/action/actionrecord.h b/engines/nancy/action/actionrecord.h
index f6ee7cbc1e6..9034ab20c75 100644
--- a/engines/nancy/action/actionrecord.h
+++ b/engines/nancy/action/actionrecord.h
@@ -115,7 +115,6 @@ public:
_isDone(false),
_hasHotspot(false),
_state(ExecutionState::kBegin),
- _days(-1),
_cursorDependency(nullptr) {}
virtual ~ActionRecord() {}
@@ -162,7 +161,6 @@ public:
bool _hasHotspot;
Common::Rect _hotspot;
ExecutionState _state;
- int16 _days;
DependencyRecord *_cursorDependency;
};
diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index 0c9ae4798c4..2b74f2f073b 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -129,6 +129,12 @@ void SetValue::execute() {
uint numSingleValues = playerTable->getNumSingleValues();
+ auto *bootSummary = GetEngineData(BSUM);
+ if (bootSummary && bootSummary->endOfDayFlag != kEvNoEvent && _index == bootSummary->dayValueIndex) {
+ // Writing to the day value sends the player to bed
+ NancySceneState.requestSleep();
+ }
+
if (_index < numSingleValues) {
// Single values
int16 curValue = playerTable->getSingleValue(_index);
@@ -170,10 +176,10 @@ void SetValueCombo::execute() {
playerTable->setComboValue(_valueIndex - numSingleValues, 0);
for (uint i = 0; i < _indices.size(); ++i) {
- if (_indices[i] != kNoTableIndex) {
+ if (_indices[i] != playerTable->getNoIndex()) {
float valueToAdd = 0;
- if (_indices[i] == 100) { // ACTUAL_VALUE
+ if (_indices[i] == playerTable->getLiteralIndex()) {
valueToAdd = _percentages[i];
} else {
if (_indices[i] < numSingleValues) {
@@ -240,6 +246,12 @@ void ValueTest::execute() {
testedValue = playerTable->getComboValue(_valueIndex - numSingleValues);
}
+ if (testedValue == (float)kNoTableValue) {
+ // Nothing to test until the value gets set
+ finishExecution();
+ return;
+ }
+
// Pick which values we will test against, depending on the _testType param
Common::Array<byte> testedIndices;
switch (_testType) {
@@ -251,7 +263,7 @@ void ValueTest::execute() {
break;
case kTestAllCombo:
- testedIndices.resize(g_nancy->getGameType() == kGameTypeNancy8 ? 20 : 10);
+ testedIndices.resize(playerTable->getNumComboValues());
for (uint i = 0; i < testedIndices.size(); ++i) {
testedIndices[i] = i + numSingleValues;
}
@@ -266,7 +278,12 @@ void ValueTest::execute() {
bool satisfied = false;
for (uint i = 0; i < testedIndices.size(); ++i) {
- if (testedIndices[i] == kNoTableIndex) {
+ if (testedIndices[i] == playerTable->getNoIndex()) {
+ continue;
+ }
+
+ if ((_testType == kTestAllSingle || _testType == kTestAllCombo) && testedIndices[i] == _valueIndex) {
+ // Don't test the value against itself
continue;
}
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 0377b6f9785..cdb7bc88189 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -405,12 +405,12 @@ void CellPhonePopCellSceneFromStack::execute() {
void BumpPlayerClock::readData(Common::SeekableReadStream &stream) {
_relative = stream.readByte();
- _hours = stream.readUint16LE();
- _minutes = stream.readUint16LE();
+ _hours = stream.readSint16LE();
+ _minutes = stream.readSint16LE();
}
void BumpPlayerClock::execute() {
- NancySceneState.setPlayerTime(_hours * 3600000 + _minutes * 60000, _relative);
+ NancySceneState.setPlayerTime((uint32)(_hours * 3600000 + _minutes * 60000), _relative);
finishExecution();
}
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 54870e953a4..c3eebb04c24 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -250,8 +250,8 @@ public:
void execute() override;
byte _relative;
- uint16 _hours;
- uint16 _minutes;
+ int16 _hours;
+ int16 _minutes;
protected:
Common::String getRecordTypeName() const override { return "BumpPlayerClock"; }
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index 455ad93b485..56bdf45c8b2 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -146,6 +146,7 @@ static const byte kIncrementTableValue = 1;
static const byte kDecrementTableValue = 2;
static const uint16 kNoTableIndex = 99;
static const int16 kNoTableValue = 9999;
+static const int16 kTimerDurationIndexBase = 5000;
// Autotext ordering info
static const uint16 kListLIFO = 0;
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 6f860b386fd..27151ba8e84 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -51,40 +51,37 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
readFilename(s, fontFilename, kGameTypeNancy12);
readFilename(s, flagsFilename, kGameTypeNancy12);
- // Nancy16 went back to the pre-Nancy14 field order, and dropped both vertical offsets
+ // Nancy16 went back to the pre-Nancy14 field order, and dropped both vertical
+ // offsets and the ad scene
s.syncAsUint16LE(firstScene.sceneID, kGameTypeNancy16);
s.syncAsUint16LE(firstScene.frameID, kGameTypeNancy16);
s.syncAsUint16LE(startTimeHours, kGameTypeNancy16);
s.syncAsUint16LE(startTimeMinutes, kGameTypeNancy16);
- s.skip(1, kGameTypeNancy16); // Unknown
- s.syncAsUint16LE(adScene.sceneID, kGameTypeNancy16);
- s.syncAsUint16LE(adScene.frameID, kGameTypeNancy16);
- s.skip(2, kGameTypeNancy16); // Unknown
+ s.skip(7, kGameTypeNancy16); // End of day fields, disabled in the game data
- s.skip(1, kGameTypeNancy14, kGameTypeNancy15);
- s.syncAsUint16LE(firstScene.sceneID, kGameTypeVampire, kGameTypeNancy13);
+ s.syncAsUint16LE(firstScene.sceneID, kGameTypeVampire, kGameTypeNancy15);
s.skip(0xC, kGameTypeVampire, kGameTypeVampire); // Palette name + unknown 2 bytes
s.syncAsUint16LE(firstScene.frameID, kGameTypeVampire, kGameTypeNancy15);
- s.syncAsUint16LE(firstScene.sceneID, kGameTypeNancy14, kGameTypeNancy15);
- s.skip(2, kGameTypeNancy14, kGameTypeNancy15); // Unknown
s.syncAsUint16LE(firstScene.verticalOffset, kGameTypeVampire, kGameTypeNancy15);
- s.syncAsUint16LE(startTimeHours, kGameTypeVampire, kGameTypeNancy13);
- s.syncAsUint16LE(startTimeMinutes, kGameTypeVampire, kGameTypeNancy13);
+ s.syncAsUint16LE(startTimeHours, kGameTypeVampire, kGameTypeNancy15);
+ s.syncAsUint16LE(startTimeMinutes, kGameTypeVampire, kGameTypeNancy15);
- s.skip(1, kGameTypeNancy14, kGameTypeNancy15);
+ s.syncAsUint16LE(lateNightHour, kGameTypeNancy11, kGameTypeNancy13);
+ s.syncAsSint16LE(lateNightFlag, kGameTypeNancy11, kGameTypeNancy13);
- s.skip(1, kGameTypeNancy14, kGameTypeNancy15);
- s.syncAsUint16LE(adScene.sceneID, kGameTypeNancy7, kGameTypeNancy13);
+ s.syncAsByte(dayValueIndex, kGameTypeNancy14, kGameTypeNancy15);
+ s.syncAsSint16LE(endOfDayFlag, kGameTypeNancy14, kGameTypeNancy15);
+ s.syncAsUint16LE(endOfDayHour, kGameTypeNancy14, kGameTypeNancy15);
+ s.syncAsUint16LE(wakeUpHour, kGameTypeNancy14, kGameTypeNancy15);
+
+ s.syncAsUint16LE(adScene.sceneID, kGameTypeNancy7, kGameTypeNancy15);
s.syncAsUint16LE(adScene.frameID, kGameTypeNancy7, kGameTypeNancy15);
- s.syncAsUint16LE(adScene.sceneID, kGameTypeNancy14, kGameTypeNancy15);
- s.skip(2, kGameTypeNancy14, kGameTypeNancy15); // Unknown
s.syncAsUint16LE(adScene.verticalOffset, kGameTypeNancy7, kGameTypeNancy15);
s.skip(0xA4, kGameTypeVampire, kGameTypeNancy2);
// Nancy15 added the studio URL, preceded by a few unknown values
- s.skip(4, kGameTypeNancy15, kGameTypeNancy15); // Unknown
s.skip(2, kGameTypeNancy15); // Unknown
s.skip(256, kGameTypeNancy15); // Studio URL
@@ -98,7 +95,6 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
s.skip(1);
}
- s.skip(4, kGameTypeNancy11, kGameTypeNancy14); // Unknown
s.skip(8, kGameTypeVampire, kGameTypeVampire);
readRect(s, extraButtonHotspot, kGameTypeVampire, kGameTypeVampire);
@@ -144,6 +140,14 @@ BSUM::BSUM(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
s.syncAsByte(overrideMovementTimeDeltas);
s.syncAsSint16LE(slowMovementTimeDelta);
s.syncAsSint16LE(fastMovementTimeDelta);
+
+ s.skip(4, kGameTypeNancy9, kGameTypeNancy11); // Unknown
+ if (s.getVersion() >= kGameTypeNancy9 && s.getVersion() <= kGameTypeNancy11) {
+ timerDurations.resize(10);
+ for (uint i = 0; i < timerDurations.size(); ++i) {
+ s.syncAsUint16LE(timerDurations[i]);
+ }
+ }
}
VIEW::VIEW(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 30606830b6c..ee7f92d6338 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -55,6 +55,20 @@ struct BSUM : public EngineData {
uint16 startTimeHours;
uint16 startTimeMinutes;
+ // Nancy11-13: on the day after the game starts, once the clock reaches
+ // lateNightHour, lateNightFlag gets raised
+ uint16 lateNightHour = 0;
+ int16 lateNightFlag = kEvNoEvent;
+
+ // Nancy14-15 end of day. Once the clock reaches endOfDayHour, endOfDayFlag
+ // is raised so the scripts can send the player to bed. Writing to the value
+ // at dayValueIndex puts the player to sleep: the next day starts at
+ // wakeUpHour, and the new day number gets copied into that value.
+ byte dayValueIndex = 0;
+ int16 endOfDayFlag = kEvNoEvent;
+ uint16 endOfDayHour = 0;
+ uint16 wakeUpHour = 0;
+
// More Nancy Drew! scene
SceneChangeDescription adScene;
@@ -90,6 +104,10 @@ struct BSUM : public EngineData {
byte overrideMovementTimeDeltas;
uint16 slowMovementTimeDelta;
uint16 fastMovementTimeDelta;
+
+ // Nancy9-11: timer dependencies with a seconds value of kTimerDurationIndexBase
+ // or above take their seconds from this table instead
+ Common::Array<uint16> timerDurations;
};
// Contains rects defining the in-game viewport
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index 683ebaeb5ba..44966f582f2 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -65,7 +65,10 @@ namespace Nancy {
// - 8: Nancy12 DrivingPuzzle fuel state persisted
// - 9: RippedLetterPuzzle stores its scene ID and tried flag
// - 10: Nancy14/15 inventory arrays hold 49 items instead of 50
-static const int kSavegameVersion = 10;
+// - 11: Nancy12+ TableData holds 100 single values instead of 30, and
+// the Nancy14/15 player clock only holds the time of the current
+// day, with the day itself stored separately
+static const int kSavegameVersion = 11;
struct NancyGameDescription;
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 88be7f7c6eb..d0aead22f08 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -250,6 +250,26 @@ void TableData::synchronize(Common::Serializer &ser) {
}
ser.syncArray(comboValues.data(), num, Common::Serializer::FloatLE);
+
+ if (ser.isLoading() && ser.getVersion() < 11 && g_nancy->getGameType() >= kGameTypeNancy12) {
+ // Older saves split the values at index 30 instead of 100, so everything
+ // from index 30 on was stored as a combo value
+ Common::Array<float> oldComboValues = comboValues;
+ comboValues.clear();
+
+ for (uint i = 0; i < oldComboValues.size(); ++i) {
+ if (oldComboValues[i] == (float)kNoTableValue) {
+ continue;
+ }
+
+ uint index = i + 30;
+ if (index < getNumSingleValues()) {
+ setSingleValue(index, (int16)oldComboValues[i]);
+ } else {
+ setComboValue(index - getNumSingleValues(), oldComboValues[i]);
+ }
+ }
+ }
}
static void syncInt16Array(Common::Serializer &ser, Common::Array<int16> &arr) {
@@ -318,6 +338,11 @@ void QuizPuzzleData::synchronize(Common::Serializer &ser) {
}
void TableData::setSingleValue(uint16 index, int16 value) {
+ if (index >= getNumSingleValues()) {
+ warning("TableData: single value index %u is out of range", index);
+ return;
+ }
+
if (singleValues.size() <= index) {
singleValues.resize(index + 1, kNoTableValue);
}
@@ -330,6 +355,11 @@ int16 TableData::getSingleValue(uint16 index) const {
}
void TableData::setComboValue(uint16 index, float value) {
+ if (index >= getNumComboValues()) {
+ warning("TableData: combo value index %u is out of range", index);
+ return;
+ }
+
if (comboValues.size() <= index) {
comboValues.resize(index + 1, kNoTableValue);
}
@@ -342,8 +372,31 @@ float TableData::getComboValue(uint16 index) const {
}
uint TableData::getNumSingleValues() const {
- // nancy8 has 20 single & 20 combo values, later games have 30/10
- return g_nancy->getGameType() <= kGameTypeNancy8 ? 20 : 30;
+ if (g_nancy->getGameType() <= kGameTypeNancy8) {
+ return 20;
+ } else if (g_nancy->getGameType() <= kGameTypeNancy11) {
+ return 30;
+ }
+
+ return 100;
+}
+
+uint TableData::getNumComboValues() const {
+ return g_nancy->getGameType() == kGameTypeNancy8 ? 20 : 10;
+}
+
+byte TableData::getNoIndex() const {
+ if (g_nancy->getGameType() <= kGameTypeNancy11) {
+ return kNoTableIndex;
+ } else if (g_nancy->getGameType() == kGameTypeNancy12) {
+ return 121;
+ }
+
+ return 255;
+}
+
+byte TableData::getLiteralIndex() const {
+ return g_nancy->getGameType() <= kGameTypeNancy11 ? 100 : 120;
}
int16 TableData::getValue(uint16 index) const {
@@ -356,6 +409,15 @@ int16 TableData::getValue(uint16 index) const {
return (int16)(value + (value < 0 ? -0.5f : 0.5f));
}
+void TableData::setValue(uint16 index, int16 value) {
+ uint numSingleValues = getNumSingleValues();
+ if (index < numSingleValues) {
+ setSingleValue(index, value);
+ } else {
+ setComboValue(index - numSingleValues, value);
+ }
+}
+
void CellPhoneData::synchronize(Common::Serializer &ser) {
ser.syncAsByte(noSignal);
ser.syncAsByte(batteryLow);
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 3f7153b995b..dbcfe691b2e 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -231,12 +231,19 @@ struct TableData : public PuzzleData {
float getComboValue(uint16 index) const;
// The number of single (non-combo) values, i.e. the boundary between the
- // single-value and combo-value index ranges: 20 up to nancy8, 30 afterwards.
+ // single-value and combo-value index ranges.
uint getNumSingleValues() const;
+ uint getNumComboValues() const;
+
+ // Index markers used inside SetValueCombo and ValueTest records: an entry
+ // to skip, and an entry whose payload is used as a literal number.
+ byte getNoIndex() const;
+ byte getLiteralIndex() const;
// Reads a value by its combined index (single values come first, then combos).
// Combo (float) values are rounded to the nearest integer.
int16 getValue(uint16 index) const;
+ void setValue(uint16 index, int16 value);
Common::Array<int16> singleValues;
Common::Array<float> comboValues;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 47dce9852f4..28038c23002 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -337,18 +337,40 @@ void Scene::finishUIPrepScene() {
}
void Scene::setPlayerTime(Time time, byte relative) {
+ auto *bootSummary = GetEngineData(BSUM);
+ assert(bootSummary);
+
if (relative == kRelativeClockBump) {
- // Relative, add the specified time to current playerTime
+ // Relative, add the specified time to current playerTime. The originals wrap
+ // a negative time around to the previous day, which no game script needs.
+ if ((int64)(uint32)_timers.playerTime + (int32)(uint32)time < 0) {
+ warning("Moving the player time back past 00:00 is not supported");
+ return;
+ }
+
_timers.playerTime += time;
+ } else if (bootSummary->endOfDayFlag != kEvNoEvent) {
+ // Absolute, the clock only holds the time of the current day
+ _timers.playerTime = time;
} else {
// Absolute, maintain days but replace hours and minutes
_timers.playerTime = _timers.playerTime.getDays() * 86400000 + time;
}
+ _timers.playerTimeNextMinute = g_nancy->getTotalPlayTime() + bootSummary->playerTimeMinuteLength;
+}
+
+uint Scene::getPlayerTimeMinutes() const {
auto *bootSummary = GetEngineData(BSUM);
assert(bootSummary);
- _timers.playerTimeNextMinute = g_nancy->getTotalPlayTime() + bootSummary->playerTimeMinuteLength;
+ if (bootSummary->endOfDayFlag != kEvNoEvent) {
+ // Games with an end of day don't wrap the clock at midnight, so
+ // staying up late keeps counting past 24:00
+ return _timers.playerTime.getTotalHours() * 60 + _timers.playerTime.getMinutes();
+ }
+
+ return _timers.playerTime.getHours() * 60 + _timers.playerTime.getMinutes();
}
byte Scene::getPlayerTOD() const {
@@ -372,9 +394,9 @@ byte Scene::getPlayerTOD() const {
auto *bootSummary = GetEngineData(BSUM);
assert(bootSummary);
- uint16 minutes = _timers.playerTime.getHours() * 60 + _timers.playerTime.getMinutes();
+ uint minutes = getPlayerTimeMinutes();
- if (minutes >= bootSummary->dayStartMinutes && minutes < bootSummary->dayEndMinutes) {
+ if (minutes >= bootSummary->dayStartMinutes && minutes <= bootSummary->dayEndMinutes) {
return kPlayerDay;
} else {
return kPlayerNight;
@@ -1237,6 +1259,16 @@ void Scene::synchronize(Common::Serializer &ser) {
ser.syncAsUint32LE((uint32 &)_timers.lastTotalTime);
ser.syncAsUint32LE((uint32 &)_timers.sceneTime);
ser.syncAsUint32LE((uint32 &)_timers.playerTime);
+ ser.syncAsSint16LE(_timers.playerDay, 11);
+
+ if (ser.isLoading() && ser.getVersion() < 11) {
+ auto *bootSummary = GetEngineData(BSUM);
+ if (bootSummary && bootSummary->endOfDayFlag != kEvNoEvent) {
+ // Older saves kept counting days into the clock. The day itself is
+ // restored from the day value once the puzzle data has been loaded.
+ _timers.playerTime = _timers.playerTime.getHours() * 3600000 + _timers.playerTime.getMinutes() * 60000;
+ }
+ }
ser.syncAsUint32LE((uint32 &)_timers.pushedPlayTime);
ser.syncAsUint32LE((uint32 &)_timers.timerTime);
ser.syncAsByte(_timers.timerIsActive);
@@ -1325,6 +1357,15 @@ void Scene::synchronize(Common::Serializer &ser) {
}
}
+ auto *bootSummary = GetEngineData(BSUM);
+ if (ser.getVersion() < 11 && bootSummary && bootSummary->endOfDayFlag != kEvNoEvent) {
+ // Older saves only have the day in the day value
+ TableData *table = (TableData *)getPuzzleData(TableData::getTag());
+ assert(table);
+ int16 day = table->getValue(bootSummary->dayValueIndex);
+ _timers.playerDay = day == kNoTableValue ? 0 : day;
+ }
+
// Restore the taskbar disable overrides now that the persisted
// TaskbarData is available. A disable can be set from an earlier
// scene's AR that won't re-run here, so it has to come from the save.
@@ -1389,12 +1430,18 @@ void Scene::init() {
g_nancy->_cursor->setCursorItemID(-1);
_timers.lastTotalTime = 0;
- _timers.playerTime = bootSummary->startTimeHours * 3600000;
+ _timers.playerTime = bootSummary->startTimeHours * 3600000 + bootSummary->startTimeMinutes * 60000;
_timers.sceneTime = 0;
_timers.timerTime = 0;
_timers.timerIsActive = false;
_timers.playerTimeNextMinute = 0;
_timers.pushedPlayTime = 0;
+ _timers.sleepRequested = false;
+ _timers.playerDay = 0;
+
+ if (bootSummary->endOfDayFlag != kEvNoEvent) {
+ setPlayerDay(0);
+ }
if (ConfMan.hasKey("load_ad", Common::ConfigManager::kTransientDomain)) {
changeScene(bootSummary->adScene);
@@ -1659,6 +1706,8 @@ void Scene::run() {
_timers.playerTimeNextMinute = currentPlayTime + bootSummary->playerTimeMinuteLength;
}
+ updateEndOfDay();
+
handleInput();
if (g_nancy->getState() == NancyState::kMainMenu) {
@@ -1691,6 +1740,41 @@ void Scene::run() {
}
}
+void Scene::updateEndOfDay() {
+ auto *bootSummary = GetEngineData(BSUM);
+ assert(bootSummary);
+
+ if (bootSummary->lateNightFlag != kEvNoEvent) {
+ if (_timers.playerTime.getDays() == 1 && _timers.playerTime.getHours() >= bootSummary->lateNightHour) {
+ setEventFlag(bootSummary->lateNightFlag, g_nancy->_true);
+ }
+ }
+
+ if (bootSummary->endOfDayFlag == kEvNoEvent) {
+ return;
+ }
+
+ if (!getEventFlag(bootSummary->endOfDayFlag, g_nancy->_true) && _timers.playerTime.getTotalHours() >= bootSummary->endOfDayHour) {
+ setEventFlag(bootSummary->endOfDayFlag, g_nancy->_true);
+ } else if (_timers.sleepRequested) {
+ _timers.sleepRequested = false;
+ _timers.playerTime = bootSummary->wakeUpHour * 3600000;
+ setPlayerDay(_timers.playerDay + 1);
+ setEventFlag(bootSummary->endOfDayFlag, g_nancy->_false);
+ }
+}
+
+void Scene::setPlayerDay(int16 day) {
+ auto *bootSummary = GetEngineData(BSUM);
+ assert(bootSummary);
+
+ _timers.playerDay = day;
+
+ TableData *table = (TableData *)getPuzzleData(TableData::getTag());
+ assert(table);
+ table->setValue(bootSummary->dayValueIndex, day);
+}
+
void Scene::tickSoftwareTimers(uint32 deltaMs) {
if (g_nancy->getGameType() < kGameTypeNancy11 || deltaMs == 0) {
return;
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 31b157ec369..a728dd5b3d6 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -139,7 +139,12 @@ public:
void setPlayerTime(Time time, byte relative);
Time getPlayerTime() const { return _timers.playerTime; }
Time getTimerTime() const { return _timers.timerIsActive ? _timers.timerTime : 0; }
+ uint getPlayerTimeMinutes() const;
byte getPlayerTOD() const;
+ // Nancy14-15. The day is kept separately from the clock, and copied into the
+ // day value so the scripts can read it.
+ void requestSleep() { _timers.sleepRequested = true; }
+ void setPlayerDay(int16 day);
void addItemToInventory(int16 id);
void removeItemFromInventory(int16 id, bool pickUp = true);
@@ -285,6 +290,8 @@ public:
bool timerIsActive = false;
Time playerTime; // In-game time of day, adds a minute every 5 seconds
Time playerTimeNextMinute; // Stores the next tick count until we add a minute to playerTime
+ bool sleepRequested = false; // Nancy14-15: start the next day on the following frame
+ int16 playerDay = 0; // Nancy14-15: the current day, also copied into the day value
};
Timers _timers;
@@ -300,6 +307,11 @@ private:
// Nancy 11+ AR 69. Advances all running software timers (stored as TimerData
// puzzle data) and fires any whose configured duration has just elapsed.
void tickSoftwareTimers(uint32 deltaMs);
+
+ // Raises the late night flag (Nancy11-13) or the end-of-day flag (Nancy14-15)
+ // once it gets late. In Nancy14-15, also starts the next day at the wake-up
+ // hour after the player has been sent to sleep.
+ void updateEndOfDay();
void fireSoftwareTimer(TimerData::Timer &timer);
void fireTimerTrigger(TimerData::Trigger &trigger);
Commit: 1634a263dddda3666488cd2ad1b16bd1d0705ba4
https://github.com/scummvm/scummvm/commit/1634a263dddda3666488cd2ad1b16bd1d0705ba4
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:35+03:00
Commit Message:
NANCY: Update handling of dependencies and time in the debug console
Changed paths:
engines/nancy/console.cpp
diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index e44192a1df1..9bed7b359b8 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -540,7 +540,7 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
break;
case DependencyType::kElapsedPlayerTime:
debugPrintf("kPlayerTime, player time %s %i hours, %i minutes, %i seconds, %i milliseconds",
- dep.condition == 0 ? "greater than" : (dep.condition == 1 ? "less than" : "equals"),
+ dep.condition == 0 ? "at or after" : (dep.condition == 1 ? "at or before" : (dep.condition == 2 ? "equals" : "between")),
dep.hours,
dep.minutes,
dep.seconds,
@@ -594,6 +594,15 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
break;
case DependencyType::kRandom:
debugPrintf("kRandom, chance %i", dep.condition);
+ break;
+ case DependencyType::kDefaultAR:
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ debugPrintf("kDefaultAR, no record of this type (or types %i, %i, %i, %i) executed in this scene",
+ dep.hours, dep.minutes, dep.seconds, dep.milliseconds);
+ } else {
+ debugPrintf("kDefaultAR, previous record did not execute");
+ }
+
break;
default:
debugPrintf("unknown type %u", (uint)dep.type);
@@ -1014,6 +1023,13 @@ bool NancyConsole::Cmd_getPlayerTime(int argc, const char **argv) {
time.getHours(),
time.getMinutes(),
(uint32)time);
+
+ auto *bootSummary = GetEngineData(BSUM);
+ if (bootSummary && bootSummary->endOfDayFlag != kEvNoEvent) {
+ // Games with an end of day keep the day separately from the clock
+ debugPrintf("Day: %d\n", NancySceneState._timers.playerDay);
+ }
+
return true;
}
@@ -1030,9 +1046,15 @@ bool NancyConsole::Cmd_setPlayerTime(int argc, const char **argv) {
}
Time &time = NancySceneState._timers.playerTime;
+ auto *bootSummary = GetEngineData(BSUM);
if (argc == 2) {
time = atoi(argv[1]);
+ } else if (bootSummary && bootSummary->endOfDayFlag != kEvNoEvent) {
+ // Games with an end of day keep the day separately from the clock
+ NancySceneState.setPlayerDay(atoi(argv[1]));
+ time = atoi(argv[2]) * 3600000 + // hours
+ atoi(argv[3]) * 60000; // minutes
} else {
time = atoi(argv[1]) * 86400000 + // days
atoi(argv[2]) * 3600000 + // hours
Commit: b43c76f39a619877ee3295d51aa5b5ebb48b4fa9
https://github.com/scummvm/scummvm/commit/b43c76f39a619877ee3295d51aa5b5ebb48b4fa9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:37+03:00
Commit Message:
NANCY: Add further extra info to debug output
Changed paths:
engines/nancy/action/datarecords.cpp
engines/nancy/action/datarecords.h
engines/nancy/action/soundrecords.cpp
engines/nancy/action/soundrecords.h
diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index 2b74f2f073b..89350e50c82 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -339,6 +339,21 @@ void ValueTest::execute() {
finishExecution();
}
+Common::String EventFlags::getRecordExtraInfo() const {
+ Common::String info;
+ for (uint i = 0; i < ARRAYSIZE(_flags.descs); ++i) {
+ const FlagDescription &desc = _flags.descs[i];
+ if (desc.label == kFlagNoLabel) {
+ continue;
+ }
+
+ info += Common::String::format("%sflag %d, %s -> %s", info.empty() ? "" : "; ", desc.label,
+ g_nancy->getEventFlagName(desc.label).c_str(), desc.flag == g_nancy->_true ? "true" : "false");
+ }
+
+ return info;
+}
+
void EventFlags::readData(Common::SeekableReadStream &stream) {
if (_flagsType == kEventFlags) {
if (g_nancy->getGameType() >= kGameTypeNancy15) {
diff --git a/engines/nancy/action/datarecords.h b/engines/nancy/action/datarecords.h
index 0e17e8d1af6..944ef8777fd 100644
--- a/engines/nancy/action/datarecords.h
+++ b/engines/nancy/action/datarecords.h
@@ -122,6 +122,8 @@ public:
MultiEventFlagDescription _flags;
FlagsType _flagsType;
+ Common::String getRecordExtraInfo() const override;
+
protected:
Common::String getRecordTypeName() const override { return _flagsType == kEventFlagsTerse ? "EventFlagsTerse" : "EventFlags"; }
};
diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index f637369226f..b4b8a16e3e0 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -319,6 +319,28 @@ void PlaySound::applyAfterSoundAction() {
}
}
+Common::String PlaySound::getRecordExtraInfo() const {
+ Common::String info = Common::String::format("Sound %s, channel %u, loops %u, volume %u, scene %d%s",
+ _sound.name.c_str(), _sound.channelID, _sound.numLoops, _sound.volume, _sceneChange.sceneID,
+ _changeSceneImmediately ? " (without waiting)" : "");
+
+ Common::Array<FlagDescription> flags = _flags;
+ if (flags.empty()) {
+ flags.push_back(_flag);
+ }
+
+ for (uint i = 0; i < flags.size(); ++i) {
+ if (flags[i].label == kFlagNoLabel) {
+ continue;
+ }
+
+ info += Common::String::format("; flag %d, %s -> %s", flags[i].label,
+ g_nancy->getEventFlagName(flags[i].label).c_str(), flags[i].flag == g_nancy->_true ? "true" : "false");
+ }
+
+ return info;
+}
+
Common::String PlaySound::getRecordTypeName() const {
if (g_nancy->getGameType() <= kGameTypeNancy2) {
return "PlayDigiSoundAndDie";
diff --git a/engines/nancy/action/soundrecords.h b/engines/nancy/action/soundrecords.h
index 513dd4f67c3..4898f072ff1 100644
--- a/engines/nancy/action/soundrecords.h
+++ b/engines/nancy/action/soundrecords.h
@@ -42,6 +42,8 @@ public:
uint16 channel = 0;
byte volume = 0;
+ Common::String getRecordExtraInfo() const override { return Common::String::format("Channel %u, volume %u", channel, volume); }
+
protected:
Common::String getRecordTypeName() const override { return "SetVolume"; }
};
@@ -139,7 +141,7 @@ public:
// it explicitly in the closed-caption records below.
Common::String _ccText;
- Common::String getRecordExtraInfo() const override { return Common::String::format("Scene %d", _sceneChange.sceneID); }
+ Common::String getRecordExtraInfo() const override;
protected:
Common::String getRecordTypeName() const override;
Commit: af36a934aa0691d94d4ee480416b96b538539117
https://github.com/scummvm/scummvm/commit/af36a934aa0691d94d4ee480416b96b538539117
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:38+03:00
Commit Message:
NANCY: NANCY13+: Keep sounds playing on sound-triggered scene changes
Changed paths:
engines/nancy/action/soundrecords.cpp
diff --git a/engines/nancy/action/soundrecords.cpp b/engines/nancy/action/soundrecords.cpp
index b4b8a16e3e0..4b6b7886ce6 100644
--- a/engines/nancy/action/soundrecords.cpp
+++ b/engines/nancy/action/soundrecords.cpp
@@ -239,6 +239,7 @@ void PlaySound::readDataNancy13(Common::SeekableReadStream &stream) {
// scene ID (frame/vertical offset stay 0).
_changeSceneImmediately = stream.readByte();
_sceneChange.sceneID = stream.readUint16LE();
+ _sceneChange.continueSceneSound = kContinueSceneSound; // sounds keep playing into the new scene
_afterSoundAction = stream.readByte(); // overlay-refresh control; unused
// The single event flag became a list of { label, value } pairs.
@@ -702,6 +703,7 @@ void ConcatMultiSound::execute() {
if (_exitSceneID != kNoScene) {
SceneChangeDescription desc;
desc.sceneID = _exitSceneID;
+ desc.continueSceneSound = kContinueSceneSound;
NancySceneState.changeScene(desc);
}
Commit: b52790e7beed28d406d0a875a3f3119ec60db33e
https://github.com/scummvm/scummvm/commit/b52790e7beed28d406d0a875a3f3119ec60db33e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:39+03:00
Commit Message:
NANCY: NANCY14: Fixes for secondary movies
- Keep finished secondary movies in place when rotating
- Fix the loop count, frame range and looping functionality
Now, looping scene animations, like the ones in Heather's office (scene
3820) display correctly. Also, looping animations like the flashing led
in Nancy's phone (while a call is on hald) are not stuck in the
viewport while rotating.
Changed paths:
engines/nancy/action/secondarymovie.cpp
engines/nancy/action/secondarymovie.h
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 7dd98d84934..78b243d1302 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -698,14 +698,15 @@ void PlaySecondaryMovie::readDataNancy13(Common::Serializer &ser, Common::Seekab
}
// Nancy14 compacted the non-random layout: the videoSceneChange 5/6 flag is
-// gone (a scene change is now requested via the sceneID sentinel), playDirection
-// moved after lastFrame, and a "hide on finish" flag was added. AR 44 matches
-// AR 41 plus a trailing movie-volume byte.
+// gone (a scene change is now requested via the sceneID sentinel), a loop count
+// and a "hide on finish" flag were added, and the play direction follows from
+// the frame range, as in Nancy13. 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.
+// the hide-on-finish flag, and the loop count 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;
@@ -724,20 +725,25 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
ser.syncAsUint16LE(_playStyle);
}
- ser.syncAsUint16LE(_firstFrame);
+ // 0 loops forever
+ ser.syncAsUint16LE(_numLoops);
- 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).
+ if (isNancy15 && (int16)_numLoops == -1) {
+ // LOOP_RANDOM: a min/max loop count follows; 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 ?
+ _numLoops = maxLoops > minLoops ?
(uint16)(minLoops + g_nancy->_randomSource->getRandomNumber(maxLoops - minLoops - 1)) : minLoops;
}
+ ser.syncAsUint16LE(_firstFrame);
ser.syncAsUint16LE(_lastFrame);
- ser.syncAsUint16LE(_playDirection);
+
+ _playDirection = orderSentinelFrame(_lastFrame) < orderSentinelFrame(_firstFrame) ?
+ kPlayMovieReverse : kPlayMovieForward;
+
ser.syncAsSint16LE(_sceneChange.sceneID);
ser.syncAsUint16LE(_sceneChange.frameID);
@@ -989,6 +995,94 @@ void PlaySecondaryMovie::updateMask(int viewportFrame) {
_mask.setVisible(false);
}
+void PlaySecondaryMovie::updateGraphics() {
+ // A movie that played to its end keeps its last frame on screen after the
+ // record is done, and one whose dependencies stopped holding isn't executed
+ // anymore. Neither follows the viewport through execute(), so keep them
+ // attached to their background frame here. Records that never got to show a
+ // frame, or random movies that were stopped, have nothing to keep on screen.
+ if (_fullFrame.empty() || _randomStopRequested) {
+ return;
+ }
+
+ if (_isDone || !_isActive) {
+ updateViewportFrame();
+ }
+}
+
+void PlaySecondaryMovie::updateViewportFrame() {
+ int newFrame = NancySceneState.getSceneInfo().frameID;
+ if (newFrame == _curViewportFrame) {
+ return;
+ }
+
+ _curViewportFrame = newFrame;
+ int activeFrame = -1;
+ for (uint i = 0; i < _videoDescs.size(); ++i) {
+ if (newFrame == _videoDescs[i].frameID) {
+ activeFrame = i;
+ break;
+ }
+ }
+
+ if (activeFrame != -1) {
+ if (_fullFrame.empty()) {
+ moveTo(_videoDescs[activeFrame].destRect);
+ } else {
+ // Crop the last decoded frame for the new background frame, since a
+ // finished or paused movie won't decode another one.
+ applyVideoDesc(activeFrame);
+ }
+
+ setVisible(true);
+
+ // Nancy13 talkable characters: the character's on-screen box
+ // doubles as a clickable hotspot that opens its conversation.
+ if (_talkSceneID != kNoScene) {
+ _hotspot = _screenPosition;
+ _hasHotspot = true;
+ }
+ } else if (isRandom() && _videoDescs.empty()) {
+ // A random movie with no descriptors isn't tied to a specific
+ // background frame: play it across the full viewport.
+ moveTo(NancySceneState.getViewport().getBounds());
+ setVisible(true);
+ _hasHotspot = false;
+ } else {
+ setVisible(false);
+ _hasHotspot = false;
+ }
+
+ updateMask(newFrame);
+}
+
+void PlaySecondaryMovie::applyVideoDesc(int descID) {
+ // Nancy14 stores an all -1 srcRect to mean "use the whole frame".
+ Common::Rect srcRect = descID != -1 ? _videoDescs[descID].srcRect : Common::Rect();
+ if (srcRect.isEmpty()) {
+ srcRect = Common::Rect(_fullFrame.w, _fullFrame.h);
+ }
+
+ Common::Rect destRect = descID != -1 ? _videoDescs[descID].destRect : _screenPosition;
+
+ // The videoDesc's size might be larger than the decoded video (for example, nancy10's
+ // COR_AceFidgetEars_ANIM, and nancy12's PAR_ArcadeAnimationB); clamp here to avoid
+ // reading out-of-bounds during draw. (Adjust destRect too: avoid stretching)
+ const int16 decodedWidth = (int16)_decoder.getWidth();
+ if (srcRect.width() > decodedWidth) {
+ srcRect.setWidth(decodedWidth);
+ destRect.setWidth(decodedWidth);
+ }
+ const int16 decodedHeight = (int16)_decoder.getHeight();
+ if (srcRect.height() > decodedHeight) {
+ srcRect.setHeight(decodedHeight);
+ destRect.setHeight(decodedHeight);
+ }
+
+ _drawSurface.create(_fullFrame, srcRect);
+ moveTo(destRect);
+}
+
void PlaySecondaryMovie::onPause(bool pause) {
_decoder.pauseVideo(pause);
RenderActionRecord::onPause(pause);
@@ -1079,41 +1173,7 @@ void PlaySecondaryMovie::execute() {
}
}
- int newFrame = NancySceneState.getSceneInfo().frameID;
-
- if (newFrame != _curViewportFrame) {
- _curViewportFrame = newFrame;
- int activeFrame = -1;
- for (uint i = 0; i < _videoDescs.size(); ++i) {
- if (newFrame == _videoDescs[i].frameID) {
- activeFrame = i;
- break;
- }
- }
-
- if (activeFrame != -1) {
- _screenPosition = _videoDescs[activeFrame].destRect;
- setVisible(true);
-
- // Nancy13 talkable characters: the character's on-screen box
- // doubles as a clickable hotspot that opens its conversation.
- if (_talkSceneID != kNoScene) {
- _hotspot = _screenPosition;
- _hasHotspot = true;
- }
- } else if (isRandom() && _videoDescs.empty()) {
- // A random movie with no descriptors isn't tied to a specific
- // background frame: play it across the full viewport.
- _screenPosition = NancySceneState.getViewport().getBounds();
- setVisible(true);
- _hasHotspot = false;
- } else {
- setVisible(false);
- _hasHotspot = false;
- }
-
- updateMask(newFrame);
- }
+ updateViewportFrame();
// We update the decoder here instead of in updateGraphics() to avoid an
// edge case in nancy4 (scene 3180) where the very last frame has a frameFlag that should trigger
@@ -1145,31 +1205,7 @@ void PlaySecondaryMovie::execute() {
}
GraphicsManager::copyToManaged(*decodedFrame, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
-
- // Nancy14 stores an all -1 srcRect to mean "use the whole frame".
- Common::Rect srcRect = descID != -1 ? _videoDescs[descID].srcRect : Common::Rect();
- if (srcRect.isEmpty()) {
- srcRect = Common::Rect(_fullFrame.w, _fullFrame.h);
- }
-
- Common::Rect destRect = descID != -1 ? _videoDescs[descID].destRect : _screenPosition;
-
- // The videoDesc's size might be larger than the decoded video (for example, nancy10's
- // COR_AceFidgetEars_ANIM, and nancy12's PAR_ArcadeAnimationB); clamp here to avoid
- // reading out-of-bounds during draw. (Adjust destRect too: avoid stretching)
- const int16 decodedWidth = (int16)_decoder.getWidth();
- if (srcRect.width() > decodedWidth) {
- srcRect.setWidth(decodedWidth);
- destRect.setWidth(decodedWidth);
- }
- const int16 decodedHeight = (int16)_decoder.getHeight();
- if (srcRect.height() > decodedHeight) {
- srcRect.setHeight(decodedHeight);
- destRect.setHeight(decodedHeight);
- }
-
- _drawSurface.create(_fullFrame, srcRect);
- moveTo(destRect);
+ applyVideoDesc(descID);
_needsRedraw = true;
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index f78dff85ad2..ce1a125f066 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -112,6 +112,7 @@ public:
void init() override;
void onPause(bool pause) override;
void registerGraphics() override;
+ void updateGraphics() override;
void readData(Common::SeekableReadStream &stream) override;
void execute() override;
@@ -354,6 +355,14 @@ protected:
// or hide it when the record doesn't describe one for that frame.
void updateMask(int viewportFrame);
+ // Place and show the movie for the current background frame, or hide it
+ // when the record doesn't describe one for that frame.
+ void updateViewportFrame();
+
+ // Crop the last decoded frame with the given video description and move
+ // the movie to its destination. -1 uses the whole frame at its current spot.
+ void applyVideoDesc(int descID);
+
Graphics::ManagedSurface _fullFrame;
Graphics::ManagedSurface _maskImage;
ForegroundMask _mask;
Commit: 278b44e872c188adfc548ca9287d9dbadbf6c7e6
https://github.com/scummvm/scummvm/commit/278b44e872c188adfc548ca9287d9dbadbf6c7e6
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:40+03:00
Commit Message:
NANCY: NANCY14: Fixes for cake baking puzzle (BuildPuzzle)
- Save the puzzle state
- Set the correct item IDs of each piece. This fixes the puzzle getitng
stuck when all ingredients are placed in the bowl
Changed paths:
engines/nancy/action/puzzle/buildpuzzle.cpp
engines/nancy/action/puzzle/buildpuzzle.h
engines/nancy/puzzledata.cpp
engines/nancy/puzzledata.h
diff --git a/engines/nancy/action/puzzle/buildpuzzle.cpp b/engines/nancy/action/puzzle/buildpuzzle.cpp
index 74a4ec13d18..7ebb24af643 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.cpp
+++ b/engines/nancy/action/puzzle/buildpuzzle.cpp
@@ -52,7 +52,8 @@ void BuildPuzzle::readData(Common::SeekableReadStream &stream) {
readFilename(stream, _altImageName);
_trayImageMode = stream.readByte();
- stream.skip(3); // 0x46: the difficulty gate's flag
+ _saveState = stream.readByte();
+ _resumeFlag = stream.readSint16LE();
_requiredPlaced = stream.readUint16LE();
_usePlacedGate = stream.readByte();
_stateItemID = stream.readUint16LE();
@@ -201,6 +202,25 @@ void BuildPuzzle::setPieceCursor(bool isHeld) {
}
void BuildPuzzle::init() {
+ BuildPuzzleData *data = (BuildPuzzleData *)NancySceneState.getPuzzleData(BuildPuzzleData::getTag());
+ assert(data);
+
+ uint16 sceneID = NancySceneState.getSceneInfo().sceneID;
+ // Several scenes share this puzzle and the one saved board, so the board is
+ // only picked up by the scene it was saved for. Any other scene starts over
+ // and drops the board, so a later resume can't load another puzzle's pieces.
+ bool resume = data->sceneID == sceneID && _resumeFlag != -1 &&
+ NancySceneState.getEventFlag(_resumeFlag, g_nancy->_true);
+
+ if (!resume) {
+ data->sceneID = sceneID;
+ data->placedCount = 0;
+ data->solved = false;
+ data->wrongIngredient = false;
+ data->pieces.clear();
+ data->zones.clear();
+ }
+
const uint32 transColor = g_nancy->_graphics->getTransColor();
g_nancy->_resource->loadImage(_imageName, _image);
@@ -266,6 +286,19 @@ void BuildPuzzle::init() {
_buttonPress.setTransparent(true);
_buttonPress.setVisible(false);
+ if (resume) {
+ restoreState(*data);
+ } else {
+ // Starting over clears everything the puzzle wrote.
+ for (uint i = 0; i < _numDefined; ++i) {
+ setItemValue(_pieces[i].itemID, 0);
+ }
+
+ setPlacedCount(0);
+ setFlagOnChange(_solvedFlag, false, _lastSolvedFlag);
+ setFlagOnChange(_wrongIngredientFlag, false, _lastWrongFlag);
+ }
+
_isInitialized = true;
}
@@ -382,6 +415,7 @@ int16 BuildPuzzle::clonePiece(int16 pieceIdx) {
clone.placedDestRect = original.placedDestRect;
clone.kind = original.kind;
clone.zoneID = original.zoneID;
+ clone.itemID = original.itemID;
clone.holds = original.holds;
clone.fillVariant = original.fillVariant;
clone.liveRect = original.liveRect;
@@ -503,6 +537,7 @@ void BuildPuzzle::pickUpPiece(int16 pieceIdx) {
// exists while it is in a zone, so it goes away rather than onto the cursor.
if (piece.assignedZone != -1) {
adjustZone(piece.assignedZone, piece.sourceID, -1);
+ addItemValue(piece.itemID, -1);
setPlacedCount(_placedCount - 1);
piece.assignedZone = -1;
@@ -579,6 +614,7 @@ void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point
}
adjustZone(zoneIdx, piece.sourceID, (int8)carriedAmount());
+ addItemValue(piece.itemID, carriedAmount());
g_nancy->_sound->loadSound(_dropSound);
g_nancy->_sound->playSound(_dropSound);
@@ -592,6 +628,10 @@ void BuildPuzzle::placePiece(int16 pieceIdx, int16 zoneIdx, const Common::Point
bool solved = checkSolved();
setFlagOnChange(_solvedFlag, solved, _lastSolvedFlag);
+ if (_saveState) {
+ saveState();
+ }
+
// Without the gate the puzzle waits to be handed in, so a wrong mix can be
// thrown away first.
if (!_usePlacedGate) {
@@ -618,6 +658,36 @@ void BuildPuzzle::setPlacedCount(int16 count) {
}
}
+void BuildPuzzle::setItemValue(int16 itemID, int16 value) {
+ if (itemID < 0 || itemID == 255) {
+ return;
+ }
+
+ TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ if (table) {
+ table->setValue(itemID, value);
+ }
+}
+
+void BuildPuzzle::addItemValue(int16 itemID, int16 delta) {
+ if (itemID < 0 || itemID == 255) {
+ return;
+ }
+
+ TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ if (!table) {
+ return;
+ }
+
+ // An unset value counts as zero.
+ int16 value = table->getValue(itemID);
+ if (value == kNoTableValue) {
+ value = 0;
+ }
+
+ table->setValue(itemID, value + delta);
+}
+
void BuildPuzzle::pressButton(HeldButton button) {
const Common::Rect &src = button == kSubmitButton ? _submitSrcRect : _startOverSrcRect;
const Common::Rect &dest = button == kSubmitButton ? _submitHotspot : _startOverHotspot;
@@ -677,6 +747,7 @@ void BuildPuzzle::resetPuzzle() {
}
piece.liveRect = piece.destRect;
+ setItemValue(piece.itemID, 0);
updatePieceRender((int16)i);
}
@@ -689,6 +760,80 @@ void BuildPuzzle::resetPuzzle() {
setFlagOnChange(_wrongIngredientFlag, false, _lastWrongFlag);
}
+void BuildPuzzle::saveState() {
+ BuildPuzzleData *data = (BuildPuzzleData *)NancySceneState.getPuzzleData(BuildPuzzleData::getTag());
+ assert(data);
+
+ data->placedCount = _placedCount;
+ data->solved = _solvedFlag != -1 && NancySceneState.getEventFlag(_solvedFlag, g_nancy->_true);
+ data->wrongIngredient = _wrongIngredientFlag != -1 &&
+ NancySceneState.getEventFlag(_wrongIngredientFlag, g_nancy->_true);
+
+ data->pieces.clear();
+ for (uint i = 0; i < _pieces.size(); ++i) {
+ const Piece &piece = _pieces[i];
+ if (!piece.inUse) {
+ continue;
+ }
+
+ data->pieces.push_back(piece.sourceID);
+ data->pieces.push_back(piece.assignedZone);
+ data->pieces.push_back(piece.liveRect.left);
+ data->pieces.push_back(piece.liveRect.top);
+ data->pieces.push_back(piece.liveRect.right);
+ data->pieces.push_back(piece.liveRect.bottom);
+ }
+
+ data->zones.clear();
+ for (uint i = 0; i < _zones.size(); ++i) {
+ const Zone &zone = _zones[i];
+ data->zones.push_back(zone.numWrong);
+ for (uint j = 0; j < zone.counts.size(); ++j) {
+ data->zones.push_back(zone.counts[j]);
+ }
+ }
+}
+
+void BuildPuzzle::restoreState(const BuildPuzzleData &data) {
+ for (uint i = 0; i + 5 < data.pieces.size(); i += 6) {
+ int16 sourceID = data.pieces[i];
+ if (sourceID < 0 || sourceID >= (int16)_numDefined) {
+ continue;
+ }
+
+ // The pieces from the record come first, in order; anything after them
+ // is a copy of one of them.
+ int16 pieceIdx = (int16)(i / 6);
+ if (pieceIdx >= (int16)_numDefined) {
+ pieceIdx = clonePiece(sourceID);
+ if (pieceIdx == -1) {
+ break;
+ }
+ }
+
+ Piece &piece = _pieces[pieceIdx];
+ piece.assignedZone = data.pieces[i + 1];
+ piece.liveRect = Common::Rect(data.pieces[i + 2], data.pieces[i + 3], data.pieces[i + 4], data.pieces[i + 5]);
+ updatePieceRender(pieceIdx);
+ }
+
+ uint pos = 0;
+ for (uint i = 0; i < _zones.size() && pos < data.zones.size(); ++i) {
+ Zone &zone = _zones[i];
+ zone.numWrong = data.zones[pos++];
+ zone.numHeld = zone.numWrong;
+
+ for (uint j = 0; j < zone.counts.size() && pos < data.zones.size(); ++j) {
+ zone.counts[j] = (byte)data.zones[pos++];
+ zone.numHeld += zone.counts[j];
+ }
+ }
+
+ _placedCount = data.placedCount;
+ setFlagOnChange(_solvedFlag, data.solved, _lastSolvedFlag);
+ setFlagOnChange(_wrongIngredientFlag, data.wrongIngredient, _lastWrongFlag);
+}
+
void BuildPuzzle::execute() {
switch (_state) {
case kBegin:
diff --git a/engines/nancy/action/puzzle/buildpuzzle.h b/engines/nancy/action/puzzle/buildpuzzle.h
index 45ffa8686ea..f61893489ad 100644
--- a/engines/nancy/action/puzzle/buildpuzzle.h
+++ b/engines/nancy/action/puzzle/buildpuzzle.h
@@ -26,6 +26,9 @@
#include "engines/nancy/renderobject.h"
namespace Nancy {
+
+struct BuildPuzzleData;
+
namespace Action {
// Nancy 14 reuses AR 166 for a rebuilt assembly puzzle (tea, cookies, parfait,
@@ -166,6 +169,11 @@ protected:
Common::Rect _startOverHotspot;
SoundDescription _startOverSound;
+ // With _saveState set, the board is saved after every drop. It is restored when
+ // the puzzle's scene runs again while _resumeFlag is set.
+ byte _saveState = 0;
+ int16 _resumeFlag = -1;
+
// Both cleared when the puzzle starts from scratch.
int16 _wrongIngredientFlag = -1; // set once something not in a recipe is dropped in
int16 _solvedFlag = -1;
@@ -237,12 +245,19 @@ protected:
bool checkSolved() const;
// Also updates the shared item state that mirrors the count
void setPlacedCount(int16 count);
+ // A piece's shared item state holds how much of it went into the zones,
+ // which the scene's own value tests read once the puzzle is handed in.
+ void setItemValue(int16 itemID, int16 value);
+ void addItemValue(int16 itemID, int16 delta);
// Show a button pressed; it acts once its art has been up for a moment
void pressButton(HeldButton button);
// Hand the puzzle in: the solve scene, or the fail scene when a zone is short
void takeOutcome();
// Empty every zone and put all the pieces back
void resetPuzzle();
+ // Save the board, and put it back when the puzzle resumes
+ void saveState();
+ void restoreState(const BuildPuzzleData &data);
// The tea puzzle has four: backing away, plus the teapot, the recipe book
// and the sink.
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index d0aead22f08..d1a03ee28ae 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -714,8 +714,19 @@ void DrivingData::synchronize(Common::Serializer &ser) {
ser.syncAsByte(infiniteFuel, 8);
}
+void BuildPuzzleData::synchronize(Common::Serializer &ser) {
+ ser.syncAsUint16LE(sceneID);
+ ser.syncAsSint16LE(placedCount);
+ ser.syncAsByte(solved);
+ ser.syncAsByte(wrongIngredient);
+ syncInt16Array(ser, pieces);
+ syncInt16Array(ser, zones);
+}
+
PuzzleData *makePuzzleData(const uint32 tag) {
switch(tag) {
+ case BuildPuzzleData::getTag():
+ return new BuildPuzzleData();
case DrivingData::getTag():
return new DrivingData();
case WordFindPuzzleData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index dbcfe691b2e..d711dfe5e85 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -494,6 +494,24 @@ struct DrivingData : public PuzzleData {
bool infiniteFuel = false; // cheat toggle, kept across building visits
};
+// Nancy14 BuildPuzzle (AR 166). The board as it was after the last drop. A puzzle
+// scene that re-runs picks it back up, as long as it is still the last build
+// puzzle entered and its resume flag is set; otherwise the puzzle starts over.
+struct BuildPuzzleData : public PuzzleData {
+ BuildPuzzleData() {}
+ virtual ~BuildPuzzleData() {}
+
+ static constexpr uint32 getTag() { return MKTAG('B', 'L', 'D', 'P'); }
+ virtual void synchronize(Common::Serializer &ser);
+
+ uint16 sceneID = kNoScene;
+ int16 placedCount = 0;
+ bool solved = false;
+ bool wrongIngredient = false;
+ Common::Array<int16> pieces; // 6 per piece: sourceID, assignedZone, left, top, right, bottom
+ Common::Array<int16> zones; // per zone: numWrong, then one count per ingredient
+};
+
PuzzleData *makePuzzleData(const uint32 tag);
} // End of namespace Nancy
Commit: 1e2fc7008b6f09ac425a7de75e906b1458ddd46a
https://github.com/scummvm/scummvm/commit/1e2fc7008b6f09ac425a7de75e906b1458ddd46a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:41+03:00
Commit Message:
NANCY: NANCY14: Implement digit images in TextLineOverlay
Used in the day change scene
Changed paths:
engines/nancy/action/overlay.cpp
engines/nancy/action/overlay.h
diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index ce9d836cbbc..b8f4f8862c6 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -518,48 +518,103 @@ void TableIndexOverlay::execute() {
}
}
+void TextLineOverlay::init() {
+ if (!_digitImageName.empty()) {
+ g_nancy->_resource->loadImage(_digitImageName, _digitImage);
+ }
+
+ RenderObject::init();
+}
+
void TextLineOverlay::readData(Common::SeekableReadStream &stream) {
_fontID = stream.readUint16LE();
_textColor = stream.readUint16LE();
- _position.x = stream.readSint16LE();
- stream.skip(2);
- _position.y = stream.readSint16LE();
- stream.skip(2);
+ _position.x = stream.readSint32LE();
+ _position.y = stream.readSint32LE();
readFilename(stream, _textKey);
_tableIndex = stream.readSint16LE();
+
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ _numDigits = stream.readSint16LE();
+
+ Common::String imageName;
+ readFilename(stream, imageName);
+ if (!imageName.empty() && imageName != "NO_FILE") {
+ _digitImageName = Common::Path(imageName);
+ _digitSpacing = stream.readUint16LE();
+ for (uint i = 0; i < 10; ++i) {
+ readRect(stream, _digitSrcRects[i]);
+ }
+ }
+ }
}
void TextLineOverlay::execute() {
- if (_isDone) {
- return;
+ switch (_state) {
+ case kBegin:
+ init();
+ _state = kRun;
+ // fall through
+ case kRun: {
+ // The table value can change while the scene is shown, so the text is
+ // re-evaluated every frame and only redrawn when it differs
+ Common::String text = getText();
+ if (text != _displayedText) {
+ _displayedText = text;
+ if (_digitImageName.empty()) {
+ drawText(text);
+ } else {
+ drawDigitImages(text);
+ }
+ }
+ break;
}
-
- const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
- if (!font) {
- return;
+ default:
+ break;
}
+}
- Common::String text;
+Common::String TextLineOverlay::getText() const {
if (!_textKey.empty()) {
- text = _textKey;
- } else {
+ return _textKey;
+ }
+
+ int value = 0;
+ if (_tableIndex != kZeroTableIndex) {
TableData *playerTable = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
assert(playerTable);
- int16 value = playerTable->getValue(_tableIndex);
+ value = playerTable->getValue(_tableIndex);
+ }
+
+ // An unset value is displayed as zero
+ if (value == kNoTableValue) {
+ value = 0;
+ }
- // An unset value is displayed as zero
- if (value == kNoTableValue) {
- value = 0;
+ // Nancy14 keeps only the lowest _numDigits digits of the value
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ int modulus = 1;
+ for (int i = 0; i < _numDigits; ++i) {
+ modulus *= 10;
}
- text = Common::String::format("%d", value);
+ value = (int16)(value % modulus);
+ }
+
+ return Common::String::format("%d", value);
+}
+
+void TextLineOverlay::drawText(const Common::String &text) {
+ const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
+ if (!font) {
+ return;
}
uint width = font->getStringWidth(text);
uint height = font->getFontHeight();
if (!width || !height) {
- _isDone = true;
+ setVisible(false);
return;
}
@@ -573,8 +628,44 @@ void TextLineOverlay::execute() {
setTransparent(true);
setVisible(true);
registerGraphics();
+}
+
+void TextLineOverlay::drawDigitImages(const Common::String &text) {
+ // Each digit is drawn with its bottom row on the stored y; the next digit
+ // starts at the previous digit's last column plus the spacing
+ Common::Array<const Common::Rect *> srcRects;
+ Common::Array<int16> offsets;
+ int16 x = 0;
+ int16 width = 0;
+ int16 height = 0;
+ for (uint i = 0; i < text.size(); ++i) {
+ if (text[i] < '0' || text[i] > '9') {
+ continue;
+ }
+
+ const Common::Rect &src = _digitSrcRects[text[i] - '0'];
+ srcRects.push_back(&src);
+ offsets.push_back(x);
+ width = x + src.width();
+ height = MAX<int16>(height, src.height());
+ x += src.width() - 1 + _digitSpacing;
+ }
+
+ if (srcRects.empty() || !width || !height) {
+ setVisible(false);
+ return;
+ }
- _isDone = true;
+ _drawSurface.create(width, height, g_nancy->_graphics->getInputPixelFormat());
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+ for (uint i = 0; i < srcRects.size(); ++i) {
+ _drawSurface.blitFrom(_digitImage, *srcRects[i], Common::Point(offsets[i], height - srcRects[i]->height()));
+ }
+
+ moveTo(Common::Rect(_position.x, _position.y - height + 1, _position.x + width, _position.y + 1));
+ setTransparent(true);
+ setVisible(true);
+ registerGraphics();
}
void RolloverOverlay::init() {
diff --git a/engines/nancy/action/overlay.h b/engines/nancy/action/overlay.h
index 13590e9ac85..04f7a926402 100644
--- a/engines/nancy/action/overlay.h
+++ b/engines/nancy/action/overlay.h
@@ -153,12 +153,15 @@ protected:
// Draws a single line of text on top of the scene background. The text is a
// value looked up from the player-data table (used by the nancy12 minigolf
-// scorecard, where each hole's score is a separate record).
+// scorecard, where each hole's score is a separate record). Nancy14 added a
+// digit count that the value is truncated to, and an optional image holding
+// the glyphs for digits 0-9, which replaces the font when present.
class TextLineOverlay : public RenderActionRecord {
public:
TextLineOverlay() : RenderActionRecord(8) {}
virtual ~TextLineOverlay() {}
+ void init() override;
void readData(Common::SeekableReadStream &stream) override;
void execute() override;
@@ -167,11 +170,26 @@ public:
protected:
Common::String getRecordTypeName() const override { return "TextLineOverlay"; }
+ Common::String getText() const;
+ void drawText(const Common::String &text);
+ void drawDigitImages(const Common::String &text);
+
+ // Table index that always displays zero
+ static const int16 kZeroTableIndex = 255;
+
uint16 _fontID = 0;
uint16 _textColor = 0;
Common::Point _position;
Common::String _textKey;
int16 _tableIndex = 0;
+
+ int16 _numDigits = 0;
+ Common::Path _digitImageName;
+ uint16 _digitSpacing = 0;
+ Common::Rect _digitSrcRects[10];
+
+ Graphics::ManagedSurface _digitImage;
+ Common::String _displayedText;
};
// Nancy14 AR 53. A rollover label: an image that is only drawn while the mouse
Commit: 5886eb87ce88f2d319b63da0fa61579df840f8c9
https://github.com/scummvm/scummvm/commit/5886eb87ce88f2d319b63da0fa61579df840f8c9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:42+03:00
Commit Message:
NANCY: NANCY10+: Fixes for MazeChasePuzzle
- Use the correct wall type for left/right walls. Allows pieces to move
correctly near walls in the Dodo box puzzle in Nancy14 (Nancy10 has
no such walls in the Roadrunner puzzle)
- Handle the exit scene sliding action correctly, when the player's
piece leaves the scene
- Apply the correct delay when solving the puzzle
Changed paths:
engines/nancy/action/puzzle/mazechasepuzzle.cpp
engines/nancy/action/puzzle/mazechasepuzzle.h
diff --git a/engines/nancy/action/puzzle/mazechasepuzzle.cpp b/engines/nancy/action/puzzle/mazechasepuzzle.cpp
index ef4837891c9..5541074266b 100644
--- a/engines/nancy/action/puzzle/mazechasepuzzle.cpp
+++ b/engines/nancy/action/puzzle/mazechasepuzzle.cpp
@@ -156,9 +156,15 @@ void MazeChasePuzzle::readData(Common::SeekableReadStream &stream) {
_exitPos.y = stream.readUint16LE();
if (isNancy10) {
- // Selects how the player piece leaves the board when it reaches the
- // exit: zero makes it disappear at the hole, non-zero slides it off.
- _pieceDisappearsAtExit = stream.readByte() == 0;
+ byte exitBehavior = stream.readByte();
+ if (exitBehavior <= kExitSlideRight) {
+ _exitBehavior = (ExitBehavior)exitBehavior;
+ } else {
+ // nancy14 keeps the piece in place for any other value
+ _exitBehavior = g_nancy->getGameType() >= kGameTypeNancy14 ? kExitStay : kExitSlideRight;
+ }
+ } else {
+ _exitBehavior = _exitPos.x == 0 ? kExitSlideLeft : kExitSlideRight;
}
_grid.resize(height, Common::Array<uint16>(width));
@@ -235,17 +241,27 @@ void MazeChasePuzzle::execute() {
}
if (_pieces[0]._gridPos == _exitPos) {
- if (_pieceDisappearsAtExit) {
- // The piece vanishes at the hole instead of sliding past the edge
+ switch (_exitBehavior) {
+ case kExitDisappear:
_pieces[0].setVisible(false);
- } else {
- _pieces[0]._gridPos = _exitPos + Common::Point(_exitPos.x == 0 ? -1 : 1, 0);
+ break;
+ case kExitSlideLeft:
+ case kExitSlideRight:
+ _pieces[0]._gridPos = _exitPos + Common::Point(_exitBehavior == kExitSlideLeft ? -1 : 1, 0);
++_currentAnimFrame;
+ break;
+ case kExitStay:
+ break;
}
g_nancy->_sound->loadSound(_solveSound);
g_nancy->_sound->playSound(_solveSound);
_solved = true;
+
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ // The delay runs alongside the solve sound, in 3-second steps
+ _solveSoundPlayTime = g_nancy->getTotalPlayTime() + _solveSoundDelay * 3000;
+ }
_state = kActionTrigger;
} else {
for (uint i = 1; i < _pieces.size(); ++i) {
diff --git a/engines/nancy/action/puzzle/mazechasepuzzle.h b/engines/nancy/action/puzzle/mazechasepuzzle.h
index e05c8123b28..9fa403471b3 100644
--- a/engines/nancy/action/puzzle/mazechasepuzzle.h
+++ b/engines/nancy/action/puzzle/mazechasepuzzle.h
@@ -46,7 +46,9 @@ public:
void handleInput(NancyInput &input) override;
protected:
- enum WallType { kWallLeft = 1, kWallUp = 2, kWallRight = 3, kWallDown = 4, kWallLeftRight = 6, kWallUpDown = 6 };
+ enum WallType { kWallLeft = 1, kWallUp = 2, kWallRight = 3, kWallDown = 4, kWallLeftRight = 5, kWallUpDown = 6 };
+
+ enum ExitBehavior { kExitDisappear = 0, kExitSlideLeft = 1, kExitSlideRight = 2, kExitStay = 3 };
class Piece : public RenderObject {
public:
@@ -117,11 +119,9 @@ protected:
int _currentAnimFrame = -1;
- // nancy10 added a byte before the grid selecting how the player piece
- // leaves the board once it reaches the exit. When zero (the roadrunner
- // minigame), the piece simply vanishes at the hole; otherwise it slides
- // off past the edge of the board and stays visible (the nancy5 dancers).
- bool _pieceDisappearsAtExit = false;
+ // How the player piece leaves the board once it reaches the exit. nancy10
+ // added a byte selecting this; older games always slide off the edge.
+ ExitBehavior _exitBehavior = kExitSlideRight;
uint32 _solveSoundPlayTime = 0;
bool _solved = false;
Commit: e5e15baee15e6462345f60685a3ffa3ca3a744a8
https://github.com/scummvm/scummvm/commit/e5e15baee15e6462345f60685a3ffa3ca3a744a8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:43+03:00
Commit Message:
NANCY: NANCY15: Implement the solar panel cells puzzle (MagicBoxPuzzle)
A grid of numbered cells has a few of its cells left blank; the
matching number of loose tiles waits in a tray beside the board. A tile
is picked up by clicking it, carried on the cursor and dropped into a
blank cell (or back into the tray). The board is solved once every row
and every column adds up to the target printed beside it.
Changed paths:
A engines/nancy/action/puzzle/magicboxpuzzle.cpp
A engines/nancy/action/puzzle/magicboxpuzzle.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 42ac7c5faf1..56ff4ca9f18 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -55,7 +55,7 @@
#include "engines/nancy/action/puzzle/dropsortpuzzle.h"
//#include "engines/nancy/action/puzzle/escapegridpuzzle.h"
#include "engines/nancy/action/puzzle/gridmappuzzle.h"
-//#include "engines/nancy/action/puzzle/magicboxpuzzle.h"
+#include "engines/nancy/action/puzzle/magicboxpuzzle.h"
#include "engines/nancy/action/puzzle/matchpuzzle.h"
#include "engines/nancy/action/puzzle/hamradiopuzzle.h"
#include "engines/nancy/action/puzzle/hangmanpuzzle.h"
@@ -535,8 +535,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
return new DecoderPuzzle();
// -- Nancy15 new puzzles (types 183-185) --
case 183:
- //return new MagicBoxPuzzle();
- return nullptr; // TODO
+ return new MagicBoxPuzzle();
case 184:
//return new EscapeGridPuzzle();
return nullptr; // TODO
diff --git a/engines/nancy/action/puzzle/magicboxpuzzle.cpp b/engines/nancy/action/puzzle/magicboxpuzzle.cpp
new file mode 100644
index 00000000000..cef4f0fcd3e
--- /dev/null
+++ b/engines/nancy/action/puzzle/magicboxpuzzle.cpp
@@ -0,0 +1,412 @@
+/* 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/magicboxpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static void readRectList(Common::SeekableReadStream &stream, Common::Array<Common::Rect> &dst) {
+ int16 num = stream.readSint16LE();
+ dst.resize(num > 0 ? num : 0);
+ for (int16 i = 0; i < num; ++i) {
+ readRect(stream, dst[i]);
+ }
+}
+
+void MagicBoxPuzzle::readData(Common::SeekableReadStream &stream) {
+ _gridFlowType = stream.readUint16LE(); // 0x00
+
+ _numCols = stream.readSint32LE(); // 0x02
+ _numRows = stream.readSint32LE(); // 0x06
+ _subgridCols = stream.readSint32LE(); // 0x0a
+ _subgridRows = stream.readSint32LE(); // 0x0e
+
+ _allCellsBlank = stream.readByte(); // 0x12
+ _unknown13 = stream.readByte(); // 0x13
+ _allowTakeBack = stream.readByte(); // 0x14
+
+ // The board, row by row. Each cell is a single int; 0 leaves the cell blank.
+ uint numCells = (uint)MAX<int32>(0, _numRows) * (uint)MAX<int32>(0, _numCols);
+ _cellValues.resize(numCells);
+ for (uint i = 0; i < numCells; ++i) {
+ _cellValues[i] = stream.readSint32LE();
+ }
+
+ // One destination rect per blank cell, in the order the cells were read.
+ readRectList(stream, _slotDests);
+
+ // The loose tiles waiting in the tray.
+ uint16 numPieces = stream.readUint16LE();
+ _pieceValues.resize(numPieces);
+ for (uint16 i = 0; i < numPieces; ++i) {
+ _pieceValues[i] = stream.readSint32LE();
+ }
+
+ readFilename(stream, _tileImageName);
+ readRectList(stream, _tileSrcs);
+
+ // The tray spots. There is at least one per loose tile.
+ readRectList(stream, _trayDests);
+
+ readFilename(stream, _indicatorImageName);
+ readRectList(stream, _indicatorSrcs);
+ readRectList(stream, _indicatorDests);
+
+ _rowTargets.resize(MAX<int32>(0, _numRows));
+ for (uint i = 0; i < _rowTargets.size(); ++i) {
+ _rowTargets[i] = stream.readSint32LE();
+ }
+ _colTargets.resize(MAX<int32>(0, _numCols));
+ for (uint i = 0; i < _colTargets.size(); ++i) {
+ _colTargets[i] = stream.readSint32LE();
+ }
+
+ _hoverCursorType = stream.readUint16LE();
+ _dragCursorType = stream.readUint16LE();
+ stream.skip(8); // speed and step of the original's tile-slide animation
+
+ for (uint i = 0; i < kNumSounds; ++i) {
+ _sounds[i].readData(stream);
+ }
+
+ _solveScene.sceneID = stream.readUint16LE();
+ _solveScene.frameID = stream.readUint16LE();
+ _solveScene.continueSceneSound = kContinueSceneSound;
+ _solveFlag.label = stream.readSint16LE();
+ _solveFlag.flag = stream.readByte();
+ _solveSound.readData(stream);
+
+ _failScene.sceneID = stream.readUint16LE();
+ _failScene.frameID = stream.readUint16LE();
+ _failScene.continueSceneSound = kContinueSceneSound;
+ _failFlag.label = stream.readSint16LE();
+ _failFlag.flag = stream.readByte();
+ _failSound.readData(stream);
+
+ readExitHotspot(stream, _exitHotspot, _exitCursorType, _exitScene, _exitFlag);
+ _exitScene.continueSceneSound = kContinueSceneSound;
+}
+
+void MagicBoxPuzzle::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(_tileImageName, _tileImage);
+ _tileImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+ if (_indicatorImageName != _tileImageName) {
+ g_nancy->_resource->loadImage(_indicatorImageName, _indicatorImage);
+ _indicatorImage.setTransparentColor(_drawSurface.getTransparentColor());
+ }
+
+ // The slots are the blank cells, taken in the same order as their dest rects.
+ _slotCells.clear();
+ for (uint i = 0; i < _cellValues.size(); ++i) {
+ if (_allCellsBlank || _cellValues[i] == 0) {
+ _slotCells.push_back(i);
+ }
+ }
+ _slotCells.resize(MIN(_slotCells.size(), _slotDests.size()));
+
+ _slotContents.clear();
+ _slotContents.resize(_slotCells.size(), -1);
+ _piecePlacement.clear();
+ _piecePlacement.resize(_pieceValues.size(), -1);
+
+ _carriedPiece = -1;
+ _solved = false;
+ _exitRequested = false;
+
+ NancySceneState.setNoHeldItem();
+
+ redraw();
+ registerGraphics();
+ _carriedObject.registerGraphics();
+}
+
+Common::Rect MagicBoxPuzzle::tileSrc(int32 value) const {
+ if (value >= 1 && (uint)value <= _tileSrcs.size()) {
+ return _tileSrcs[value - 1];
+ }
+ return Common::Rect();
+}
+
+int MagicBoxPuzzle::slotAtCursor(const Common::Point &mousePos) const {
+ for (uint i = 0; i < _slotCells.size(); ++i) {
+ if (NancySceneState.getViewport().convertViewportToScreen(_slotDests[i]).contains(mousePos)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+int MagicBoxPuzzle::trayPieceAtCursor(const Common::Point &mousePos) const {
+ for (uint i = 0; i < _piecePlacement.size(); ++i) {
+ if (_piecePlacement[i] != -1 || i >= _trayDests.size()) {
+ continue;
+ }
+ if (NancySceneState.getViewport().convertViewportToScreen(_trayDests[i]).contains(mousePos)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+int32 MagicBoxPuzzle::rowSum(int row) const {
+ int32 sum = 0;
+ for (int col = 0; col < _numCols; ++col) {
+ sum += _cellValues[row * _numCols + col];
+ }
+ for (uint i = 0; i < _slotContents.size(); ++i) {
+ if (_slotContents[i] != -1 && _slotCells[i] / _numCols == row) {
+ sum += _pieceValues[_slotContents[i]];
+ }
+ }
+ return sum;
+}
+
+int32 MagicBoxPuzzle::colSum(int col) const {
+ int32 sum = 0;
+ for (int row = 0; row < _numRows; ++row) {
+ sum += _cellValues[row * _numCols + col];
+ }
+ for (uint i = 0; i < _slotContents.size(); ++i) {
+ if (_slotContents[i] != -1 && _slotCells[i] % _numCols == col) {
+ sum += _pieceValues[_slotContents[i]];
+ }
+ }
+ return sum;
+}
+
+bool MagicBoxPuzzle::isSolved() const {
+ for (int row = 0; row < _numRows; ++row) {
+ if (rowSum(row) != _rowTargets[row]) {
+ return false;
+ }
+ }
+ for (int col = 0; col < _numCols; ++col) {
+ if (colSum(col) != _colTargets[col]) {
+ return false;
+ }
+ }
+ return true;
+}
+
+void MagicBoxPuzzle::carryPiece(int piece, NancyInput &input) {
+ _carriedPiece = piece;
+
+ Common::Rect src = (piece >= 0) ? tileSrc(_pieceValues[piece]) : Common::Rect();
+ if (!src.isEmpty() && _tileImage.getBounds().contains(src)) {
+ _carriedObject._drawSurface.create(_tileImage, src);
+ _carriedObject.setTransparent(true);
+ _carriedObject.setVisible(true);
+ _carriedObject.pickUp();
+ _carriedObject.handleInput(input);
+ } else {
+ _carriedObject.setVisible(false);
+ _carriedObject.putDown();
+ }
+}
+
+void MagicBoxPuzzle::setDataCursor(uint16 cursorType, bool hotspotVariant) const {
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)cursorType, true, hotspotVariant);
+}
+
+SoundDescription MagicBoxPuzzle::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 MagicBoxPuzzle::redraw() {
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+ // The fixed cells are part of the scene background; only the loose tiles are drawn.
+ for (uint i = 0; i < _piecePlacement.size(); ++i) {
+ if (i == (uint)_carriedPiece) {
+ continue;
+ }
+
+ Common::Rect src = tileSrc(_pieceValues[i]);
+ if (src.isEmpty()) {
+ continue;
+ }
+
+ int slot = _piecePlacement[i];
+ if (slot == -1 && i >= _trayDests.size()) {
+ continue;
+ }
+ const Common::Rect &dest = (slot == -1) ? _trayDests[i] : _slotDests[slot];
+ _drawSurface.blitFrom(_tileImage, src, Common::Point(dest.left, dest.top));
+ }
+
+ // A marker lights up next to every row and column that already adds up.
+ if (!_indicatorSrcs.empty() && !_indicatorSrcs[0].isEmpty()) {
+ const Graphics::ManagedSurface &image = _indicatorImage.empty() ? _tileImage : _indicatorImage;
+ for (uint i = 0; i < _indicatorDests.size(); ++i) {
+ bool lit = (i < (uint)_numRows) ? (rowSum(i) == _rowTargets[i]) :
+ (colSum(i - _numRows) == _colTargets[i - _numRows]);
+ if (lit) {
+ _drawSurface.blitFrom(image, _indicatorSrcs[0],
+ Common::Point(_indicatorDests[i].left, _indicatorDests[i].top));
+ }
+ }
+ }
+
+ _needsRedraw = true;
+}
+
+void MagicBoxPuzzle::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+ _state = kRun;
+ // fall through
+ case kRun:
+ if (_exitRequested) {
+ _state = kActionTrigger;
+ break;
+ }
+
+ if (_solved) {
+ if (_endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound)) {
+ _state = kActionTrigger;
+ }
+ }
+
+ break;
+ case kActionTrigger:
+ if (_exitRequested) {
+ NancySceneState.setEventFlag(_exitFlag);
+ NancySceneState.changeScene(_exitScene);
+ } else {
+ NancySceneState.setEventFlag(_solveFlag);
+ NancySceneState.changeScene(_solveScene);
+ }
+
+ finishExecution();
+ break;
+ }
+}
+
+void MagicBoxPuzzle::handleInput(NancyInput &input) {
+ if (_state != kRun || _solved || _exitRequested) {
+ return;
+ }
+
+ const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+ // -- Carrying a tile: drop it into a free slot, or put it back in its tray spot. --
+ if (_carriedPiece >= 0) {
+ setDataCursor(_dragCursorType);
+ _carriedObject.handleInput(input);
+
+ if (click) {
+ int slot = slotAtCursor(input.mousePos);
+ if (slot >= 0 && _slotContents[slot] == -1) {
+ _slotContents[slot] = _carriedPiece;
+ _piecePlacement[_carriedPiece] = slot;
+ } else {
+ _piecePlacement[_carriedPiece] = -1;
+ }
+
+ playSoundBlock(_sounds[3]);
+ carryPiece(-1, input);
+ redraw();
+
+ if (isSolved()) {
+ _solved = true;
+ _endSound = playSoundBlock(_solveSound);
+ }
+ }
+
+ input.eatMouseInput();
+ return;
+ }
+
+ // -- Not carrying: pick a tile up from the tray or off the board. --
+ int piece = trayPieceAtCursor(input.mousePos);
+ if (piece == -1 && _allowTakeBack) {
+ int slot = slotAtCursor(input.mousePos);
+ if (slot >= 0) {
+ piece = _slotContents[slot];
+ }
+ }
+
+ if (piece >= 0) {
+ setDataCursor(_hoverCursorType);
+ if (click) {
+ int slot = _piecePlacement[piece];
+ if (slot != -1) {
+ _slotContents[slot] = -1;
+ _piecePlacement[piece] = -1;
+ }
+ carryPiece(piece, input);
+ playSoundBlock(_sounds[1]);
+ redraw();
+ }
+ input.eatMouseInput();
+ return;
+ }
+
+ if (!_exitHotspot.isEmpty() &&
+ NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+ setDataCursor(_exitCursorType, false);
+ if (click) {
+ _exitRequested = true;
+ }
+ }
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/magicboxpuzzle.h b/engines/nancy/action/puzzle/magicboxpuzzle.h
new file mode 100644
index 00000000000..fd0dce7ab45
--- /dev/null
+++ b/engines/nancy/action/puzzle/magicboxpuzzle.h
@@ -0,0 +1,132 @@
+/* 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_MAGICBOXPUZZLE_H
+#define NANCY_ACTION_MAGICBOXPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/misc/mousefollow.h"
+
+namespace Nancy {
+namespace Action {
+
+// Number-square puzzle, new in Nancy15 (AR 183). A grid of numbered cells has a few of
+// its cells left blank; the matching number of loose tiles waits in a tray beside the
+// board. A tile is picked up by clicking it, carried on the cursor and dropped into a
+// blank cell (or back into the tray). The board is solved once every row and every
+// column adds up to the target printed beside it.
+//
+// The record is loaded through the shared GridFlow factory, whose leading type word must
+// be zero for this puzzle. The grid is also divided into equal subgrids, which is what
+// spaces the cell rects out into blocks on screen.
+class MagicBoxPuzzle : public RenderActionRecord {
+public:
+ MagicBoxPuzzle() : RenderActionRecord(7) {}
+ virtual ~MagicBoxPuzzle() {}
+
+ 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 "MagicBoxPuzzle"; }
+
+ // The board slots are the blank cells, in row-major order; one dest rect per slot.
+ int slotAtCursor(const Common::Point &mousePos) const;
+ int trayPieceAtCursor(const Common::Point &mousePos) const;
+
+ int32 rowSum(int row) const;
+ int32 colSum(int col) const;
+ bool isSolved() const;
+
+ // Lifts the given piece onto the cursor, or sets the carried one down for an index of -1.
+ void carryPiece(int piece, NancyInput &input);
+ Common::Rect tileSrc(int32 value) const;
+
+ void setDataCursor(uint16 cursorType, bool hotspotVariant = true) const;
+ SoundDescription playSoundBlock(const RandomSoundBlock &block);
+ void redraw();
+
+ static const uint kNumSounds = 10;
+
+ // -- File data --
+ uint16 _gridFlowType = 0; // 0x00, validated as 0 by the factory
+ int32 _numCols = 0; // 0x02
+ int32 _numRows = 0; // 0x06
+ int32 _subgridCols = 0; // 0x0a
+ int32 _subgridRows = 0; // 0x0e
+ byte _allCellsBlank = 0; // 0x12, forces every cell to be a droppable slot
+ byte _unknown13 = 0; // 0x13
+ byte _allowTakeBack = 0; // 0x14, lets a placed tile be picked up again
+
+ Common::Array<int32> _cellValues; // row-major, 0 marks a blank cell
+ Common::Array<Common::Rect> _slotDests; // one per blank cell, in row-major order
+ Common::Array<int32> _pieceValues; // the loose tiles
+ Common::Path _tileImageName;
+ Common::Array<Common::Rect> _tileSrcs; // indexed by tile value - 1
+ Common::Array<Common::Rect> _trayDests; // resting place of each loose tile
+ Common::Path _indicatorImageName;
+ Common::Array<Common::Rect> _indicatorSrcs;
+ Common::Array<Common::Rect> _indicatorDests; // _numRows row markers, then _numCols column ones
+ Common::Array<int32> _rowTargets;
+ Common::Array<int32> _colTargets;
+ uint16 _hoverCursorType = 0;
+ uint16 _dragCursorType = 0;
+
+ RandomSoundBlock _sounds[kNumSounds];
+
+ SceneChangeDescription _solveScene;
+ FlagDescription _solveFlag;
+ RandomSoundBlock _solveSound;
+ // The second outcome of the record. Neither of the shipped Nancy15 boards can be
+ // failed, so nothing reaches it.
+ SceneChangeDescription _failScene;
+ FlagDescription _failFlag;
+ RandomSoundBlock _failSound;
+
+ Common::Rect _exitHotspot;
+ uint16 _exitCursorType = 0;
+ SceneChangeDescription _exitScene;
+ FlagDescription _exitFlag;
+
+ // -- Runtime state --
+ Common::Array<int> _slotCells; // board cell index of each slot
+ Common::Array<int> _slotContents; // piece in each slot, or -1
+ Common::Array<int> _piecePlacement; // slot each piece sits in, or -1 while in its tray spot
+ int _carriedPiece = -1;
+ Misc::MouseFollowObject _carriedObject;
+ bool _solved = false;
+ bool _exitRequested = false;
+ SoundDescription _endSound;
+
+ Graphics::ManagedSurface _tileImage;
+ Graphics::ManagedSurface _indicatorImage;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_MAGICBOXPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index eafd8074d97..acfbd3cf4bb 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -43,6 +43,7 @@ MODULE_OBJS = \
action/puzzle/leverpuzzle.o \
action/puzzle/magnetmazepuzzle.o \
action/puzzle/mazechasepuzzle.o \
+ action/puzzle/magicboxpuzzle.o \
action/puzzle/matchpuzzle.o \
action/puzzle/memorypuzzle.o \
action/puzzle/meterpuzzle.o \
Commit: c6c543dd9b96918b9f721198e04ab6a34cb3cfff
https://github.com/scummvm/scummvm/commit/c6c543dd9b96918b9f721198e04ab6a34cb3cfff
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:44+03:00
Commit Message:
NANCY: NANCY14: Add new functionality for TurningPuzzle
- Add overlay animation
- Add time limit for puzzle
Fixes the pocket watch puzzle in Dieter's house
Changed paths:
engines/nancy/action/puzzle/turningpuzzle.cpp
engines/nancy/action/puzzle/turningpuzzle.h
diff --git a/engines/nancy/action/puzzle/turningpuzzle.cpp b/engines/nancy/action/puzzle/turningpuzzle.cpp
index 7409131bb82..61dbb4913d3 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.cpp
+++ b/engines/nancy/action/puzzle/turningpuzzle.cpp
@@ -45,15 +45,55 @@ void TurningPuzzle::init() {
moveTo(screenBounds);
g_nancy->_resource->loadImage(_imageName, _image);
+
+ if (!_overlaySrcRects.empty() && !_overlayDestRects.empty()) {
+ g_nancy->_resource->loadImage(_overlayImageName, _overlayImage);
+ _overlayFrameIDs.resize(_overlayDestRects.size(), 0);
+
+ if (_randomizeOverlayStart) {
+ for (uint i = 0; i < _overlayFrameIDs.size(); ++i) {
+ _overlayFrameIDs[i] = g_nancy->_randomSource->getRandomNumber(_overlaySrcRects.size() - 1);
+ }
+ }
+
+ _nextOverlayFrameTime = g_nancy->getTotalPlayTime() + _overlayFrameTime;
+ }
+
+ if (_timeLimit) {
+ _timeoutTime = g_nancy->getTotalPlayTime() + (_timeLimit * 1000);
+ }
+
registerGraphics();
}
+// Every slot plays the same frames, but each starts wherever init() left it.
+void TurningPuzzle::drawOverlay(bool advanceFrames) {
+ for (uint i = 0; i < _overlayDestRects.size(); ++i) {
+ _drawSurface.blitFrom(_overlayImage, _overlaySrcRects[_overlayFrameIDs[i]], _overlayDestRects[i]);
+
+ if (advanceFrames) {
+ if ((uint)_overlayFrameIDs[i] + 1 < _overlaySrcRects.size()) {
+ ++_overlayFrameIDs[i];
+ } else {
+ _overlayFrameIDs[i] = 0;
+ }
+ }
+ }
+
+ _needsRedraw = true;
+}
+
void TurningPuzzle::updateGraphics() {
if (_state == kBegin) {
return;
}
if (g_nancy->getGameType() >= kGameTypeNancy13) {
+ if (!_overlayFrameIDs.empty() && g_nancy->getTotalPlayTime() >= _nextOverlayFrameTime) {
+ drawOverlay(true);
+ _nextOverlayFrameTime = g_nancy->getTotalPlayTime() + _overlayFrameTime;
+ }
+
if (_objectCurrentlyTurning == -1 || g_nancy->getTotalPlayTime() <= _nextTurnTime) {
return;
}
@@ -225,8 +265,32 @@ void TurningPuzzle::readDataNancy13(Common::SeekableReadStream &stream) {
_hotspots[i].grow(-(int16)_hitInset);
}
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ Common::String overlayName;
+ readFilename(stream, overlayName);
+
+ if (!overlayName.empty() && overlayName != "NO_FILE") {
+ _overlayImageName = Common::Path(overlayName);
+ readRectArray(stream, _overlaySrcRects, stream.readUint16LE());
+ _randomizeOverlayStart = stream.readByte();
+ _overlayFrameTime = stream.readUint16LE();
+ readRectArray(stream, _overlayDestRects, stream.readUint16LE());
+ }
+ }
+
_turnSoundBlock.readData(stream);
_solveSoundBlock.readData(stream);
+
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ _timeLimit = stream.readUint16LE();
+
+ if (_timeLimit) {
+ _timeoutScene._sceneChange.sceneID = stream.readUint16LE();
+ _timeoutScene._flag.label = stream.readSint16LE();
+ _timeoutScene._flag.flag = stream.readByte();
+ _timeoutSoundBlock.readData(stream);
+ }
+ }
}
uint TurningPuzzle::numFacesOf(uint objectID) const {
@@ -384,6 +448,10 @@ void TurningPuzzle::execute() {
_currentOrder = _startPositions;
drawAllObjects();
+ if (!_overlayFrameIDs.empty()) {
+ drawOverlay(false);
+ }
+
NancySceneState.setNoHeldItem();
_state = kRun;
@@ -406,6 +474,14 @@ void TurningPuzzle::execute() {
}
_objectCurrentlyTurning = -1;
_turnFrameID = 0;
+ } else if (_timeLimit && g_nancy->getTotalPlayTime() > _timeoutTime) {
+ // Out of time: the puzzle plays its own sound, then sends the player elsewhere.
+ _timedOut = true;
+ _state = kActionTrigger;
+ _solveSound = playSoundBlock(_timeoutSoundBlock);
+ _solveState = kWaitForSound;
+ _objectCurrentlyTurning = -1;
+ _turnFrameID = 0;
}
break;
@@ -440,7 +516,9 @@ void TurningPuzzle::execute() {
// the flag is only set here: setting it as soon as the puzzle is solved can
// invalidate this record's own dependencies, which stops it from being executed
// again before it ever reaches this point.
- if (g_nancy->getGameType() >= kGameTypeNancy13 || _shouldSetSolveFlag) {
+ if (_timedOut) {
+ _timeoutScene.execute();
+ } else if (g_nancy->getGameType() >= kGameTypeNancy13 || _shouldSetSolveFlag) {
_solveScene.execute();
} else {
NancySceneState.changeScene(_solveScene._sceneChange);
diff --git a/engines/nancy/action/puzzle/turningpuzzle.h b/engines/nancy/action/puzzle/turningpuzzle.h
index de129897f0b..b0d2e552b46 100644
--- a/engines/nancy/action/puzzle/turningpuzzle.h
+++ b/engines/nancy/action/puzzle/turningpuzzle.h
@@ -36,6 +36,11 @@ namespace Action {
// its own face count, turn-frame count and sprite geometry (instead of one global pair);
// the links are a byte array; each object stores its own destination rect; and the
// solution is given as up to three alternative face orders.
+//
+// Nancy14 added two things on top of that: a decorative overlay animation (a second image
+// whose frames cycle inside a set of fixed slots), and a time limit, after which the puzzle
+// plays its own sound and sends the player to a failure scene. Example: the pocket watch
+// puzzle in Dieter's house.
class TurningPuzzle : public RenderActionRecord {
public:
enum SolveState { kNotSolved, kWaitForAnimation, kWaitBeforeSound, kWaitForSound };
@@ -67,6 +72,7 @@ protected:
void readDataNancy13(Common::SeekableReadStream &stream);
bool isSolved() const;
+ void drawOverlay(bool advanceFrames);
uint numFacesOf(uint objectID) const;
uint framesPerTurnOf(uint objectID) const;
void drawAllObjects();
@@ -120,6 +126,22 @@ protected:
RandomSoundBlock _solveSoundBlock;
bool _turnFlagSet = false;
+ // -- Nancy14 only --
+ Common::Path _overlayImageName;
+ Common::Array<Common::Rect> _overlaySrcRects; // the frames of the overlay animation
+ Common::Array<Common::Rect> _overlayDestRects; // the slots they play in
+ bool _randomizeOverlayStart = false;
+ uint16 _overlayFrameTime = 0; // ms between overlay frames
+ uint16 _timeLimit = 0; // seconds, 0 = no time limit
+ SceneChangeWithFlag _timeoutScene;
+ RandomSoundBlock _timeoutSoundBlock;
+
+ Graphics::ManagedSurface _overlayImage;
+ Common::Array<uint16> _overlayFrameIDs; // one per slot
+ uint32 _nextOverlayFrameTime = 0;
+ uint32 _timeoutTime = 0;
+ bool _timedOut = false;
+
Graphics::ManagedSurface _image;
Common::Array<uint16> _currentOrder;
Commit: 2fbe61c225da92c8e94e6fd9e454a21553cb4807
https://github.com/scummvm/scummvm/commit/2fbe61c225da92c8e94e6fd9e454a21553cb4807
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:45+03:00
Commit Message:
NANCY: NANCY14: Handle scene background videos with an alpha plane
Since this can potentially impact performance, it's only applied to
videos that actually have an alpha plane.
Fixes the sliding library animation after solving the pocket watch
puzzle in Dieter's house.
Changed paths:
engines/nancy/ui/viewport.cpp
engines/nancy/ui/viewport.h
diff --git a/engines/nancy/ui/viewport.cpp b/engines/nancy/ui/viewport.cpp
index 3cf55feb41c..3cfd51749bc 100644
--- a/engines/nancy/ui/viewport.cpp
+++ b/engines/nancy/ui/viewport.cpp
@@ -225,6 +225,7 @@ void Viewport::loadVideo(const Common::Path &filename, uint frameNr, uint vertic
}
_videoFormat = format;
+ _frameAlpha = kAlphaUnchecked;
enableEdges(kUp | kDown | kLeft | kRight);
@@ -252,6 +253,21 @@ void Viewport::setFrame(uint frameNr) {
// Videos in TVD are always upside-down
GraphicsManager::copyToManaged(*newFrame, _fullFrame, g_nancy->getGameType() == kGameTypeVampire, _videoFormat == kSmallVideoFormat);
+ // Some scene backgrounds are Bink videos carrying an alpha plane, e.g. Nancy14's
+ // PHO_WallOpn_ANIM_Last, whose whole wall opening is transparent. The original engine
+ // draws the viewport opaquely, so the alpha is never used; honoring it would punch a
+ // hole through the bottom-most layer and show the frame image behind the scene.
+ // Alpha is a property of the video file, so the first frame decides for all of them;
+ // panorama scenes decode a frame per scroll step and should not pay for the check.
+ if (_frameAlpha == kAlphaUnchecked) {
+ _frameAlpha = _fullFrame.format.aBits() && _fullFrame.rawSurface().detectAlpha() != Graphics::ALPHA_OPAQUE ?
+ kAlphaNeedsFlattening : kAlphaOpaque;
+ }
+
+ if (_frameAlpha == kAlphaNeedsFlattening) {
+ _fullFrame.surfacePtr()->setAlpha(0xFF);
+ }
+
_needsRedraw = true;
_currentFrame = frameNr;
diff --git a/engines/nancy/ui/viewport.h b/engines/nancy/ui/viewport.h
index 729c3b527fe..319b6bf0d59 100644
--- a/engines/nancy/ui/viewport.h
+++ b/engines/nancy/ui/viewport.h
@@ -82,6 +82,9 @@ public:
void enableEdges(byte edges);
protected:
+ // Whether the frames of the loaded video need their alpha flattened, see setFrame()
+ enum FrameAlpha { kAlphaUnchecked, kAlphaOpaque, kAlphaNeedsFlattening };
+
void setEdgesSize(uint16 upSize, uint16 downSize, uint16 leftSize, uint16 rightSize);
Common::Rect _nonScrollZone;
@@ -96,6 +99,7 @@ protected:
uint16 _currentFrame;
uint16 _videoFormat;
Graphics::ManagedSurface _fullFrame;
+ FrameAlpha _frameAlpha = kAlphaUnchecked;
Common::Rect _format1Bounds;
Common::Rect _format2Bounds;
Common::Point _stickyCursorPos;
Commit: fc6963582e868c6e7ca060cbf9d65756e3879135
https://github.com/scummvm/scummvm/commit/fc6963582e868c6e7ca060cbf9d65756e3879135
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:46+03:00
Commit Message:
NANCY: NANCY14: Fixes for the camera
- The camera viewfinder now moves correctly while taking a photo
- After taking a photo, the user is taken to the camera gallery
interface, instead of the in-game scene
Changed paths:
engines/nancy/action/cameraaction.cpp
engines/nancy/state/scene.h
engines/nancy/ui/camera.cpp
engines/nancy/ui/camera.h
diff --git a/engines/nancy/action/cameraaction.cpp b/engines/nancy/action/cameraaction.cpp
index 5a5dc65b534..5eb8de0b037 100644
--- a/engines/nancy/action/cameraaction.cpp
+++ b/engines/nancy/action/cameraaction.cpp
@@ -325,10 +325,12 @@ void CameraAction::execute() {
break;
case kActionTrigger:
- // Back to the scene the camera was opened from, viewfinder up.
+ // Back to the scene the camera was opened from, viewfinder up. The
+ // camera remembers its item, so it can return to the album after a shot.
+ const int16 cameraItemID = NancySceneState.getPushedInvItemID();
NancySceneState.popScene(true);
if (UI::Camera *camera = NancySceneState.getCamera()) {
- camera->activate();
+ camera->activate(cameraItemID);
}
finishExecution();
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index a728dd5b3d6..5340cec5437 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -121,6 +121,7 @@ public:
void changeScene(const SceneChangeDescription &sceneDescription);
void pushScene(int16 itemID = -1);
void popScene(bool inventory = false);
+ int16 getPushedInvItemID() const { return _sceneState.pushedInvItemID; }
// Nancy 11+ "UI prep scenes": opening a taskbar popup first runs a hidden,
// videoless scene whose event-flag-gated ARs populate the popup's content;
diff --git a/engines/nancy/ui/camera.cpp b/engines/nancy/ui/camera.cpp
index 3f206258e3a..4b2371c6ff2 100644
--- a/engines/nancy/ui/camera.cpp
+++ b/engines/nancy/ui/camera.cpp
@@ -56,17 +56,29 @@ Common::Rect Camera::viewfinderScreenRect() const {
const int16 w = MIN<int16>(_cameraData->viewRect.width(), vp.width());
const int16 h = MIN<int16>(_cameraData->viewRect.height(), vp.height());
+ const int16 cx = CLIP<int16>(_aimPoint.x, vp.left + w / 2, vp.right - w / 2);
+ const int16 cy = CLIP<int16>(_aimPoint.y, vp.top + h / 2, vp.bottom - h / 2);
+
Common::Rect box(w, h);
- box.moveTo(vp.left + (vp.width() - w) / 2, vp.top + (vp.height() - h) / 2);
+ box.moveTo(cx - w / 2, cy - h / 2);
return box;
}
-void Camera::activate() {
+void Camera::drawViewfinder() {
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+ Common::Rect box = viewfinderScreenRect();
+ box.translate(-_screenPosition.left, -_screenPosition.top);
+ _drawSurface.blitFrom(_image, _cameraData->viewRect, box);
+ _needsRedraw = true;
+}
+
+void Camera::activate(int16 itemID) {
if (_isActive) {
return;
}
_isActive = true;
+ _itemID = itemID;
// The camera outlives every scene, so its surface is built on first use.
const Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
@@ -83,14 +95,13 @@ void Camera::activate() {
moveTo(vpBounds);
- // The viewfinder is fixed, so it only needs drawing once.
- _drawSurface.clear(g_nancy->_graphics->getTransColor());
- Common::Rect box = viewfinderScreenRect();
- box.translate(-_screenPosition.left, -_screenPosition.top);
- _drawSurface.blitFrom(_image, _cameraData->viewRect, box);
+ // Start aimed at the middle of the viewport; the box follows the cursor
+ // from its first move.
+ const Common::Rect vp = NancySceneState.getViewport().getScreenPosition();
+ _aimPoint = Common::Point(vp.left + vp.width() / 2, vp.top + vp.height() / 2);
+ drawViewfinder();
setVisible(true);
- _needsRedraw = true;
}
void Camera::deactivate() {
@@ -176,6 +187,27 @@ void Camera::takePicture() {
playSoundBlock(_cameraData->shutterSound);
deactivate();
+ returnToAlbum();
+}
+
+void Camera::returnToAlbum() {
+ if (_itemID == -1) {
+ return;
+ }
+
+ // Reopen the camera's close-up, just like viewing it from the inventory,
+ // so the album shows the new picture and later exits to this scene.
+ auto *inv = GetEngineData(INV);
+ assert(inv);
+ const INV::ItemDescription &item = inv->itemDescriptions[_itemID];
+
+ NancySceneState.pushScene(_itemID);
+ NancySceneState.removeItemFromInventory(_itemID, false);
+
+ SceneChangeDescription sceneChange;
+ sceneChange.sceneID = item.sceneID;
+ sceneChange.continueSceneSound = item.sceneSoundFlag;
+ NancySceneState.changeScene(sceneChange);
}
void Camera::handleInput(NancyInput &input) {
@@ -187,6 +219,11 @@ void Camera::handleInput(NancyInput &input) {
// The pointer is blanked so only the viewfinder shows.
g_nancy->_cursor->setCursorType(CursorManager::kNancy13Blank, true, false);
+ if (input.mousePos != _aimPoint) {
+ _aimPoint = input.mousePos;
+ drawViewfinder();
+ }
+
if (input.input & NancyInput::kLeftMouseButtonUp) {
takePicture();
}
diff --git a/engines/nancy/ui/camera.h b/engines/nancy/ui/camera.h
index 9bcbb599808..1c49fae5e49 100644
--- a/engines/nancy/ui/camera.h
+++ b/engines/nancy/ui/camera.h
@@ -32,9 +32,9 @@ struct NancyInput;
namespace UI {
// The standalone camera introduced in Nancy14 (UICM), switched on by its photo
-// album (CameraAction). While it is on, a viewfinder box sits at the centre of
-// the viewport and the scene's hotspots and panning are suppressed; a click
-// photographs every subject the box frames.
+// album (CameraAction). While it is on, a viewfinder box follows the cursor
+// and the scene's hotspots and panning are suppressed; a click photographs
+// every subject the box frames.
class Camera : public RenderObject {
public:
Camera() : RenderObject(9) {}
@@ -42,7 +42,8 @@ public:
void init() override;
- void activate();
+ // itemID is the inventory item whose close-up is the photo album, or -1.
+ void activate(int16 itemID = -1);
void deactivate();
bool isActive() const { return _isActive; }
@@ -56,12 +57,16 @@ public:
static void playSoundBlock(const RandomSoundBlock &block);
protected:
- // UICM's picture size, centred in the viewport.
+ // UICM's picture size, centred on the cursor and kept inside the viewport.
Common::Rect viewfinderScreenRect() const;
+ void drawViewfinder();
void takePicture();
+ void returnToAlbum();
const UICM *_cameraData = nullptr;
Graphics::ManagedSurface _image;
+ Common::Point _aimPoint;
+ int16 _itemID = -1;
bool _isActive = false;
};
Commit: 4f437e92102edeaf8efce270fc62046ffc2203ac
https://github.com/scummvm/scummvm/commit/4f437e92102edeaf8efce270fc62046ffc2203ac
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-18T03:59:47+03:00
Commit Message:
NANCY: NANCY13-14: Implement new functionality for RotatingLockPuzzle
- Implement the per-dial starting position functionality for Nancy14
- Fix puzzle cursors used in Nancy13/Nancy14
Fixes the rotating lock puzzle in the secret passage
Changed paths:
engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
engines/nancy/action/puzzle/rotatinglockpuzzle.h
engines/nancy/cursor.h
diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
index 863d1e9fcd7..d305ef2c410 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.cpp
@@ -90,6 +90,17 @@ void RotatingLockPuzzle::readData(Common::SeekableReadStream &stream) {
stream.skip((8 - numDials) * 16);
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ // Nancy 14 added per-dial starting positions, stored in a fixed
+ // 8-byte slot before the solution
+ _startSequence.reserve(numDials);
+ for (uint i = 0; i < numDials; ++i) {
+ _startSequence.push_back(stream.readByte());
+ }
+
+ stream.skip(8 - numDials);
+ }
+
_correctSequence.reserve(numDials);
for (uint i = 0; i < numDials; ++i) {
_correctSequence.push_back(stream.readByte());
@@ -101,7 +112,13 @@ void RotatingLockPuzzle::readData(Common::SeekableReadStream &stream) {
if (isNancy10) {
// Nancy 10 added per-puzzle cursor types for the up/down hotspots,
// stored right after the sequence slot. A value of 0 means "use the
- // default movement cursor".
+ // default movement cursor"; Nancy 13+ defaults to the blue puzzle
+ // up/down cursors instead.
+ if (g_nancy->getGameType() >= kGameTypeNancy13) {
+ _upCursorType = CursorManager::kNancy13PuzzleMoveUp;
+ _downCursorType = CursorManager::kNancy13PuzzleMoveDown;
+ }
+
int16 upType = stream.readSint16LE();
int16 downType = stream.readSint16LE();
if (upType != 0)
@@ -151,6 +168,12 @@ void RotatingLockPuzzle::execute() {
NancySceneState.setNoHeldItem();
for (uint i = 0; i < _correctSequence.size(); ++i) {
+ if (!_startSequence.empty() && _startSequence[i] != kRandomStart) {
+ _currentSequence.push_back(_startSequence[i]);
+ drawDial(i);
+ continue;
+ }
+
byte v = g_nancy->_randomSource->getRandomNumber(_iconsPerDial - 1);
// Nancy 10 rerolls until the starting value differs from the
// solution so the puzzle never appears already-solved.
diff --git a/engines/nancy/action/puzzle/rotatinglockpuzzle.h b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
index a0246bd50fc..bc9f81d0335 100644
--- a/engines/nancy/action/puzzle/rotatinglockpuzzle.h
+++ b/engines/nancy/action/puzzle/rotatinglockpuzzle.h
@@ -31,6 +31,7 @@ namespace Action {
class RotatingLockPuzzle : public RenderActionRecord {
public:
enum SolveState { kNotSolved, kPlaySound, kWaitForSound };
+ static const byte kRandomStart = 99;
RotatingLockPuzzle() : RenderActionRecord(7) {}
virtual ~RotatingLockPuzzle() {}
@@ -45,6 +46,9 @@ public:
Common::Array<Common::Rect> _destRects;
Common::Array<Common::Rect> _upHotspots;
Common::Array<Common::Rect> _downHotspots;
+ // Nancy 14+: per-dial starting positions; kRandomStart picks a random
+ // position that differs from the solution
+ Common::Array<byte> _startSequence;
Common::Array<byte> _correctSequence;
uint16 _iconsPerDial = 10;
// Cursor types shown while hovering a dial's up/down hotspot. Nancy 10+
diff --git a/engines/nancy/cursor.h b/engines/nancy/cursor.h
index e1aa240f8fd..6b1aca444e2 100644
--- a/engines/nancy/cursor.h
+++ b/engines/nancy/cursor.h
@@ -132,6 +132,8 @@ public:
kNancy13PuzzleRotateCCW = 26,
kNancy13PuzzleDragHand = 28,
kNancy13PuzzleDropHand = 29,
+ kNancy13PuzzleMoveUp = 30,
+ kNancy13PuzzleMoveDown = 31,
kNancy13PuzzleArrow = 34
};
More information about the Scummvm-git-logs
mailing list