[Scummvm-git-logs] scummvm master -> ab349cd205dc41ebf3398a306738fe7b0791e04e
bluegr
noreply at scummvm.org
Mon Sep 21 22:05:39 UTC 2026
This automated email contains information about 9 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .
Summary:
11828cf532 NANCY: NANCY15: Play the "can't" sounds from the PUIV bank
59e5fa495f NANCY: NANCY15: Handle multiple player chars in inventory commands
a5eac8e599 NANCY: NANCY15: Honor the player character in inventory action records
37177b74dc NANCY: NANCY15: Implement new functionality for the Movie Control AR
41f3931723 NANCY: NANCY15: Add new functionality for conversation cels
d0f9a7401c NANCY: NANCY15: Handle the player character in ResourceUse records
8656e29e7f NANCY: NANCY13: Properly convert 24bpp images to 32bpp
9c2a253e15 NANCY: NANCY10: Correct mapping for new font glyphs
ab349cd205 NANCY: NANCY15: Implement NecklacePuzzle
Commit: 11828cf5321890064300f89dbf3c7b16f5c0039a
https://github.com/scummvm/scummvm/commit/11828cf5321890064300f89dbf3c7b16f5c0039a
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:00:51+03:00
Commit Message:
NANCY: NANCY15: Play the "can't" sounds from the PUIV bank
They moved out of the inventory data and into the active character's own
bank: one group of interchangeable sounds per item, keyed by item ID,
plus the character's generic response. Items with no group stay silent.
Changed paths:
engines/nancy/state/scene.cpp
engines/nancy/state/scene.h
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 260e31747ef..0e1c4e88ad5 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -642,11 +642,102 @@ static Common::String getSoundSubtitle(const Common::String &soundName, const Co
return fallback;
}
+// Nancy15 moved the "can't" responses out of the inventory data and into the
+// active player character's PUIV bank: one group of interchangeable sounds per
+// item, keyed by item ID, plus the character's generic response, which is the
+// one the InventorySoundOverride record installs for its default command. They
+// all share the bank's channel and volume.
+bool Scene::getPlayerCantSound(int16 itemID, SoundDescription &sound) const {
+ auto *puivData = GetEngineData(PUIV);
+ if (!puivData) {
+ return false;
+ }
+
+ Common::String name;
+
+ if (itemID < 0) {
+ name = puivData->name;
+ } else {
+ for (uint i = 0; i < puivData->soundGroups.size(); ++i) {
+ const PUIV::SoundGroup &group = puivData->soundGroups[i];
+ if (group.tag == itemID && group.variants.size()) {
+ // The variants are interchangeable, so one is picked at random
+ name = group.variants[g_nancy->_randomSource->getRandomNumber(group.variants.size() - 1)];
+ break;
+ }
+ }
+ }
+
+ if (name.empty() || name.equalsIgnoreCase("NO SOUND")) {
+ return false;
+ }
+
+ sound.name = name;
+ sound.channelID = puivData->channelID;
+ sound.volume = puivData->volume;
+
+ return true;
+}
+
+void Scene::playPlayerCantSound(int16 itemID) {
+ auto *inventoryData = GetEngineData(INV);
+ assert(inventoryData);
+
+ SoundDescription sound;
+ Common::String caption;
+
+ if (itemID >= 0 && _inventorySoundOverrides.contains(itemID)) {
+ InventorySoundOverride &override = _inventorySoundOverrides[itemID];
+
+ if (override.isDefault) {
+ // Back to the character's generic response
+ if (!getPlayerCantSound(-1, sound)) {
+ return;
+ }
+ } else {
+ sound = override.sound;
+ caption = override.caption;
+ }
+ } else if (!getPlayerCantSound(itemID, sound)) {
+ // An item with no response of its own stays silent
+ return;
+ }
+
+ if (sound.name.empty() || sound.name.equalsIgnoreCase("NO SOUND")) {
+ // Silenced by an override
+ return;
+ }
+
+ // One response at a time: if the bank's channel is busy, the sound (and its
+ // caption) are left alone instead of being restarted or overlapped
+ if (g_nancy->_sound->isSoundPlaying(sound.channelID)) {
+ return;
+ }
+
+ if (ConfMan.getBool("subtitles")) {
+ _textbox.clear();
+ }
+
+ g_nancy->_sound->loadSound(sound);
+ g_nancy->_sound->playSound(sound);
+
+ if (ConfMan.getBool("subtitles")) {
+ _textbox.addTextLine(getSoundSubtitle(sound.name, caption), inventoryData->captionAutoClearTime);
+ }
+}
+
void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
// Improvement: nancy2 never shows the caption text, even though it exists in the data; we show it
auto *inventoryData = GetEngineData(INV);
assert(inventoryData);
+ // Nancy15 keeps no "can't" sounds in the inventory data; they come from the
+ // player character's own bank instead
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ playPlayerCantSound(itemID);
+ return;
+ }
+
// Nancy9 and newer play every "can't" sound on the same dedicated sound-effects
// channel as the default "can't" sound. If one is already playing, leave it (and
// the current caption) alone instead of restarting it or overlapping a new one.
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 5340cec5437..2c7b5d6a456 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -319,6 +319,11 @@ private:
// Rect of the open Nancy 10+ taskbar popup, or empty if none.
Common::Rect activePopupConfinement() const;
+ // Nancy15's "can't" responses live in the active player character's PUIV
+ // bank instead of the inventory data
+ bool getPlayerCantSound(int16 itemID, SoundDescription &sound) const;
+ void playPlayerCantSound(int16 itemID);
+
void initStaticData();
void clearSceneData(bool nextIsNoArt = false);
Commit: 59e5fa495fd18f340128df7a9bf5cf56297050e9
https://github.com/scummvm/scummvm/commit/59e5fa495fd18f340128df7a9bf5cf56297050e9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:36+03:00
Commit Message:
NANCY: NANCY15: Handle multiple player chars in inventory commands
Nancy15 gives every playable character their own inventory, and scene
dependencies can test any of them. get_inventory now reports ownership
per character, set_inventory takes an optional -c <index>, and the action
record dump names the character an inventory dependency asks about.
Changed paths:
engines/nancy/console.cpp
engines/nancy/console.h
engines/nancy/state/scene.cpp
engines/nancy/state/scene.h
diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index ecb71740011..f10fc30727e 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -498,6 +498,35 @@ void NancyConsole::printActionRecord(const Action::ActionRecord *record, bool no
}
}
+// Nancy15 gives every playable character their own inventory, so the console
+// has to name the one it reports on. The PCUI chunk holds the image names the
+// characters are known by, e.g. "PUI_CRE_Nancy".
+static Common::String playerCharacterName(uint characterIndex) {
+ const PCUI *pcui = GetEngineData(PCUI);
+ if (pcui && characterIndex < pcui->characters.size()) {
+ const Common::String &imageName = pcui->characters[characterIndex].imageName;
+ size_t lastSeparator = imageName.findLastOf('_');
+ if (lastSeparator != Common::String::npos) {
+ return imageName.substr(lastSeparator + 1);
+ }
+
+ if (!imageName.empty()) {
+ return imageName;
+ }
+ }
+
+ return Common::String::format("character %u", characterIndex);
+}
+
+static uint numPlayerCharacters() {
+ if (g_nancy->getGameType() < kGameTypeNancy15) {
+ return 1;
+ }
+
+ const PCUI *pcui = GetEngineData(PCUI);
+ return pcui ? MIN<uint>(pcui->characters.size(), kMaxPlayerCharacters) : 1;
+}
+
void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &record) {
using namespace Action;
@@ -517,6 +546,13 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
inventoryData->itemDescriptions[dep.label].name.c_str() :
"Invalid",
dep.condition == g_nancy->_true ? "true" : "false");
+
+ // Nancy15+ can ask about a character other than the one being played
+ if (g_nancy->getGameType() >= kGameTypeNancy15 && dep.hours >= 0 &&
+ dep.hours != kPlayerCharacterActive) {
+ debugPrintf(", %s", playerCharacterName(dep.hours).c_str());
+ }
+
break;
case DependencyType::kEvent:
debugPrintf("kEvent, flag %u, %s, %s",
@@ -1067,6 +1103,31 @@ bool NancyConsole::Cmd_setEventFlags(int argc, const char **argv) {
return cmdExit(0, nullptr);
}
+void NancyConsole::printInventoryItem(uint itemID) {
+ auto *inventoryData = GetEngineData(INV);
+ assert(inventoryData);
+
+ byte keep = inventoryData->itemDescriptions[itemID].keepItem;
+ debugPrintf("\nItem %u, %s, %s",
+ itemID,
+ inventoryData->itemDescriptions[itemID].name.c_str(),
+ keep == 0 ? "UseThenLose" : keep == 1 ? "KeepAlways" : keep == 2 ? "ReturnToInventory" : "NewSceneView");
+
+ uint numCharacters = numPlayerCharacters();
+ if (numCharacters == 1) {
+ debugPrintf(", %s", NancySceneState.hasItem(itemID) == g_nancy->_true ? "true" : "false");
+ return;
+ }
+
+ // Every character keeps their own copy of the item flags, and scene
+ // dependencies can ask about any of them, so list them all
+ for (uint i = 0; i < numCharacters; ++i) {
+ debugPrintf(", %s: %s",
+ playerCharacterName(i).c_str(),
+ NancySceneState.hasCharacterItem(i, itemID) == g_nancy->_true ? "true" : "false");
+ }
+}
+
bool NancyConsole::Cmd_getInventory(int argc, const char **argv) {
if (g_nancy->getState() != NancyState::kScene) {
debugPrintf("Not in the kScene state\n");
@@ -1074,34 +1135,26 @@ bool NancyConsole::Cmd_getInventory(int argc, const char **argv) {
}
uint numItems = g_nancy->getStaticData().numItems;
- auto *inventoryData = GetEngineData(INV);
- assert(inventoryData);
debugPrintf("Total number of inventory items: %u\n", numItems);
+ if (numPlayerCharacters() > 1) {
+ debugPrintf("Playing as %s\n", playerCharacterName(g_nancy->getPlayerCharacter()).c_str());
+ }
+
if (argc == 1) {
for (uint i = 0; i < numItems; ++i) {
- byte keep = inventoryData->itemDescriptions[i].keepItem;
- debugPrintf("\nItem %u, %s, %s, %s",
- i,
- inventoryData->itemDescriptions[i].name.c_str(),
- keep == 0 ? "UseThenLose" : keep == 1 ? "KeepAlways" : keep == 2 ? "ReturnToInventory" : "NewSceneView",
- NancySceneState.hasItem(i) == g_nancy->_true ? "true" : "false");
+ printInventoryItem(i);
}
} else {
for (int i = 1; i < argc; ++i) {
- int flagID = atoi(argv[i]);
- if (flagID < 0 || flagID >= (int)numItems) {
- debugPrintf("\nInvalid flag %s", argv[i]);
+ int itemID = atoi(argv[i]);
+ if (itemID < 0 || itemID >= (int)numItems) {
+ debugPrintf("\nInvalid item %s", argv[i]);
continue;
}
- byte keep = inventoryData->itemDescriptions[flagID].keepItem;
- debugPrintf("\nItem %u, %s, %s, %s",
- flagID,
- inventoryData->itemDescriptions[flagID].name.c_str(),
- keep == 0 ? "UseThenLose" : keep == 1 ? "KeepAlways" : keep == 2 ? "ReturnToInventory" : "NewSceneView",
- NancySceneState.hasItem(flagID) == g_nancy->_true ? "true" : "false");
+ printInventoryItem(itemID);
}
}
@@ -1119,13 +1172,39 @@ bool NancyConsole::Cmd_setInventory(int argc, const char **argv) {
return true;
}
- if (argc < 2 || argc % 2 == 0) {
+ // Without -c the items go to whoever is being played, which is the only
+ // inventory the games before Nancy15 have
+ uint characterIndex = g_nancy->getPlayerCharacter();
+ int firstItemArg = 1;
+
+ if (argc > 1 && Common::String(argv[1]).equalsIgnoreCase("-c")) {
+ if (argc < 3) {
+ debugPrintf("Missing character index after -c\n");
+ return true;
+ }
+
+ int requestedCharacter = atoi(argv[2]);
+ if (requestedCharacter < 0 || requestedCharacter >= (int)numPlayerCharacters()) {
+ debugPrintf("Invalid character %s\n", argv[2]);
+ return true;
+ }
+
+ characterIndex = requestedCharacter;
+ firstItemArg = 3;
+ }
+
+ if (argc < firstItemArg + 2 || (argc - firstItemArg) % 2 != 0) {
debugPrintf("Sets one or more inventory items to the provided value.\n");
- debugPrintf("Usage: %s <itemID> <true/false>...\n", argv[0]);
+ debugPrintf("Usage: %s [-c <characterIndex>] <itemID> <true/false>...\n", argv[0]);
+ debugPrintf("-c picks the player character to give the items to (Nancy15+); the character being played is the default.\n");
return true;
}
- for (int i = 1; i < argc; i += 2) {
+ Common::String targetDescription = numPlayerCharacters() > 1 ?
+ Common::String::format("the inventory of %s", playerCharacterName(characterIndex).c_str()) :
+ Common::String("inventory");
+
+ for (int i = firstItemArg; i < argc; i += 2) {
int itemID = atoi(argv[i]);
if (itemID < 0 || itemID >= (int)g_nancy->getStaticData().numItems) {
debugPrintf("Invalid item %s\n", argv[i]);
@@ -1133,15 +1212,17 @@ bool NancyConsole::Cmd_setInventory(int argc, const char **argv) {
}
if (Common::String(argv[i + 1]).compareTo("true") == 0) {
- NancySceneState.addItemToInventory(itemID);
- debugPrintf("Added item %i, %s, to inventory\n",
+ NancySceneState.addItemToCharacterInventory(characterIndex, itemID);
+ debugPrintf("Added item %i, %s, to %s\n",
itemID,
- inventoryData->itemDescriptions[itemID].name.c_str());
+ inventoryData->itemDescriptions[itemID].name.c_str(),
+ targetDescription.c_str());
} else if (Common::String(argv[i + 1]).compareTo("false") == 0) {
- NancySceneState.removeItemFromInventory(itemID, false);
- debugPrintf("Removed item %i, %s, from inventory\n",
+ NancySceneState.removeItemFromCharacterInventory(characterIndex, itemID);
+ debugPrintf("Removed item %i, %s, from %s\n",
itemID,
- inventoryData->itemDescriptions[itemID].name.c_str());
+ inventoryData->itemDescriptions[itemID].name.c_str(),
+ targetDescription.c_str());
} else {
debugPrintf("Invalid value %s\n", argv[i + 1]);
continue;
diff --git a/engines/nancy/console.h b/engines/nancy/console.h
index 8645d24ce4e..5c00b0d693f 100644
--- a/engines/nancy/console.h
+++ b/engines/nancy/console.h
@@ -71,6 +71,8 @@ private:
bool Cmd_soundInfo(int argc, const char **argv);
bool Cmd_showHotspots(int argc, const char **argv);
+ void printInventoryItem(uint itemID);
+
void printActionRecord(const Action::ActionRecord *record, bool noDependencies = false);
void recursePrintDependencies(const Action::DependencyRecord &record);
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 0e1c4e88ad5..90e3cf25683 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -487,6 +487,51 @@ void Scene::removeItemFromInventory(int16 id, bool pickUp) {
}
}
+void Scene::addItemToCharacterInventory(uint characterIndex, int16 id) {
+ if (id == -1) {
+ return;
+ }
+
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ if (hasItem(id) == g_nancy->_false) {
+ addItemToInventory(id);
+ }
+
+ return;
+ }
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (!playerChar) {
+ return;
+ }
+
+ PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+
+ const uint numItems = g_nancy->getStaticData().numItems;
+ inventory.items.resize(numItems, g_nancy->_false);
+ inventory.disabledItems.resize(numItems, 0);
+
+ if ((uint)id >= inventory.items.size() || inventory.items[id] == g_nancy->_true ||
+ inventory.heldItem == id) {
+ return;
+ }
+
+ inventory.items[id] = g_nancy->_true;
+
+ // Handing an item to a character who hasn't been played yet makes their
+ // inventory real; it would be thrown away on the next switch otherwise
+ inventory.isValid = true;
+
+ for (uint i = 0; i < inventory.order.size(); ++i) {
+ if (inventory.order[i] == id) {
+ inventory.order.remove_at(i);
+ break;
+ }
+ }
+
+ inventory.order.insert_at(0, id);
+}
+
void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id) {
if (characterIndex == g_nancy->getPlayerCharacter()) {
if (hasItem(id) == g_nancy->_true) {
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 2c7b5d6a456..8c12e095da2 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -153,6 +153,7 @@ public:
// Nancy15+ inventory action records and dependencies pick the character to
// act on, which needn't be the one being played. Anyone else is served from
// their parked inventory instead of the live one.
+ void addItemToCharacterInventory(uint characterIndex, int16 id);
void removeItemFromCharacterInventory(uint characterIndex, int16 id);
byte hasCharacterItem(uint characterIndex, int16 id);
int32 getCharacterUIResource(uint characterIndex, uint index);
Commit: a5eac8e59937dea1c3938739a8dd2eef94928cb8
https://github.com/scummvm/scummvm/commit/a5eac8e59937dea1c3938739a8dd2eef94928cb8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:37+03:00
Commit Message:
NANCY: NANCY15: Honor the player character in inventory action records
Changed paths:
engines/nancy/action/inventoryrecords.cpp
engines/nancy/action/inventoryrecords.h
engines/nancy/commontypes.h
engines/nancy/state/scene.cpp
engines/nancy/state/scene.h
diff --git a/engines/nancy/action/inventoryrecords.cpp b/engines/nancy/action/inventoryrecords.cpp
index 308cb7835dd..edecaf00911 100644
--- a/engines/nancy/action/inventoryrecords.cpp
+++ b/engines/nancy/action/inventoryrecords.cpp
@@ -34,40 +34,22 @@ namespace Action {
void AddInventoryNoHS::readData(Common::SeekableReadStream &stream) {
_itemID = stream.readUint16LE();
- if (g_nancy->getGameType() >= kGameTypeNancy6) {
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ // The character the item goes to took the first half of the cursor flag
+ _characterIndex = stream.readByte();
+ _setCursor = stream.readByte();
+ _forceCursor = stream.readUint16LE();
+ } else if (g_nancy->getGameType() >= kGameTypeNancy6) {
_setCursor = stream.readUint16LE();
_forceCursor = stream.readUint16LE();
}
}
void AddInventoryNoHS::execute() {
- if (_setCursor) {
- if (NancySceneState.getHeldItem() == _itemID) {
- // Already holding the item, e.g. when the scene reloads itself
- } else if (NancySceneState.getHeldItem() != -1) {
- // Currently holding another item
- if (_forceCursor) {
- NancySceneState.addItemToInventory(NancySceneState.getHeldItem());
- if (NancySceneState.hasItem(_itemID) == g_nancy->_true) {
- NancySceneState.removeItemFromInventory(_itemID, true);
- } else {
- NancySceneState.setHeldItem(_itemID);
- }
- } else {
- NancySceneState.addItemToInventory(_itemID);
- }
- } else {
- if (NancySceneState.hasItem(_itemID) == g_nancy->_true) {
- NancySceneState.removeItemFromInventory(_itemID, true);
- } else {
- NancySceneState.setHeldItem(_itemID);
- }
- }
- } else {
- if (NancySceneState.hasItem(_itemID) == g_nancy->_false) {
- NancySceneState.addItemToInventory(_itemID);
- }
- }
+ uint characterIndex = _characterIndex == kPlayerCharacterActive ?
+ g_nancy->getPlayerCharacter() : _characterIndex;
+
+ NancySceneState.giveItemToCharacter(characterIndex, _itemID, _setCursor, _forceCursor);
_isDone = true;
}
@@ -166,7 +148,14 @@ void ShowInventoryItem::execute() {
void InventorySoundOverride::readData(Common::SeekableReadStream &stream) {
_command = stream.readByte();
_itemID = stream.readUint16LE();
- stream.skip(2);
+
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ _characterIndex = stream.readByte();
+ stream.skip(1);
+ } else {
+ stream.skip(2);
+ }
+
char buf[61];
stream.read(buf, 60);
buf[60] = '\0';
@@ -175,12 +164,17 @@ void InventorySoundOverride::readData(Common::SeekableReadStream &stream) {
}
void InventorySoundOverride::execute() {
- NancySceneState.installInventorySoundOverride(_command, _sound, _caption, _itemID);
+ NancySceneState.installInventorySoundOverride(_command, _sound, _caption, _itemID, _characterIndex);
_isDone = true;
}
void EnableDisableInventory::readData(Common::SeekableReadStream &stream) {
_itemID = stream.readUint16LE();
+
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ _characterIndex = stream.readByte();
+ }
+
bool disabled = stream.readUint16LE();
bool playSound = stream.readUint16LE();
@@ -193,7 +187,29 @@ void EnableDisableInventory::readData(Common::SeekableReadStream &stream) {
}
void EnableDisableInventory::execute() {
- NancySceneState.setItemDisabledState(_itemID, _disabledState);
+ uint characterIndex = _characterIndex == kPlayerCharacterActive ?
+ g_nancy->getPlayerCharacter() : _characterIndex;
+
+ if (g_nancy->getGameType() >= kGameTypeNancy15 &&
+ _itemID >= kInvItemGroupAll && _itemID <= kInvItemGroupPortable) {
+ // Nancy15 can enable or disable a whole group of items at once
+ auto *inventoryData = GetEngineData(INV);
+ assert(inventoryData);
+
+ for (uint i = 0; i < inventoryData->itemDescriptions.size(); ++i) {
+ bool isViewable = inventoryData->itemDescriptions[i].keepItem == kInvItemNewSceneView;
+
+ if ((_itemID == kInvItemGroupViewable && !isViewable) ||
+ (_itemID == kInvItemGroupPortable && isViewable)) {
+ continue;
+ }
+
+ NancySceneState.setCharacterItemDisabledState(characterIndex, i, _disabledState);
+ }
+ } else {
+ NancySceneState.setCharacterItemDisabledState(characterIndex, _itemID, _disabledState);
+ }
+
_isDone = true;
}
diff --git a/engines/nancy/action/inventoryrecords.h b/engines/nancy/action/inventoryrecords.h
index 8977901ea1b..ea7cbb50461 100644
--- a/engines/nancy/action/inventoryrecords.h
+++ b/engines/nancy/action/inventoryrecords.h
@@ -34,6 +34,8 @@ public:
void execute() override;
uint16 _itemID = 0;
+ // Nancy15+, kPlayerCharacterActive means the character being played
+ byte _characterIndex = kPlayerCharacterActive;
bool _setCursor = false;
bool _forceCursor = false;
@@ -95,6 +97,8 @@ public:
byte _command = 0;
uint16 _itemID = 0;
+ // Nancy15+, kPlayerCharacterActive means the character being played
+ byte _characterIndex = kPlayerCharacterActive;
SoundDescription _sound;
Common::String _caption;
@@ -109,6 +113,8 @@ public:
void execute() override;
uint16 _itemID = 0;
+ // Nancy15+, kPlayerCharacterActive means the character being played
+ byte _characterIndex = kPlayerCharacterActive;
byte _disabledState = 0;
protected:
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index 56bdf45c8b2..ddf62428c09 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -76,6 +76,12 @@ static const byte kInvItemKeepAlways = 1;
static const byte kInvItemReturn = 2;
static const byte kInvItemNewSceneView = 3;
+// Nancy15 EnableDisableInventory records can name a whole group of items
+// instead of a single one
+static const uint16 kInvItemGroupAll = 100;
+static const uint16 kInvItemGroupViewable = 101;
+static const uint16 kInvItemGroupPortable = 102;
+
// Inventory item sound override commands
static const byte kInvSoundOverrideCommandNoSound = 0;
static const byte kInvSoundOverrideCommandTurnOff = 1;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 90e3cf25683..29509fd9732 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -532,10 +532,10 @@ void Scene::addItemToCharacterInventory(uint characterIndex, int16 id) {
inventory.order.insert_at(0, id);
}
-void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id) {
+void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id, bool pickUp) {
if (characterIndex == g_nancy->getPlayerCharacter()) {
if (hasItem(id) == g_nancy->_true) {
- removeItemFromInventory(id, false);
+ removeItemFromInventory(id, pickUp);
}
return;
@@ -563,11 +563,112 @@ void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id) {
}
}
- if (inventory.heldItem == id) {
+ if (pickUp) {
+ inventory.heldItem = id;
+ } else if (inventory.heldItem == id) {
inventory.heldItem = -1;
}
}
+int16 Scene::getCharacterHeldItem(uint characterIndex) {
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ return getHeldItem();
+ }
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ return playerChar ? playerChar->getInventory(characterIndex).heldItem : -1;
+}
+
+void Scene::setCharacterHeldItem(uint characterIndex, int16 id) {
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ setHeldItem(id);
+ return;
+ }
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (!playerChar) {
+ return;
+ }
+
+ PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+ inventory.heldItem = id;
+
+ if (id != -1) {
+ // An item waiting in a character's hand makes their inventory real
+ inventory.isValid = true;
+ }
+}
+
+void Scene::returnCharacterHeldItem(uint characterIndex) {
+ const int16 heldItem = getCharacterHeldItem(characterIndex);
+ if (heldItem == -1) {
+ return;
+ }
+
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ addItemToInventory(heldItem);
+ return;
+ }
+
+ setCharacterHeldItem(characterIndex, -1);
+ addItemToCharacterInventory(characterIndex, heldItem);
+}
+
+void Scene::giveItemToCharacter(uint characterIndex, int16 id, bool intoHand, bool forceIntoHand) {
+ if (id == -1) {
+ return;
+ }
+
+ if (!intoHand) {
+ addItemToCharacterInventory(characterIndex, id);
+ return;
+ }
+
+ const int16 heldItem = getCharacterHeldItem(characterIndex);
+ if (heldItem == id) {
+ // Already holding the item, e.g. when the scene reloads itself
+ return;
+ }
+
+ if (heldItem != -1) {
+ if (!forceIntoHand) {
+ // Their hand is full and the record doesn't insist
+ addItemToCharacterInventory(characterIndex, id);
+ return;
+ }
+
+ returnCharacterHeldItem(characterIndex);
+ }
+
+ // Into the hand, out of the inventory if that is where the item was
+ if (hasCharacterItem(characterIndex, id) == g_nancy->_true) {
+ removeItemFromCharacterInventory(characterIndex, id, true);
+ } else {
+ setCharacterHeldItem(characterIndex, id);
+ }
+}
+
+void Scene::setCharacterItemDisabledState(uint characterIndex, int16 id, byte state) {
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ setItemDisabledState(id, state);
+ return;
+ }
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (!playerChar || id < 0) {
+ return;
+ }
+
+ // Unlike an item, a disabled state doesn't make a character's inventory
+ // real: one who has never been played is set up from scratch when they are
+ PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+ inventory.disabledItems.resize(g_nancy->getStaticData().numItems, 0);
+
+ if ((uint)id < inventory.disabledItems.size()) {
+ inventory.disabledItems[id] = state;
+ }
+}
+
void Scene::setHeldItem(int16 id) {
_flags.heldItem = id; g_nancy->_cursor->setCursorItemID(id);
}
@@ -630,30 +731,37 @@ int32 Scene::getCharacterUIResource(uint characterIndex, uint index) {
return index < characterSet.size() ? characterSet[index] : 0;
}
-void Scene::installInventorySoundOverride(byte command, const SoundDescription &sound, const Common::String &caption, uint16 itemID) {
+void Scene::installInventorySoundOverride(byte command, const SoundDescription &sound,
+ const Common::String &caption, uint16 itemID, byte characterIndex) {
InventorySoundOverride newOverride;
+ // An override can be installed on a character who isn't being played, so it
+ // waits in their own set until they are
+ uint targetCharacter = characterIndex < kMaxPlayerCharacters ?
+ characterIndex : MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1);
+ Common::HashMap<uint16, InventorySoundOverride> &overrides = _inventorySoundOverrides[targetCharacter];
+
switch (command) {
case kInvSoundOverrideCommandNoSound :
// Make the sound silent
newOverride.sound = sound;
newOverride.sound.name = "NO SOUND";
newOverride.caption = caption; // Assumes the caption will be empty
- _inventorySoundOverrides.setVal(itemID, newOverride);
+ overrides.setVal(itemID, newOverride);
break;
case kInvSoundOverrideCommandNewSound :
newOverride.sound = sound;
newOverride.caption = caption;
- _inventorySoundOverrides.setVal(itemID, newOverride);
+ overrides.setVal(itemID, newOverride);
break;
case kInvSoundOverrideCommandICant :
// Make the sound the default "I can't use that here"
newOverride.isDefault = true;
- _inventorySoundOverrides.setVal(itemID, newOverride);
+ overrides.setVal(itemID, newOverride);
break;
case kInvSoundOverrideCommandTurnOff :
// Remove any previous override
- _inventorySoundOverrides.erase(itemID);
+ overrides.erase(itemID);
break;
default :
return;
@@ -687,6 +795,10 @@ static Common::String getSoundSubtitle(const Common::String &soundName, const Co
return fallback;
}
+Common::HashMap<uint16, Scene::InventorySoundOverride> &Scene::activeSoundOverrides() {
+ return _inventorySoundOverrides[MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1)];
+}
+
// Nancy15 moved the "can't" responses out of the inventory data and into the
// active player character's PUIV bank: one group of interchangeable sounds per
// item, keyed by item ID, plus the character's generic response, which is the
@@ -731,8 +843,8 @@ void Scene::playPlayerCantSound(int16 itemID) {
SoundDescription sound;
Common::String caption;
- if (itemID >= 0 && _inventorySoundOverrides.contains(itemID)) {
- InventorySoundOverride &override = _inventorySoundOverrides[itemID];
+ if (itemID >= 0 && activeSoundOverrides().contains(itemID)) {
+ InventorySoundOverride &override = activeSoundOverrides()[itemID];
if (override.isDefault) {
// Back to the character's generic response
@@ -810,9 +922,9 @@ void Scene::playItemCantSound(int16 itemID, bool notHoldingSound) {
g_nancy->_sound->playSound("CANT");
}
} else if ((uint)itemID < _flags.items.size()) {
- if (_inventorySoundOverrides.contains(itemID)) {
+ if (activeSoundOverrides().contains(itemID)) {
// We have an override installed
- InventorySoundOverride &override = _inventorySoundOverrides[itemID];
+ InventorySoundOverride &override = activeSoundOverrides()[itemID];
if (!override.isDefault) {
// Not set to the default sound, play the override
g_nancy->_sound->loadSound(override.sound);
@@ -1788,10 +1900,12 @@ void Scene::load(bool fromSaveFile) {
}
}
- for (auto &override : _inventorySoundOverrides) {
- g_nancy->_sound->stopSound(override._value.sound);
+ for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
+ for (auto &override : _inventorySoundOverrides[i]) {
+ g_nancy->_sound->stopSound(override._value.sound);
+ }
+ _inventorySoundOverrides[i].clear();
}
- _inventorySoundOverrides.clear();
_timers.sceneTime = 0;
g_nancy->_sound->clearListenerPositionOverride();
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 8c12e095da2..63a36a5d02a 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -154,8 +154,14 @@ public:
// act on, which needn't be the one being played. Anyone else is served from
// their parked inventory instead of the live one.
void addItemToCharacterInventory(uint characterIndex, int16 id);
- void removeItemFromCharacterInventory(uint characterIndex, int16 id);
+ void removeItemFromCharacterInventory(uint characterIndex, int16 id, bool pickUp = false);
byte hasCharacterItem(uint characterIndex, int16 id);
+ void setCharacterItemDisabledState(uint characterIndex, int16 id, byte state);
+
+ // AddInventoryNoHS (AR 120): hand the item over, into the character's hand
+ // when the record asks for it and their hand is free or forceIntoHand is set
+ void giveItemToCharacter(uint characterIndex, int16 id, bool intoHand, bool forceIntoHand);
+
int32 getCharacterUIResource(uint characterIndex, uint index);
int16 getHeldItem() const { return _flags.heldItem; }
void setHeldItem(int16 id);
@@ -167,7 +173,10 @@ public:
_flags.disabledItems[id] = state;
}
- void installInventorySoundOverride(byte command, const SoundDescription &sound, const Common::String &caption, uint16 itemID);
+ // Nancy15 records name the player character the override belongs to;
+ // kPlayerCharacterActive (and every earlier game) means whoever is played
+ void installInventorySoundOverride(byte command, const SoundDescription &sound,
+ const Common::String &caption, uint16 itemID, byte characterIndex = kPlayerCharacterActive);
void playItemCantSound(int16 itemID = -1, bool notHoldingSound = false);
void setEventFlag(int16 label, byte flag);
@@ -320,6 +329,10 @@ private:
// Rect of the open Nancy 10+ taskbar popup, or empty if none.
Common::Rect activePopupConfinement() const;
+ int16 getCharacterHeldItem(uint characterIndex);
+ void setCharacterHeldItem(uint characterIndex, int16 id);
+ void returnCharacterHeldItem(uint characterIndex);
+
// Nancy15's "can't" responses live in the active player character's PUIV
// bank instead of the inventory data
bool getPlayerCantSound(int16 itemID, SoundDescription &sound) const;
@@ -382,6 +395,9 @@ private:
Common::String caption;
};
+ // The overrides of the character being played
+ Common::HashMap<uint16, InventorySoundOverride> &activeSoundOverrides();
+
// UI
UI::FullScreenImage _frame;
UI::Viewport _viewport;
@@ -418,7 +434,9 @@ private:
int16 _lastHintCharacter;
int16 _lastHintID;
NancyState::NancyState _gameStateRequested;
- Common::HashMap<uint16, InventorySoundOverride> _inventorySoundOverrides;
+ // One set of overrides per player character; everything before Nancy15 only
+ // ever touches the first
+ Common::HashMap<uint16, InventorySoundOverride> _inventorySoundOverrides[kMaxPlayerCharacters];
Misc::Lightning *_lightning;
Common::Queue<Misc::SpecialEffect> _specialEffects;
Commit: 37177b74dcd595aa48a32026bddc1b76e2ad1f24
https://github.com/scummvm/scummvm/commit/37177b74dcd595aa48a32026bddc1b76e2ad1f24
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:38+03:00
Commit Message:
NANCY: NANCY15: Implement new functionality for the Movie Control AR
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 b9e0e7e3d70..a3545870503 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -1344,6 +1344,14 @@ void PlaySecondaryMovie::skip() {
// --- PlayRandomMovieControl --------------------------------------------
void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ // Nancy15 names the movie to control. The literal "RandomMovie" keeps the
+ // old behavior of addressing the scene's random movie; any other name is
+ // a movie file, which is looked up among the loaded movies.
+ readFilename(stream, _movieName);
+ _isRandomMovie = _movieName.baseName().equalsIgnoreCase("RandomMovie");
+ }
+
_mode = stream.readByte();
_hasSceneChange = g_nancy->getGameType() < kGameTypeNancy13;
@@ -1353,24 +1361,62 @@ void PlayRandomMovieControl::readData(Common::SeekableReadStream &stream) {
}
void PlayRandomMovieControl::execute() {
- PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
- if (target && target->isRandom()) {
- if (_hasSceneChange) {
- target->stopRandom();
- } else {
+ if (_isRandomMovie) {
+ PlaySecondaryMovie *target = NancySceneState.getActiveMovie();
+ if (target && target->isRandom()) {
+ if (_hasSceneChange) {
+ target->stopRandom();
+ } else {
+ switch (_mode) {
+ case kStopNow:
+ target->stopRandomNow();
+ break;
+ case kPauseMovie:
+ target->pauseRandom(true);
+ break;
+ case kResumeMovie:
+ target->pauseRandom(false);
+ break;
+ case kStopAtEnd:
+ target->stopRandom();
+ break;
+ case kPauseAtEnd:
+ // The original defers the pause until the movie reaches its
+ // end; nothing in the shipped data uses it
+ warning("Unimplemented PlayRandomMovieControl mode kPauseAtEnd");
+ break;
+ default:
+ warning("Unknown PlayRandomMovieControl mode %u", _mode);
+ break;
+ }
+ }
+ }
+ } else {
+ MoviePlayer *movie = MoviePlayer::findLoadedMovie(_movieName);
+ if (movie) {
switch (_mode) {
case kStopNow:
- target->stopRandomNow();
+ movie->stop();
break;
case kPauseMovie:
- target->pauseRandom(true);
+ movie->pauseVideo(true);
break;
case kResumeMovie:
- target->pauseRandom(false);
+ movie->pauseVideo(false);
+ break;
+ case kStopAtEnd:
+ case kPauseAtEnd:
+ warning("Unimplemented deferred PlayRandomMovieControl mode %u for movie %s",
+ _mode, _movieName.toString().c_str());
break;
default:
+ warning("Unknown PlayRandomMovieControl mode %u", _mode);
break;
}
+ } else if (_mode == kResumeMovie) {
+ // The original loads the movie and starts playing it here
+ warning("PlayRandomMovieControl can't play movie %s, it is not loaded",
+ _movieName.toString().c_str());
}
}
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index a7719e7842f..71803408bda 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -376,7 +376,9 @@ protected:
// Companion AR for the random-movie variant of PlaySecondaryMovie. When
// executed it stops the currently-active random PlaySecondaryMovie and
-// optionally performs a scene change / event-flag set.
+// optionally performs a scene change / event-flag set. From Nancy15 on the
+// record names the movie it controls, so it can also address an already-loaded
+// movie by filename.
class PlayRandomMovieControl : public ActionRecord {
public:
PlayRandomMovieControl() {}
@@ -387,7 +389,9 @@ public:
enum RandomMovieControlMode : byte {
kStopNow = 0,
kPauseMovie = 1,
- kResumeMovie = 2
+ kResumeMovie = 2,
+ kStopAtEnd = 3,
+ kPauseAtEnd = 4
};
protected:
@@ -398,6 +402,9 @@ protected:
// Nancy13's record is the mode byte alone; earlier games append a scene
// change to it.
bool _hasSceneChange = true;
+ // Nancy15 prefixes the mode with the name of the movie to control.
+ Common::Path _movieName;
+ bool _isRandomMovie = true;
};
} // End of namespace Action
Commit: 41f393172322352c8ed7aa155f2b3331d137d813
https://github.com/scummvm/scummvm/commit/41f393172322352c8ed7aa155f2b3331d137d813
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:39+03:00
Commit Message:
NANCY: NANCY15: Add new functionality for conversation cels
- Handle new rects in the conversation XSheet
- Handle Bink movies used for character animations
Changed paths:
engines/nancy/action/conversation.cpp
engines/nancy/action/conversation.h
engines/nancy/resource.cpp
engines/nancy/resource.h
diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 19faca812af..4947ab70bef 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -1044,6 +1044,34 @@ void ConversationCel::init() {
} else break;
}
+ // A Nancy15 tree that isn't a loaded cel archive names a movie - the character's
+ // talking head, which is played into the tree's rect while the body keeps
+ // animating from cels.
+ _treeMovies.resize(_celRObjects.size());
+ for (uint i = 0; i < _celRObjects.size(); ++i) {
+ // _treeRects is only filled in from Nancy15 on, so earlier games always
+ // take the cel path here
+ if (i >= _treeRects.size() || g_nancy->_resource->hasCifTree(_treeNames[i])) {
+ continue;
+ }
+
+ if (_treeRects[i].isEmpty()) {
+ warning("Cel tree '%s' is neither a loaded .cal nor a movie with a destination rect",
+ _treeNames[i].c_str());
+ continue;
+ }
+
+ Common::SharedPtr<MoviePlayer> movie(new MoviePlayer());
+ if (!movie->loadFile(Common::Path(_treeNames[i]))) {
+ warning("Couldn't load conversation movie '%s'", _treeNames[i].c_str());
+ continue;
+ }
+
+ movie->start();
+ _treeMovies[i] = movie;
+ _celRObjects[i].moveTo(_treeRects[i]);
+ }
+
registerGraphics();
}
@@ -1051,7 +1079,9 @@ void ConversationCel::registerGraphics() {
for (uint i = 0; i < _celRObjects.size(); ++i) {
_celRObjects[i].setZOrder(9 + _drawingOrder[i]);
_celRObjects[i].setVisible(true);
- _celRObjects[i].setTransparent(true);
+ // Head movies carry an alpha plane, so they are composited by it rather
+ // than by the transparent color key the cels use.
+ _celRObjects[i].setTransparent(i >= _treeMovies.size() || !_treeMovies[i]);
_celRObjects[i].registerGraphics();
}
@@ -1061,8 +1091,22 @@ void ConversationCel::registerGraphics() {
void ConversationCel::updateGraphics() {
uint32 currentTime = g_nancy->getTotalPlayTime();
+ for (uint i = 0; i < _treeMovies.size(); ++i) {
+ if (_treeMovies[i] && _treeMovies[i]->needsUpdate()) {
+ const Graphics::Surface *frame = _treeMovies[i]->decodeNextFrame();
+ if (frame) {
+ _celRObjects[i].setMovieFrame(*frame);
+ }
+ }
+ }
+
if (_state == kRun && currentTime > _nextFrameTime && _curFrame < MIN<uint>(_lastFrame + 1, _celNames[0].size())) {
for (uint i = 0; i < _celRObjects.size(); ++i) {
+ if (i < _treeMovies.size() && _treeMovies[i]) {
+ // Movie trees run on the movie's own clock
+ continue;
+ }
+
Cel &cel = loadCel(_celNames[i][_curFrame], _treeNames[i]);
if (_overrideTreeRects[i] == kCelOverrideTreeRectsOn) {
_celRObjects[i]._drawSurface.create(cel.surf, _overrideRectSrcs[i]);
@@ -1078,6 +1122,21 @@ void ConversationCel::updateGraphics() {
}
}
+void ConversationCel::RenderedCel::setMovieFrame(const Graphics::Surface &frame) {
+ GraphicsManager::copyToManaged(frame, _drawSurface);
+ _needsRedraw = true;
+}
+
+void ConversationCel::onPause(bool pause) {
+ for (uint i = 0; i < _treeMovies.size(); ++i) {
+ if (_treeMovies[i]) {
+ _treeMovies[i]->pauseVideo(pause);
+ }
+ }
+
+ RenderActionRecord::onPause(pause);
+}
+
void ConversationCel::readData(Common::SeekableReadStream &stream) {
if (g_nancy->getGameType() >= kGameTypeNancy13) {
readDataNancy13(stream);
@@ -1169,6 +1228,13 @@ void ConversationCel::readXSheet(Common::SeekableReadStream &stream, const Commo
// Skip any unused tree-name slots so the frame time is read from its fixed
// offset regardless of numTrees.
xsheet->skip((kMaxTrees - numTrees) * kNameSize);
+
+ // Nancy15 inserted a destination rect per tree slot ahead of the frame time.
+ // It is only filled in for trees that name a movie instead of a cel archive.
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ readRectArray(*xsheet, _treeRects, kMaxTrees);
+ }
+
_frameTime = xsheet->readUint32LE();
} else {
xsheet->skip(2);
@@ -1205,6 +1271,10 @@ ConversationCel::Cel &ConversationCel::loadCel(const Common::Path &name, const C
bool ConversationCel::load() {
for (uint i = _curFrame; i < _celNames[0].size(); ++i) {
for (uint j = 0; j < _celRObjects.size(); ++j) {
+ if (j < _treeMovies.size() && _treeMovies[j]) {
+ continue;
+ }
+
if (!_celCache.contains(_celNames[j][i])) {
loadCel(_celNames[j][i], _treeNames[j]);
return false;
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 6e6f023ad0c..278e2cf284e 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -185,6 +185,7 @@ public:
void init() override;
void registerGraphics() override;
void updateGraphics() override;
+ void onPause(bool pause) override;
void readData(Common::SeekableReadStream &stream) override;
@@ -204,6 +205,9 @@ protected:
public:
RenderedCel() : RenderObject(9) {}
bool isViewportRelative() const override { return true; }
+
+ // Nancy15 head movies: show a decoded movie frame instead of a cel
+ void setMovieFrame(const Graphics::Surface &frame);
};
static const byte kCelOverrideTreeRectsOff = 1;
@@ -229,6 +233,12 @@ protected:
Common::Array<Common::Rect> _overrideRectSrcs;
Common::Array<Common::Rect> _overrideRectDests;
+ // Nancy15 gave every cel tree a destination rect inside the XSheet. A tree that
+ // names a movie instead of a loaded cel archive (the character's head) is played
+ // into that rect; _treeMovies holds one player per such tree, null for cel trees.
+ Common::Array<Common::Rect> _treeRects;
+ Common::Array<Common::SharedPtr<MoviePlayer>> _treeMovies;
+
uint _curFrame = 0;
uint32 _nextFrameTime = 0;
diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index 089bf8e88d4..cc06dc9cc08 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -213,10 +213,8 @@ IFF *ResourceManager::loadIFF(const Common::Path &name) {
bool ResourceManager::readCifTree(const Common::String &name, const Common::String &ext, int priority) {
// Nancy15+ asks for a player character's tree again on every switch back
// to that character, so make sure each tree is only ever added once
- for (const Common::String &loaded : _cifTreeNames) {
- if (loaded.equalsIgnoreCase(name)) {
- return true;
- }
+ if (hasCifTree(name)) {
+ return true;
}
CifTree *tree = CifTree::makeCifTreeArchive(name, ext);
@@ -234,6 +232,16 @@ bool ResourceManager::readCifTree(const Common::String &name, const Common::Stri
return true;
}
+bool ResourceManager::hasCifTree(const Common::String &name) const {
+ for (const Common::String &loaded : _cifTreeNames) {
+ if (loaded.equalsIgnoreCase(name)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
void ResourceManager::setCifTreePriority(const Common::String &name, int priority) {
Common::String upper = name;
upper.toUppercase();
diff --git a/engines/nancy/resource.h b/engines/nancy/resource.h
index c436d71408c..9607ddc4c79 100644
--- a/engines/nancy/resource.h
+++ b/engines/nancy/resource.h
@@ -49,6 +49,10 @@ public:
// Load a new ciftree
bool readCifTree(const Common::String &name, const Common::String &ext, int priority);
+ // Whether a ciftree of that name has been loaded. Nancy15 conversations use it
+ // to tell a cel archive apart from a movie of the same name (see ConversationCel).
+ bool hasCifTree(const Common::String &name) const;
+
// Change the search priority of an already loaded ciftree. Nancy15+ ships one
// copy of the popup UI resources per player character, so the tree belonging
// to the active character has to win name lookups against all the others.
Commit: d0f9a7401c6c1b928895ab643060e4b4d67daabb
https://github.com/scummvm/scummvm/commit/d0f9a7401c6c1b928895ab643060e4b4d67daabb
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:41+03:00
Commit Message:
NANCY: NANCY15: Handle the player character in ResourceUse records
Changed paths:
engines/nancy/action/miscrecords.cpp
engines/nancy/action/miscrecords.h
engines/nancy/state/scene.cpp
engines/nancy/state/scene.h
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 9bef435cc67..3f7bfd7e92c 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -989,9 +989,13 @@ void HintSystem::selectHint() {
}
void ResourceUse::readData(Common::SeekableReadStream &stream) {
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ _characterIndex = stream.readByte(); // whose resources are changed
+ }
+
_resourceIndex = stream.readSint16LE(); // which UIRC resource to change
- _amount = stream.readSint16LE(); // value / delta
- _mode = stream.readByte(); // 0 = set, non-zero = add
+ _amount = stream.readSint16LE(); // value / delta, or a table index
+ _mode = stream.readByte(); // see ResourceUseMode
_flag.label = stream.readSint16LE(); // event flag set on success
_flag.flag = stream.readByte();
@@ -1013,6 +1017,10 @@ void ResourceUse::readData(Common::SeekableReadStream &stream) {
_valueDest.y = stream.readSint32LE();
}
+byte ResourceUse::getCharacterIndex() const {
+ return _characterIndex == kPlayerCharacterActive ? (byte)g_nancy->getPlayerCharacter() : _characterIndex;
+}
+
void ResourceUse::init() {
Common::Rect screenBounds = NancySceneState.getViewport().getBounds();
_drawSurface.create(screenBounds.width(), screenBounds.height(), g_nancy->_graphics->getInputPixelFormat());
@@ -1040,7 +1048,7 @@ void ResourceUse::init() {
const Font *font = g_nancy->_graphics->getFont(item.fontID);
if (font && item.numDecimals >= 0) {
const Common::String text =
- formatUIResourceValue(item, NancySceneState.getUIResource(_resourceIndex));
+ formatUIResourceValue(item, NancySceneState.getUIResource(_resourceIndex, getCharacterIndex()));
font->drawString(&_drawSurface, text, _valueDest.x, _valueDest.y, screenBounds.width() - _valueDest.x, 0);
}
}
@@ -1050,18 +1058,31 @@ void ResourceUse::init() {
}
void ResourceUse::applyChange() {
- if (_mode == 0) {
+ const byte characterIndex = getCharacterIndex();
+
+ if (_mode > kSetTableValue) {
+ warning("Unknown ResourceUse mode %u, treating it as an addition", _mode);
+ }
+
+ // In the table modes the amount is a table index, not the amount itself.
+ int32 amount = _amount;
+ if (_mode == kAddTableValue || _mode == kSetTableValue) {
+ auto *tableData = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ amount = (tableData && _amount != tableData->getNoIndex()) ? tableData->getValue(_amount) : 0;
+ }
+
+ if (_mode == kSetValue || _mode == kSetTableValue) {
// Set the resource outright.
- NancySceneState.setUIResource(_resourceIndex, _amount);
+ NancySceneState.setUIResource(_resourceIndex, amount, characterIndex);
NancySceneState.setEventFlag(_flag);
_paymentApplied = true;
} else {
// Add the (signed) amount, but never let the resource go negative â
// the original skips the change (e.g. when Nancy can't afford it).
- const int32 result = NancySceneState.getUIResource(_resourceIndex) + _amount;
+ const int32 result = NancySceneState.getUIResource(_resourceIndex, characterIndex) + amount;
_paymentApplied = result >= 0;
if (_paymentApplied) {
- NancySceneState.setUIResource(_resourceIndex, result);
+ NancySceneState.setUIResource(_resourceIndex, result, characterIndex);
NancySceneState.setEventFlag(_flag);
}
}
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index d15c29503b2..434a79991a0 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -507,9 +507,23 @@ protected:
// flag and starts the matching outcome sound.
void applyChange();
+ // The protagonist whose resources this record changes
+ byte getCharacterIndex() const;
+
+ enum ResourceUseMode : byte {
+ kSetValue = 0, // set the resource to _amount
+ kAddValue = 1, // add _amount, if the resource can cover it
+ kAddTableValue = 2, // same, with the amount taken from the table
+ kSetTableValue = 3 // set to the table value at _amount
+ };
+
+ // Nancy15 pays from a named protagonist's resources; kPlayerCharacterActive
+ // (and every earlier game) means whoever is being played.
+ byte _characterIndex = kPlayerCharacterActive;
+
int16 _resourceIndex = 0;
- int16 _amount = 0;
- byte _mode = 0; // 0 = set the resource, non-zero = add (clamped to >= 0)
+ int16 _amount = 0; // an amount, or a table index in the table modes
+ byte _mode = kSetValue;
FlagDescription _flag; // event flag set when the change is applied
Common::String _failSoundName; // played when the change can't be applied
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 29509fd9732..4a185b9f257 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -1079,19 +1079,53 @@ static void seedUIResourceData(UIResourceData *data) {
}
}
-int32 Scene::getUIResource(uint index) {
+// The character being played keeps their resources in `values`; the other
+// protagonists' sets are parked in `characterValues` until they are played.
+// A character who has never been played starts from the UIRC starting values.
+static Common::Array<int32> *characterResourceValues(UIResourceData *data, byte characterIndex) {
+ if (!data) {
+ return nullptr;
+ }
+
+ if (characterIndex == kPlayerCharacterActive || characterIndex == g_nancy->getPlayerCharacter()) {
+ return &data->values;
+ }
+
+ if (characterIndex >= kMaxPlayerCharacters) {
+ warning("UI resource change for unknown player character %u, using the active one", characterIndex);
+ return &data->values;
+ }
+
+ Common::Array<int32> &stored = data->getCharacterValues(characterIndex);
+ if (stored.empty()) {
+ stored.resize(data->values.size(), 0);
+
+ const UIRC *uirc = GetEngineData(UIRC)
+ if (uirc) {
+ for (uint i = 0; i < stored.size() && i < uirc->items.size(); ++i) {
+ stored[i] = uirc->items[i].startingValue;
+ }
+ }
+ }
+
+ return &stored;
+}
+
+int32 Scene::getUIResource(uint index, byte characterIndex) {
UIResourceData *data = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
seedUIResourceData(data);
- if (!data || index >= data->values.size()) {
+ Common::Array<int32> *values = characterResourceValues(data, characterIndex);
+ if (!values || index >= values->size()) {
return 0;
}
- return data->values[index];
+ return (*values)[index];
}
-void Scene::setUIResource(uint index, int32 value) {
+void Scene::setUIResource(uint index, int32 value, byte characterIndex) {
UIResourceData *data = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
seedUIResourceData(data);
- if (!data || index >= data->values.size()) {
+ Common::Array<int32> *values = characterResourceValues(data, characterIndex);
+ if (!values || index >= values->size()) {
return;
}
@@ -1105,7 +1139,7 @@ void Scene::setUIResource(uint index, int32 value) {
}
}
- data->values[index] = MAX<int32>(value, 0);
+ (*values)[index] = MAX<int32>(value, 0);
}
// Nancy 11+ AR 30/31 store the "player scrolling disabled" state in an event
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 63a36a5d02a..44426060bf6 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -192,8 +192,10 @@ public:
// coin purse amount in cents. Backed by the lazily-created, saved
// UIResourceData puzzle chunk, seeded from UIRC on first use and mutated by
// AR 132 (ResourceUse). Non-const because the first access creates/seeds it.
- int32 getUIResource(uint index);
- void setUIResource(uint index, int32 value);
+ // Nancy15 records name the protagonist whose resources they change;
+ // kPlayerCharacterActive (and every earlier game) means whoever is played.
+ int32 getUIResource(uint index, byte characterIndex = kPlayerCharacterActive);
+ void setUIResource(uint index, int32 value, byte characterIndex = kPlayerCharacterActive);
void setLogicCondition(int16 label, byte flag);
bool getLogicCondition(int16 label, byte flag) const;
Commit: 8656e29e7f7b92626bc683a3802ce368c212fecc
https://github.com/scummvm/scummvm/commit/8656e29e7f7b92626bc683a3802ce368c212fecc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:43+03:00
Commit Message:
NANCY: NANCY13: Properly convert 24bpp images to 32bpp
The target surface may already be in 32bpp, in which case the previous
in-place surface conversion would fail. Fixes setting the interface in
Nancy15.
Changed paths:
engines/nancy/resource.cpp
diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index cc06dc9cc08..4a985c28c14 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -174,10 +174,29 @@ bool ResourceManager::loadImage(const Common::Path &name, Graphics::ManagedSurfa
}
#endif
- GraphicsManager::copyToManaged(buf, surf, info.width, info.height, g_nancy->_graphics->getInputPixelFormat(info.depth));
+ if (info.depth == 24 && surf.format.bpp() == 32) {
+ // Nancy13+ uses 32bpp surfaces for 24bpp images, so we need to convert the data
+ // to 32bpp before copying it into the surface
+ uint32 newBufSize = info.width * info.height * 4;
+ byte *newBuf = new byte[newBufSize];
+ for (uint y = 0; y < info.height; ++y) {
+ for (uint x = 0; x < info.width; ++x) {
+ uint32 srcIndex = y * info.pitch + x * 3;
+ uint32 destIndex = y * info.width * 4 + x * 4;
+ newBuf[destIndex + 0] = buf[srcIndex + 0];
+ newBuf[destIndex + 1] = buf[srcIndex + 1];
+ newBuf[destIndex + 2] = buf[srcIndex + 2];
+ newBuf[destIndex + 3] = 0xFF; // alpha channel
+ }
+ }
+ delete[] buf;
+ buf = newBuf;
+ bufSize = newBufSize;
+ info.pitch = info.width * 4;
+ info.depth = 32;
+ }
- if (info.depth == 24)
- surf.convertToInPlace(g_nancy->_graphics->getInputPixelFormat(32));
+ GraphicsManager::copyToManaged(buf, surf, info.width, info.height, g_nancy->_graphics->getInputPixelFormat(info.depth));
delete[] buf;
delete stream;
Commit: 9c2a253e15d6b9f3900f157ef45a0f6ee89c0dba
https://github.com/scummvm/scummvm/commit/9c2a253e15d6b9f3900f157ef45a0f6ee89c0dba
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:46+03:00
Commit Message:
NANCY: NANCY10: Correct mapping for new font glyphs
- Fix the underscore glyph in Nancy10+
- Map the two new font glyphs used in Nancy15
Changed paths:
engines/nancy/font.cpp
engines/nancy/font.h
diff --git a/engines/nancy/font.cpp b/engines/nancy/font.cpp
index 684756e68c1..81fd8594c48 100644
--- a/engines/nancy/font.cpp
+++ b/engines/nancy/font.cpp
@@ -125,6 +125,7 @@ void Font::read(Common::SeekableReadStream &stream) {
if (g_nancy->getGameType() >= kGameTypeNancy10) {
// Nancy10 added even more characters to its fonts
+ _underscoreOffset = stream.readUint16LE();
if (g_nancy->getGameLanguage() == Common::FR_FRA) {
_oeLigatureOffset = stream.readUint16LE();
_iWithDiaeresisOffset = stream.readUint16LE();
@@ -132,7 +133,6 @@ void Font::read(Common::SeekableReadStream &stream) {
_uppercaseAWithDotOffset = stream.readUint16LE();
_aWithDotOffset = stream.readUint16LE();
}
- _underscoreOffset = stream.readUint16LE();
_hashOffset = stream.readUint16LE();
_dollarOffset = stream.readUint16LE();
_lessThanOffset = stream.readUint16LE();
@@ -145,9 +145,9 @@ void Font::read(Common::SeekableReadStream &stream) {
}
if (g_nancy->getGameType() >= kGameTypeNancy15) {
- // Nancy15 added two more characters. Their glyphs are only present in a
- // few of the fonts, and it is not yet known which characters they are
- stream.skip(4);
+ // Nancy15 added two more characters. Their glyphs are only present in a few of the fonts
+ _caretOffset = stream.readUint16LE();
+ _atSignOffset = stream.readUint16LE();
numCharacters = 117;
}
@@ -392,9 +392,15 @@ Common::Rect Font::getCharacterSourceRect(char chr) const {
case '\x80':
offset = _euroOffset;
break;
- // TODO: _uppercaseAWithDotOffset
- // TODO: _aWithDotOffset
- // TODO: _oeLigatureOffset
+ case '\xc5':
+ offset = _uppercaseAWithDotOffset;
+ break;
+ case '\xe5':
+ offset = _aWithDotOffset;
+ break;
+ case '\x9c':
+ offset = _oeLigatureOffset;
+ break;
default:
offset = -1;
break;
@@ -485,6 +491,13 @@ Common::Rect Font::getCharacterSourceRect(char chr) const {
case '}':
offset = _rightCurlyBracketOffset;
break;
+ // ASCII punctuation whose glyphs were added in nancy15
+ case '^':
+ offset = _caretOffset;
+ break;
+ case '@':
+ offset = _atSignOffset;
+ break;
default:
offset = -1;
break;
diff --git a/engines/nancy/font.h b/engines/nancy/font.h
index 26147d95a25..6a76ac4f188 100644
--- a/engines/nancy/font.h
+++ b/engines/nancy/font.h
@@ -140,6 +140,10 @@ private:
int16 _rightCurlyBracketOffset = -1;
int16 _euroOffset = -1;
+ // Two more characters, introduced in nancy15
+ int16 _caretOffset = -1;
+ int16 _atSignOffset = -1;
+
// Specific offsets in French variant of nancy12
int16 _uppercaseCWithCedillaOffset = -1;
int16 _uWithCircumflexOffset = -1;
Commit: ab349cd205dc41ebf3398a306738fe7b0791e04e
https://github.com/scummvm/scummvm/commit/ab349cd205dc41ebf3398a306738fe7b0791e04e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-22T01:02:47+03:00
Commit Message:
NANCY: NANCY15: Implement NecklacePuzzle
This is a rewritten version of BeadPuzzle, and functionally it's quite
similar. It's used in the necklace puzzle, where Nancy has to make an
"Aloha necklace" for Big Mike
Changed paths:
A engines/nancy/action/puzzle/necklacepuzzle.cpp
A engines/nancy/action/puzzle/necklacepuzzle.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 45c6f7fd382..06afed6e8d9 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -70,6 +70,7 @@
#include "engines/nancy/action/puzzle/mirrorlightpuzzle.h"
#include "engines/nancy/action/puzzle/mouselightpuzzle.h"
#include "engines/nancy/action/puzzle/multibuildpuzzle.h"
+#include "engines/nancy/action/puzzle/necklacepuzzle.h"
#include "engines/nancy/action/puzzle/onebuildpuzzle.h"
#include "engines/nancy/action/puzzle/orderingpuzzle.h"
#include "engines/nancy/action/puzzle/overridelockpuzzle.h"
@@ -627,7 +628,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
case 242:
return new MagnetMazePuzzle();
case 243:
- return new BeadPuzzle();
+ if (g_nancy->getGameType() <= kGameTypeNancy14)
+ return new BeadPuzzle();
+ else
+ return new NecklacePuzzle();
case 244:
return new GridMapPuzzle();
// -- Nancy 11 and up --
diff --git a/engines/nancy/action/puzzle/necklacepuzzle.cpp b/engines/nancy/action/puzzle/necklacepuzzle.cpp
new file mode 100644
index 00000000000..194be940a3c
--- /dev/null
+++ b/engines/nancy/action/puzzle/necklacepuzzle.cpp
@@ -0,0 +1,636 @@
+/* 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 "common/util.h"
+
+#include "graphics/font.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/font.h"
+#include "engines/nancy/puzzledata.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/necklacepuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+NecklacePuzzle::~NecklacePuzzle() {
+ refundBeads();
+}
+
+void NecklacePuzzle::readData(Common::SeekableReadStream &stream) {
+ _refundOnExit = stream.readByte();
+ _overlapPlaced = stream.readSint32LE();
+ _overlapFalling = stream.readSint32LE();
+ _dropFlag.label = stream.readSint16LE();
+ _dropFlag.flag = stream.readByte();
+ _heldCursorType = stream.readUint16LE();
+ _hoverCursorType = stream.readUint16LE();
+ _heldOffsetX = stream.readSint32LE();
+ _heldOffsetY = stream.readSint32LE();
+ _countFontID = stream.readUint16LE();
+ _unknownA0 = stream.readUint16LE();
+ _countTextExtraY = stream.readUint16LE();
+ _countTextX = stream.readSint32LE();
+ _countTextY = stream.readSint32LE();
+
+ uint16 numTypes = stream.readUint16LE();
+ _beadTypes.resize(numTypes);
+ for (uint16 i = 0; i < numTypes; ++i) {
+ BeadType &type = _beadTypes[i];
+ type.key = stream.readSint32LE();
+ type.overlaps = stream.readByte();
+
+ Common::Path names[3];
+ for (uint j = 0; j < 3; ++j) {
+ readFilename(stream, names[j]);
+ }
+
+ // The three sheets repeat across kinds, so each distinct one is loaded once.
+ int *slots[3] = { &type.cursorImage, &type.strandImage, &type.boxImage };
+ for (uint j = 0; j < 3; ++j) {
+ uint k = 0;
+ for (; k < _imageNames.size(); ++k) {
+ if (_imageNames[k] == names[j]) {
+ break;
+ }
+ }
+
+ if (k == _imageNames.size()) {
+ _imageNames.push_back(names[j]);
+ }
+
+ *slots[j] = (int)k;
+ }
+ }
+
+ uint16 numBeads = stream.readUint16LE();
+ _beads.resize(numBeads);
+ for (uint16 i = 0; i < numBeads; ++i) {
+ Bead &bead = _beads[i];
+ bead.type = stream.readByte();
+ readRect(stream, bead.cursorSrc);
+ readRect(stream, bead.strandSrc);
+ readRect(stream, bead.boxSrc);
+ readRect(stream, bead.boxDest);
+ bead.tableIndex = stream.readSint16LE();
+ }
+
+ uint16 numStrands = stream.readUint16LE();
+ _strands.resize(numStrands);
+ for (uint16 i = 0; i < numStrands; ++i) {
+ Strand &strand = _strands[i];
+ strand.grabByTopBead = stream.readByte();
+ strand.allowSwap = stream.readByte();
+
+ uint16 numTypesAllowed = stream.readUint16LE();
+ strand.allowedTypes.resize(numTypesAllowed);
+ for (uint16 j = 0; j < numTypesAllowed; ++j) {
+ strand.allowedTypes[j] = stream.readByte();
+ }
+
+ uint16 numSolution = stream.readUint16LE();
+ strand.solution.resize(numSolution);
+ for (uint16 j = 0; j < numSolution; ++j) {
+ strand.solution[j] = stream.readUint16LE();
+ }
+
+ readRect(stream, strand.needleHotspot);
+ readRect(stream, strand.strandRect);
+
+ strand.dropSeconds = stream.readFloatLE();
+ }
+
+ _pickUpSound.readData(stream);
+ _putDownSound.readData(stream);
+ _unknownSound.readData(stream);
+
+ _solvedScene.sceneID = stream.readUint16LE();
+ _solvedScene.frameID = stream.readUint16LE();
+ int16 solvedOffset = stream.readSint16LE();
+ _solvedScene.verticalOffset = solvedOffset >= 0 ? solvedOffset : 0;
+ _solvedScene.continueSceneSound = stream.readByte();
+ _solvedSound.readData(stream);
+
+ _unsolvedScene.sceneID = stream.readUint16LE();
+ _unsolvedScene.frameID = stream.readUint16LE();
+ int16 unsolvedOffset = stream.readSint16LE();
+ _unsolvedScene.verticalOffset = unsolvedOffset >= 0 ? unsolvedOffset : 0;
+ _unsolvedScene.continueSceneSound = stream.readByte();
+ _unsolvedSound.readData(stream);
+
+ readExitHotspot(stream, _exitHotspot, _exitCursorType, _exitScene, _exitFlag);
+}
+
+void NecklacePuzzle::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);
+
+ _images.resize(_imageNames.size());
+ for (uint i = 0; i < _imageNames.size(); ++i) {
+ g_nancy->_resource->loadImage(_imageNames[i], _images[i]);
+ _images[i].setTransparentColor(_drawSurface.getTransparentColor());
+ }
+
+ NancySceneState.setNoHeldItem();
+
+ redraw();
+}
+
+void NecklacePuzzle::registerGraphics() {
+ RenderObject::registerGraphics();
+ _heldBeadObject.registerGraphics();
+}
+
+int NecklacePuzzle::findBeadType(byte key) const {
+ for (uint i = 0; i < _beadTypes.size(); ++i) {
+ if (_beadTypes[i].key == (int32)key) {
+ return (int)i;
+ }
+ }
+
+ return -1;
+}
+
+bool NecklacePuzzle::strandAccepts(const Strand &strand, byte type) const {
+ for (uint i = 0; i < strand.allowedTypes.size(); ++i) {
+ if (strand.allowedTypes[i] == type) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+int NecklacePuzzle::stackTop(const Strand &strand) const {
+ if (strand.placed.empty()) {
+ return strand.strandRect.bottom;
+ }
+
+ return strand.placed.back().rect.top;
+}
+
+int NecklacePuzzle::stackTopBelow(const Strand &strand) const {
+ if (strand.placed.size() < 2) {
+ return strand.strandRect.bottom;
+ }
+
+ return strand.placed[strand.placed.size() - 2].rect.top;
+}
+
+int16 NecklacePuzzle::beadCount(const Bead &bead) const {
+ if (bead.tableIndex == kNoTableIndex) {
+ return 0;
+ }
+
+ TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ if (!table) {
+ return 0;
+ }
+
+ int16 value = table->getValue(bead.tableIndex);
+ return value == kNoTableValue ? 0 : value;
+}
+
+void NecklacePuzzle::adjustBeadCount(const Bead &bead, int16 delta) {
+ if (bead.tableIndex == kNoTableIndex) {
+ return;
+ }
+
+ TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ if (!table) {
+ return;
+ }
+
+ int16 value = table->getValue(bead.tableIndex);
+ if (value == kNoTableValue) {
+ value = 0;
+ }
+
+ table->setValue(bead.tableIndex, value + delta);
+}
+
+NecklacePuzzle::Hover NecklacePuzzle::hitTest(const Common::Point &mousePos) const {
+ Hover hover;
+ const bool holding = _heldBead != kNoBead;
+
+ for (uint i = 0; i < _strands.size(); ++i) {
+ const Strand &strand = _strands[i];
+
+ if (holding && strand.needleHotspot.contains(mousePos)) {
+ const Bead &held = _beads[_heldBead];
+ const int extent = held.strandSrc.height() - _overlapFalling;
+
+ if (strandAccepts(strand, held.type)) {
+ if (stackTop(strand) - extent > strand.strandRect.top) {
+ hover.strand = (int)i;
+ return hover;
+ }
+
+ // The strand is full, but it can take this shell in place of the one
+ // on top, which goes back on the cursor.
+ if (strand.allowSwap && stackTopBelow(strand) - extent > strand.strandRect.top) {
+ hover.strand = (int)i;
+ hover.swapTop = true;
+ return hover;
+ }
+ }
+
+ continue;
+ }
+
+ if (holding || strand.placed.empty()) {
+ continue;
+ }
+
+ // Some strands hand the top shell back when the needle is clicked, the rest
+ // when the shell itself is.
+ const Common::Rect &grabRect = strand.grabByTopBead ? strand.placed.back().rect : strand.needleHotspot;
+ if (grabRect.contains(mousePos)) {
+ hover.strand = (int)i;
+ return hover;
+ }
+ }
+
+ for (uint i = 0; i < _beads.size(); ++i) {
+ if (_beads[i].boxDest.contains(mousePos) && beadCount(_beads[i]) > 0) {
+ hover.bead = (int)i;
+ return hover;
+ }
+ }
+
+ return hover;
+}
+
+void NecklacePuzzle::holdBead(int16 bead, NancyInput *input) {
+ _heldBead = bead;
+
+ if (_heldBead == kNoBead) {
+ _heldBeadObject.setVisible(false);
+ _heldBeadObject.putDown();
+ return;
+ }
+
+ const Bead &held = _beads[_heldBead];
+ const int typeIndex = findBeadType(held.type);
+ const int image = typeIndex >= 0 ? _beadTypes[typeIndex].cursorImage : -1;
+
+ if (image >= 0 && _images[image].getBounds().contains(held.cursorSrc)) {
+ _heldBeadObject._drawSurface.create(_images[image], held.cursorSrc);
+ _heldBeadObject.setTransparent(true);
+ _heldBeadObject.setVisible(true);
+ _heldBeadObject.pickUp();
+ if (input) {
+ _heldBeadObject.handleInput(*input);
+ }
+ } else {
+ _heldBeadObject.setVisible(false);
+ _heldBeadObject.putDown();
+ }
+}
+
+// Takes the shell off the cursor without putting it anywhere.
+void NecklacePuzzle::releaseBead() {
+ playSoundBlock(_putDownSound);
+ holdBead(kNoBead, nullptr);
+}
+
+void NecklacePuzzle::dropBeadOn(int strandIndex) {
+ Strand &strand = _strands[strandIndex];
+ const Bead &held = _beads[_heldBead];
+ const int typeIndex = findBeadType(held.type);
+ const bool overlaps = typeIndex >= 0 && _beadTypes[typeIndex].overlaps;
+
+ // The shell always starts at the top of the strand, centered across it.
+ int centeredWidth = held.strandSrc.width() - (overlaps ? _overlapPlaced : 0);
+ PlacedBead placed;
+ placed.bead = _heldBead;
+ placed.rect.left = strand.strandRect.left + (strand.strandRect.width() - centeredWidth) / 2;
+ placed.rect.top = strand.strandRect.top;
+ placed.rect.right = placed.rect.left + held.strandSrc.width();
+ placed.rect.bottom = placed.rect.top + held.strandSrc.height();
+
+ // Sliding the full length of the strand takes dropSeconds; this one only has to
+ // cover the gap down to whatever it lands on.
+ const int restOn = stackTop(strand);
+ const int fullDrop = strand.strandRect.height();
+ uint32 duration = 0;
+ if (fullDrop > 0) {
+ duration = (uint32)(strand.dropSeconds * (float)(restOn - strand.strandRect.top) * 1000.0f / (float)fullDrop);
+ }
+
+ strand.placed.push_back(placed);
+
+ _fallingStrand = strandIndex;
+ _fallingStartY = placed.rect.top;
+ _fallingEndY = restOn - held.strandSrc.height() + (overlaps ? _overlapFalling : 0);
+ _fallStartTime = g_nancy->getTotalPlayTime();
+ _fallDuration = MAX<uint32>(duration, 1);
+
+ NancySceneState.setEventFlag(_dropFlag);
+}
+
+void NecklacePuzzle::checkSolved() {
+ for (uint i = 0; i < _strands.size(); ++i) {
+ const Strand &strand = _strands[i];
+ if (strand.placed.size() != strand.solution.size()) {
+ return;
+ }
+
+ // The strand is filled bottom-up, so the design reads from the last shell back.
+ for (uint j = 0; j < strand.solution.size(); ++j) {
+ const PlacedBead &placed = strand.placed[strand.placed.size() - 1 - j];
+ if (_beads[placed.bead].tableIndex != (int16)strand.solution[j]) {
+ return;
+ }
+ }
+ }
+
+ _solved = true;
+}
+
+// Everything on the strands, and anything on the cursor, goes back in the box when
+// the puzzle is left unfinished.
+void NecklacePuzzle::refundBeads() {
+ if (!_refundOnExit || _solved || !g_nancy) {
+ return;
+ }
+
+ for (uint i = 0; i < _strands.size(); ++i) {
+ Strand &strand = _strands[i];
+ for (uint j = 0; j < strand.placed.size(); ++j) {
+ adjustBeadCount(_beads[strand.placed[j].bead], 1);
+ }
+ strand.placed.clear();
+ }
+
+ if (_heldBead != kNoBead) {
+ adjustBeadCount(_beads[_heldBead], 1);
+ _heldBead = kNoBead;
+ }
+
+ _fallingStrand = -1;
+}
+
+void NecklacePuzzle::playSoundBlock(const RandomSoundBlock &block) {
+ if (block.names.empty()) {
+ return;
+ }
+
+ uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+ const Common::String &name = block.names[idx];
+ if (name.empty() || name == "NO SOUND") {
+ return;
+ }
+
+ SoundDescription desc;
+ desc.name = name;
+ desc.channelID = block.channel;
+ desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+ desc.volume = block.volume;
+
+ g_nancy->_sound->loadSound(desc);
+ g_nancy->_sound->playSound(desc);
+}
+
+void NecklacePuzzle::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+ registerGraphics();
+ _state = kRun;
+ // fall through
+
+ case kRun:
+ if (_exitRequested || _solved) {
+ _state = kActionTrigger;
+ break;
+ }
+
+ if (_fallingStrand >= 0) {
+ Strand &strand = _strands[_fallingStrand];
+ PlacedBead &falling = strand.placed.back();
+
+ uint32 elapsed = g_nancy->getTotalPlayTime() - _fallStartTime;
+ int y = _fallingEndY;
+ if (elapsed < _fallDuration) {
+ y = _fallingStartY + (int)((int64)(_fallingEndY - _fallingStartY) * elapsed / _fallDuration);
+ }
+
+ if (y != falling.rect.top) {
+ falling.rect.moveTo(falling.rect.left, y);
+ redraw();
+ }
+
+ if (elapsed >= _fallDuration) {
+ _fallingStrand = -1;
+ checkSolved();
+ redraw();
+ }
+ }
+
+ break;
+
+ case kActionTrigger:
+ refundBeads();
+
+ if (_solved) {
+ playSoundBlock(_solvedSound);
+ if (_solvedScene.sceneID != kNoScene) {
+ NancySceneState.changeScene(_solvedScene);
+ }
+ } else {
+ playSoundBlock(_unsolvedSound);
+ NancySceneState.setEventFlag(_exitFlag);
+ NancySceneState.changeScene(_unsolvedScene.sceneID != kNoScene ? _unsolvedScene : _exitScene);
+ }
+
+ finishExecution();
+ break;
+ }
+}
+
+void NecklacePuzzle::handleInput(NancyInput &input) {
+ if (_state != kRun || _solved || _exitRequested) {
+ return;
+ }
+
+ _heldBeadObject.handleInput(input);
+
+ // Nothing can be clicked while a shell is still sliding down a strand.
+ if (_fallingStrand >= 0) {
+ return;
+ }
+
+ Common::Rect vpScreen = NancySceneState.getViewport().getScreenPosition();
+ Common::Point mouseVP = input.mousePos - Common::Point(vpScreen.left, vpScreen.top);
+ const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+
+ Hover hover = hitTest(mouseVP);
+
+ if (_heldBead != kNoBead) {
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)_heldCursorType, true,
+ hover.strand >= 0 || hover.bead >= 0);
+ } else if (hover.strand >= 0 || hover.bead >= 0) {
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true);
+ }
+
+ if (click && hover.strand >= 0) {
+ Strand &strand = _strands[hover.strand];
+
+ if (_heldBead != kNoBead) {
+ // A swap takes the shell that was on top and hands it straight back.
+ int16 displaced = kNoBead;
+ if (hover.swapTop) {
+ displaced = strand.placed.back().bead;
+ strand.placed.pop_back();
+ }
+
+ dropBeadOn(hover.strand);
+ releaseBead();
+
+ if (displaced != kNoBead) {
+ holdBead(displaced, &input);
+ }
+ } else {
+ // Take the top shell back off the strand.
+ int16 bead = strand.placed.back().bead;
+ strand.placed.pop_back();
+ holdBead(bead, &input);
+ }
+
+ redraw();
+ input.eatMouseInput();
+ return;
+ }
+
+ if (click && hover.bead >= 0) {
+ const int16 wasHeld = _heldBead;
+
+ if (wasHeld != kNoBead) {
+ adjustBeadCount(_beads[wasHeld], 1);
+ releaseBead();
+ }
+
+ // Clicking the shell already on the cursor just puts it back.
+ if (wasHeld != hover.bead) {
+ adjustBeadCount(_beads[hover.bead], -1);
+ holdBead((int16)hover.bead, &input);
+ playSoundBlock(_pickUpSound);
+ }
+
+ redraw();
+ input.eatMouseInput();
+ return;
+ }
+
+ if (click && _heldBead != kNoBead) {
+ adjustBeadCount(_beads[_heldBead], 1);
+ releaseBead();
+ redraw();
+ input.eatMouseInput();
+ return;
+ }
+
+ if (!_exitHotspot.isEmpty() && _exitHotspot.contains(mouseVP)) {
+ g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, false);
+ if (click) {
+ _exitRequested = true;
+ }
+ }
+}
+
+void NecklacePuzzle::redraw() {
+ _drawSurface.clear(_drawSurface.getTransparentColor());
+
+ for (uint i = 0; i < _beads.size(); ++i) {
+ const Bead &bead = _beads[i];
+ const int typeIndex = findBeadType(bead.type);
+ if (typeIndex < 0) {
+ continue;
+ }
+
+ const int image = _beadTypes[typeIndex].boxImage;
+ if (image >= 0 && !bead.boxSrc.isEmpty() && _images[image].getBounds().contains(bead.boxSrc)) {
+ _drawSurface.blitFrom(_images[image], bead.boxSrc, Common::Point(bead.boxDest.left, bead.boxDest.top));
+ }
+
+ drawBeadCount(bead);
+ }
+
+ for (uint i = 0; i < _strands.size(); ++i) {
+ const Strand &strand = _strands[i];
+ for (uint j = 0; j < strand.placed.size(); ++j) {
+ const PlacedBead &placed = strand.placed[j];
+ const Bead &bead = _beads[placed.bead];
+ const int typeIndex = findBeadType(bead.type);
+ if (typeIndex < 0) {
+ continue;
+ }
+
+ const int image = _beadTypes[typeIndex].strandImage;
+ if (image >= 0 && !bead.strandSrc.isEmpty() && _images[image].getBounds().contains(bead.strandSrc)) {
+ _drawSurface.blitFrom(_images[image], bead.strandSrc, Common::Point(placed.rect.left, placed.rect.top));
+ }
+ }
+ }
+
+ _needsRedraw = true;
+}
+
+// How many of this shell are still in the box, drawn next to its box slot.
+void NecklacePuzzle::drawBeadCount(const Bead &bead) {
+ if (bead.tableIndex == kNoTableIndex) {
+ return;
+ }
+
+ const Graphics::Font *font = g_nancy->_graphics->getFont(_countFontID);
+ if (!font) {
+ font = g_nancy->_graphics->getFont(0);
+ }
+ if (!font) {
+ return;
+ }
+
+ Common::String str = Common::String::format("%d", beadCount(bead));
+ int w = font->getStringWidth(str);
+ if (w <= 0) {
+ return;
+ }
+
+ font->drawString(&_drawSurface, str, bead.boxDest.left + _countTextX,
+ bead.boxDest.top + _countTextY + _countTextExtraY, w, 0);
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/necklacepuzzle.h b/engines/nancy/action/puzzle/necklacepuzzle.h
new file mode 100644
index 00000000000..8bbc6a85da8
--- /dev/null
+++ b/engines/nancy/action/puzzle/necklacepuzzle.h
@@ -0,0 +1,183 @@
+/* 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_NECKLACEPUZZLE_H
+#define NANCY_ACTION_NECKLACEPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+#include "engines/nancy/misc/mousefollow.h"
+
+namespace Nancy {
+namespace Action {
+
+// Nancy15 rewrote AR 243 into a shell-stringing puzzle: a box of shells on one
+// side, and a set of strands hanging from needles on the other. A shell is taken
+// from the box onto the cursor and dropped on a needle, where it slides down the
+// strand and stacks on the ones already there. Every strand has to end up holding
+// the exact sequence the design calls for.
+//
+// How many of each shell are left is not kept in the record; it lives in the
+// shared table values, so the box counts survive leaving and re-entering. Shells
+// left on a strand are handed back when the puzzle is abandoned unsolved.
+class NecklacePuzzle : public RenderActionRecord {
+public:
+ NecklacePuzzle() : RenderActionRecord(7) {}
+ virtual ~NecklacePuzzle();
+
+ void init() override;
+ void registerGraphics() 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 "NecklacePuzzle"; }
+
+ static const int16 kNoBead = -1;
+ static const int16 kNoTableIndex = 255;
+
+ // A kind of shell. All shells of a kind share their three sprite sheets: one
+ // for the cursor, one for the strand, one for the box.
+ struct BeadType {
+ int32 key = 0;
+ bool overlaps = false; // this kind sits into the one below it
+ int cursorImage = -1; // indices into _images
+ int strandImage = -1;
+ int boxImage = -1;
+ };
+
+ struct Bead {
+ byte type = 0; // BeadType::key
+ Common::Rect cursorSrc;
+ Common::Rect strandSrc;
+ Common::Rect boxSrc;
+ Common::Rect boxDest; // also the hotspot for taking one out of the box
+ int16 tableIndex = kNoTableIndex; // table value holding how many are left
+ };
+
+ struct PlacedBead {
+ int16 bead = kNoBead;
+ Common::Rect rect;
+ };
+
+ struct Strand {
+ bool grabByTopBead = false; // take the top shell back by clicking it, not the needle
+ bool allowSwap = false; // a full strand lets its top shell be replaced
+ Common::Array<byte> allowedTypes;
+ Common::Array<uint16> solution; // required table indices, top of the strand first
+ Common::Rect needleHotspot;
+ Common::Rect strandRect;
+ float dropSeconds = 0.0f; // time a shell takes to slide the strand's full length
+
+ Common::Array<PlacedBead> placed;
+ };
+
+ // What the cursor is over. Only one of the two is ever set.
+ struct Hover {
+ int strand = -1;
+ int bead = -1;
+ bool swapTop = false; // dropping here replaces the strand's top shell
+ };
+
+ int findBeadType(byte key) const;
+ bool strandAccepts(const Strand &strand, byte type) const;
+
+ // Y the next shell dropped on `strand` comes to rest against, and the same one
+ // level further down (used to decide whether a full strand can still swap).
+ int stackTop(const Strand &strand) const;
+ int stackTopBelow(const Strand &strand) const;
+
+ Hover hitTest(const Common::Point &mousePos) const;
+
+ int16 beadCount(const Bead &bead) const;
+ void adjustBeadCount(const Bead &bead, int16 delta);
+
+ void holdBead(int16 bead, NancyInput *input);
+ void releaseBead();
+ void dropBeadOn(int strand);
+ void checkSolved();
+ void refundBeads();
+
+ void playSoundBlock(const RandomSoundBlock &block);
+ void redraw();
+ void drawBeadCount(const Bead &bead);
+
+ // -- File data --
+ bool _refundOnExit = false;
+ int32 _overlapPlaced = 0; // taken off an overlapping shell's width on a strand
+ int32 _overlapFalling = 0; // how far an overlapping shell sinks into the one below
+ FlagDescription _dropFlag; // set every time a shell is strung
+ uint16 _heldCursorType = 0;
+ uint16 _hoverCursorType = 0;
+ int32 _heldOffsetX = 0; // where the held shell sits relative to the cursor
+ int32 _heldOffsetY = 0;
+ uint16 _countFontID = 0;
+ uint16 _unknownA0 = 0;
+ uint16 _countTextExtraY = 0;
+ int32 _countTextX = 0; // offset from the box rect's top left
+ int32 _countTextY = 0;
+
+ Common::Array<Common::Path> _imageNames;
+ Common::Array<BeadType> _beadTypes;
+ Common::Array<Bead> _beads;
+ Common::Array<Strand> _strands;
+
+ RandomSoundBlock _pickUpSound;
+ RandomSoundBlock _putDownSound;
+ RandomSoundBlock _unknownSound; // no known trigger; "NO SOUND" in the shipped record
+
+ SceneChangeDescription _solvedScene;
+ RandomSoundBlock _solvedSound;
+
+ // Played when the puzzle is left unfinished. Its scene is 9999 in the shipped
+ // record, so the exit hotspot's own scene is what the player ends up in.
+ SceneChangeDescription _unsolvedScene;
+ RandomSoundBlock _unsolvedSound;
+
+ Common::Rect _exitHotspot;
+ uint16 _exitCursorType = 0;
+ SceneChangeDescription _exitScene;
+ FlagDescription _exitFlag;
+
+ // -- Runtime state --
+ Common::Array<Graphics::ManagedSurface> _images;
+
+ int16 _heldBead = kNoBead;
+ Misc::MouseFollowObject _heldBeadObject;
+
+ int _fallingStrand = -1;
+ int _fallingStartY = 0;
+ int _fallingEndY = 0;
+ uint32 _fallStartTime = 0;
+ uint32 _fallDuration = 0;
+
+ bool _solved = false;
+ bool _exitRequested = false;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_NECKLACEPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 19b49196c07..26302ea8d74 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -53,6 +53,7 @@ MODULE_OBJS = \
action/puzzle/mirrorlightpuzzle.o \
action/puzzle/mouselightpuzzle.o \
action/puzzle/multibuildpuzzle.o \
+ action/puzzle/necklacepuzzle.o \
action/puzzle/onebuildpuzzle.o \
action/puzzle/orderingpuzzle.o \
action/puzzle/overridelockpuzzle.o \
More information about the Scummvm-git-logs
mailing list