[Scummvm-git-logs] scummvm master -> 9d2afa8740be6f2dd65ff46ce1b7f5feaaf8ea47
bluegr
noreply at scummvm.org
Tue Sep 8 00:00:58 UTC 2026
This automated email contains information about 17 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .
Summary:
bcb298b269 NANCY: NANCY14: Implement differences for Nancy's purse
369995eb81 NANCY: NANCY14: Implement handling of concatenated conversation texts
7126d43b21 NANCY: NANCY12: Handle no art scenes in secondary movies
b8649d3dcc NANCY: Add save game version history
4b80161264 NANCY: NANCY15: Rebuild text box and camera when changing characters
1d3e67bb3e NANCY: NANCY15: More work on the LDSN chunk
923d9bddba NANCY: NANCY14: Implement new interactive video logic and format
ab8b6978a7 NANCY: Check the sound channel ID before use
eef0c7e6ff NANCY: NANCY15: Allow changing the priority of a loaded CIF tree
db8ae0e841 NANCY: NANCY15: Initial work on switching characters
13c74d3483 NANCY: NANCY12 - NANCY14: Implement new dependency types
e27243554a NANCY: NANCY14: Implement BlockingPuzzle - the final boss fight puzzle
28255652de NANCY: NANCY15: Initial implementation of character swapping (PlayChar)
fe453064ff NANCY: NANCY15: Implement new eventFlags handling semantics
c4560640f4 NANCY: NANCY15: Give each player character their own data
7f23d9c99a NANCY: NANCY15: Implement new player character AR dependency
9d2afa8740 NANCY: NANCY15: Add new button for the Design Select screen
Commit: bcb298b269b6596fc8f1ea7e33c66ed7602a2ad2
https://github.com/scummvm/scummvm/commit/bcb298b269b6596fc8f1ea7e33c66ed7602a2ad2
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:04+03:00
Commit Message:
NANCY: NANCY14: Implement differences for Nancy's purse
Use Nancy's purse, instead of a cellphone in Nancy14, and implement
differences from the purse in Nancy12
Changed paths:
engines/nancy/action/miscrecords.cpp
engines/nancy/action/puzzle/drivingpuzzle.cpp
engines/nancy/enginedata.cpp
engines/nancy/enginedata.h
engines/nancy/font.cpp
engines/nancy/state/scene.cpp
engines/nancy/ui/taskbar.cpp
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index 7dbe8e1f24f..c04255785d8 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -971,13 +971,11 @@ void ResourceUse::init() {
}
if (haveItem && _drawResourceValue) {
- // The value is rendered with a '$' prefix and `unknown2` decimal places
- // (Old Clock tracks cents), using the font selected by `unknown1`.
const UIRC::ItemRecord &item = uirc->items[_resourceIndex];
- const Font *font = g_nancy->_graphics->getFont(item.unknown1);
- if (font && item.unknown2 > 0) {
- const int32 value = NancySceneState.getUIResource(_resourceIndex);
- const Common::String text = Common::String::format("$%d.%02d", value / 100, value % 100);
+ const Font *font = g_nancy->_graphics->getFont(item.fontID);
+ if (font && item.numDecimals >= 0) {
+ const Common::String text =
+ formatUIResourceValue(item, NancySceneState.getUIResource(_resourceIndex));
font->drawString(&_drawSurface, text, _valueDest.x, _valueDest.y, screenBounds.width() - _valueDest.x, 0);
}
}
diff --git a/engines/nancy/action/puzzle/drivingpuzzle.cpp b/engines/nancy/action/puzzle/drivingpuzzle.cpp
index 1384d880758..0716f7ca986 100644
--- a/engines/nancy/action/puzzle/drivingpuzzle.cpp
+++ b/engines/nancy/action/puzzle/drivingpuzzle.cpp
@@ -394,7 +394,7 @@ void DrivingPuzzle::saveState() const {
void DrivingPuzzle::refillFuel() {
const UIRC *uirc = GetEngineData(UIRC)
if (uirc && _frictionIndex >= 0 && (uint)_frictionIndex < uirc->items.size()) {
- NancySceneState.setUIResource(_frictionIndex, uirc->items[_frictionIndex].id);
+ NancySceneState.setUIResource(_frictionIndex, uirc->items[_frictionIndex].startingValue);
_fuelBurnAccum = 0.0;
}
}
@@ -403,7 +403,7 @@ void DrivingPuzzle::repairTire() {
_tireDamage = 0;
const UIRC *uirc = GetEngineData(UIRC)
if (uirc && kTireResourceIndex < uirc->items.size()) {
- NancySceneState.setUIResource(kTireResourceIndex, uirc->items[kTireResourceIndex].id);
+ NancySceneState.setUIResource(kTireResourceIndex, uirc->items[kTireResourceIndex].startingValue);
}
}
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 427739acade..3ebdeefb695 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1320,13 +1320,20 @@ EVNT::EVNT(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
}
UIRC::UIRC(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
- while (chunkStream->size() - chunkStream->pos() >= (int64)kItemRecordSize) {
+ // Nancy 14 added the maximum value field, growing each record by 2 bytes
+ const bool hasMaxValue = g_nancy->getGameType() >= kGameTypeNancy14;
+ const uint recordSize = hasMaxValue ? 259 : 257;
+
+ while (chunkStream->size() - chunkStream->pos() >= (int64)recordSize) {
ItemRecord rec;
- rec.id = chunkStream->readUint16LE();
+ rec.startingValue = chunkStream->readUint16LE();
+ if (hasMaxValue) {
+ rec.maxValue = chunkStream->readUint16LE();
+ }
readFilename(*chunkStream, rec.overlayName);
readRect(*chunkStream, rec.rect);
- rec.unknown1 = chunkStream->readSint16LE();
- rec.unknown2 = chunkStream->readSint16LE();
+ rec.fontID = chunkStream->readSint16LE();
+ rec.numDecimals = chunkStream->readSint16LE();
rec.soundChannel = chunkStream->readSint16LE();
rec.soundVolume = chunkStream->readSint16LE();
for (uint i = 0; i < kNumSounds; ++i) {
@@ -1336,6 +1343,29 @@ UIRC::UIRC(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
}
}
+Common::String formatUIResourceValue(const UIRC::ItemRecord &item, int32 value) {
+ // Nancy 12 counts cents and shows a dollar amount, Nancy 14 counts whole
+ // euros. 0x80 is the euro sign in the games' extended ASCII character set.
+ const char currencySymbol = g_nancy->getGameType() >= kGameTypeNancy14 ? '\x80' : '$';
+
+ int32 divisor = 1;
+ for (int16 i = 0; i < item.numDecimals; ++i) {
+ divisor *= 10;
+ }
+
+ Common::String ret = Common::String::format("%c%d", currencySymbol, value / divisor);
+
+ if (item.numDecimals > 0) {
+ Common::String decimals = Common::String::format("%d", value % divisor);
+ while ((int16)decimals.size() < item.numDecimals) {
+ decimals = "0" + decimals;
+ }
+ ret += "." + decimals;
+ }
+
+ return ret;
+}
+
MMIX::MMIX(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
const uint16 count = chunkStream->readUint16LE();
records.resize(count);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 6d1b1c5f2a6..28b2d24646c 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -841,16 +841,18 @@ struct EVNT : public EngineData {
};
// UI overlay element table. Introduced in Nancy 12. Each record describes one UI
-// element: the shared overlay image it belongs to, its on-screen rect and up to
-// six associated sound cues. Unused slots use the name "NO_UI_ITEM", and slots
-// without a given sound use "NO SOUND".
+// element: its starting value, the shared overlay image it belongs to, its
+// on-screen rect and up to six associated sound cues. Unused slots use the name
+// "NO_UI_ITEM", and slots without a given sound use "NO SOUND".
struct UIRC : public EngineData {
struct ItemRecord {
- uint16 id = 0;
+ uint16 startingValue = 0;
+ // Nancy 14 added an upper bound: a value that goes above it is reset to 0
+ uint16 maxValue = 0;
Common::Path overlayName;
Common::Rect rect;
- int16 unknown1 = 0;
- int16 unknown2 = 0;
+ int16 fontID = 0;
+ int16 numDecimals = 0;
int16 soundChannel = 0;
int16 soundVolume = 0;
Common::String soundNames[6];
@@ -859,11 +861,14 @@ struct UIRC : public EngineData {
UIRC(Common::SeekableReadStream *chunkStream);
static const uint kNumSounds = 6;
- static const uint kItemRecordSize = 257;
Common::Array<ItemRecord> items;
};
+// Renders a UI resource's value the way the games' UI does: a currency symbol
+// followed by the value, split into whole units and decimals as the record asks.
+Common::String formatUIResourceValue(const UIRC::ItemRecord &item, int32 value);
+
// Music mix table. Introduced in Nancy 13. Each record maps a short location
// code (e.g. "BRI", "CAM", "TUT") to the set of music / ambience tracks that
// may play there ("TacitA"/"TacitB" are the silence variants).
diff --git a/engines/nancy/font.cpp b/engines/nancy/font.cpp
index 6617f3c946f..ab93bbfb524 100644
--- a/engines/nancy/font.cpp
+++ b/engines/nancy/font.cpp
@@ -387,9 +387,11 @@ Common::Rect Font::getCharacterSourceRect(char chr) const {
case '\xef':
offset = _iWithDiaeresisOffset;
break;
+ case '\x80':
+ offset = _euroOffset;
+ break;
// TODO: _uppercaseAWithDotOffset
// TODO: _aWithDotOffset
- // TODO: _euroOffset
// TODO: _oeLigatureOffset
default:
offset = -1;
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index c882ce422dd..ed2145f0fde 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -715,9 +715,8 @@ bool Scene::getEventFlag(FlagDescription eventFlag) const {
return getEventFlag(eventFlag.label, eventFlag.flag);
}
-// On first use, seed each resource value from the UIRC boot chunk (record id =
-// initial value). After a save is loaded `seeded` is already true, so the
-// restored values are kept.
+// On first use, seed each resource value from the UIRC boot chunk. After a save
+// is loaded `seeded` is already true, so the restored values are kept.
static void seedUIResourceData(UIResourceData *data) {
if (!data || data->seeded) {
return;
@@ -729,7 +728,7 @@ static void seedUIResourceData(UIResourceData *data) {
if (uirc) {
data->values.resize(uirc->items.size());
for (uint i = 0; i < uirc->items.size(); ++i) {
- data->values[i] = uirc->items[i].id;
+ data->values[i] = uirc->items[i].startingValue;
}
}
}
@@ -746,9 +745,21 @@ int32 Scene::getUIResource(uint index) {
void Scene::setUIResource(uint index, int32 value) {
UIResourceData *data = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
seedUIResourceData(data);
- if (data && index < data->values.size()) {
- data->values[index] = value;
+ if (!data || index >= data->values.size()) {
+ return;
+ }
+
+ // Nancy 14 added a per-resource maximum. It guards the fixed-width display
+ // rather than capping the resource: a value above it empties the resource
+ // outright instead of being clamped to it.
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ const UIRC *uirc = GetEngineData(UIRC)
+ if (uirc && index < uirc->items.size() && value > (int32)uirc->items[index].maxValue) {
+ value = 0;
+ }
}
+
+ data->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/ui/taskbar.cpp b/engines/nancy/ui/taskbar.cpp
index 3fcd0a7a585..bb51bbf7a6b 100644
--- a/engines/nancy/ui/taskbar.cpp
+++ b/engines/nancy/ui/taskbar.cpp
@@ -158,7 +158,8 @@ bool Taskbar::isButtonActive(uint index) const {
}
bool Taskbar::isMoneyDisplay(uint index) const {
- return g_nancy->getGameType() == kGameTypeNancy12 && index == kTaskButtonCoinPurse;
+ const GameType gameType = g_nancy->getGameType();
+ return (gameType == kGameTypeNancy12 || gameType == kGameTypeNancy14) && index == kTaskButtonCoinPurse;
}
void Taskbar::drawMoney() {
@@ -168,20 +169,16 @@ void Taskbar::drawMoney() {
return;
}
- // The coin purse displays UI resource 0: its current value rendered with a
- // '$' prefix and `unknown2` decimal places. Old Clock tracks cents
- // (decimals 2), so a value of 350 shows as "$3.50". `unknown1` selects the
- // font. The live value lives in the scene state (seeded from UIRC, changed
- // by AR 132); UIRC only supplies the formatting config.
+ // The coin purse displays UI resource 0: its current value, rendered in the
+ // font the record selects. The live value lives in the scene state (seeded
+ // from UIRC, changed by AR 132); UIRC only supplies the formatting config.
const UIRC::ItemRecord &res = uirc->items[0];
- if (res.unknown2 < 1) {
+ if (res.numDecimals < 0) {
return;
}
- const int32 value = NancySceneState.getUIResource(0);
- const Common::String text =
- Common::String::format("$%d.%02d", value / 100, value % 100);
+ const Common::String text = formatUIResourceValue(res, NancySceneState.getUIResource(0));
- const Font *font = g_nancy->_graphics->getFont(res.unknown1);
+ const Font *font = g_nancy->_graphics->getFont(res.fontID);
if (!font) {
return;
}
@@ -193,8 +190,9 @@ void Taskbar::drawMoney() {
// center. That vertical coordinate is the bottom row the glyphs are aligned
// on, while drawString() takes the top of the line, so shift it up by the
// height of a line.
- const int x = dst.left + 12;
- const int y = dst.top + dst.height() / 2 + 10 - font->getFontHeight() + 1;
+ const bool isNancy14 = g_nancy->getGameType() == kGameTypeNancy14;
+ const int x = dst.left + (isNancy14 ? 15 : 12);
+ const int y = dst.top + dst.height() / 2 + (isNancy14 ? 8 : 10) - font->getFontHeight() + 1;
font->drawString(&_drawSurface, text, x, y, dst.right - x, 0, Graphics::kTextAlignLeft);
_needsRedraw = true;
}
@@ -462,8 +460,8 @@ void Taskbar::handleInput(NancyInput &input) {
g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
- // The Nancy12 coin purse shows Nancy's money on hover but isn't clickable, so
- // it skips the press/click handling below.
+ // The coin purse shows Nancy's money on hover but isn't clickable, so it
+ // skips the press/click handling below.
if (isMoneyDisplay(newHovered)) {
return;
}
Commit: 369995eb819fd6ec6c8485704ede66adaaf1b9a8
https://github.com/scummvm/scummvm/commit/369995eb819fd6ec6c8485704ede66adaaf1b9a8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:09+03:00
Commit Message:
NANCY: NANCY14: Implement handling of concatenated conversation texts
These are marked with the sound name. A sound name of "CONCAT" means
the line is split across several sound files, which follow after their
count.
Fixes placing the random note inside the Dodo box
Changed paths:
engines/nancy/action/conversation.cpp
engines/nancy/action/conversation.h
diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index 378ea2b384e..8e238e99177 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -177,6 +177,20 @@ void ConversationSound::readDataNancy13(Common::SeekableReadStream &stream) {
_sound.channelID = 12; // hardcoded, as in the terse variants
_sound.numLoops = 1;
+ // A sound name of "CONCAT" (Nancy14+) means the line is split across several
+ // sound files, which follow after their count. The original allows 5 at most.
+ if (_sound.name.equalsIgnoreCase("CONCAT")) {
+ const uint16 numSounds = stream.readUint16LE();
+ _concatSounds.resize(numSounds);
+ for (uint i = 0; i < numSounds; ++i) {
+ readFilename(stream, _concatSounds[i]);
+ }
+
+ if (numSounds) {
+ _sound.name = _concatSounds[0];
+ }
+ }
+
readCelDataNancy13(stream);
_conditionalResponseCharacterID = stream.readByte();
@@ -187,9 +201,17 @@ void ConversationSound::readDataNancy13(Common::SeekableReadStream &stream) {
_sceneChange.continueSceneSound = kContinueSceneSound;
// Caption and response texts are external, keyed by sound name in CONVO.
+ // Each part of a concatenated line has its own caption; they make up one
+ // exchange, so they are shown together.
const CVTX *convo = (const CVTX *)g_nancy->getEngineData("CONVO");
assert(convo);
- _text = convo->texts.getValOrDefault(_sound.name, "");
+ if (_concatSounds.empty()) {
+ _text = convo->texts.getValOrDefault(_sound.name, "");
+ } else {
+ for (uint i = 0; i < _concatSounds.size(); ++i) {
+ _text += convo->texts.getValOrDefault(_concatSounds[i], "");
+ }
+ }
uint16 numResponses = stream.readUint16LE();
_responses.resize(numResponses);
@@ -269,6 +291,12 @@ void ConversationSound::execute() {
switch (_state) {
case kBegin: {
init();
+
+ _curConcatSound = 0;
+ if (!_concatSounds.empty()) {
+ _sound.name = _concatSounds[0];
+ }
+
g_nancy->_sound->loadSound(_sound);
if (!ConfMan.getBool("speech_mute") && ConfMan.getBool("character_speech")) {
@@ -393,6 +421,21 @@ void ConversationSound::execute() {
}
if (!g_nancy->_sound->isSoundPlaying(_sound) && (_isSkipped || isVideoDonePlaying())) {
+ // The parts of a concatenated line play back to back, so start the
+ // next one instead of ending the line. Skipping cuts the whole line,
+ // not just the part that happens to be playing.
+ if (!_isSkipped && _curConcatSound + 1 < _concatSounds.size()) {
+ ++_curConcatSound;
+ _sound.name = _concatSounds[_curConcatSound];
+ g_nancy->_sound->loadSound(_sound);
+
+ if (!ConfMan.getBool("speech_mute") && ConfMan.getBool("character_speech")) {
+ g_nancy->_sound->playSound(_sound);
+ }
+
+ break;
+ }
+
g_nancy->_sound->stopSound(_sound);
bool hasResponses = false;
diff --git a/engines/nancy/action/conversation.h b/engines/nancy/action/conversation.h
index 4dc3c4835b3..6e6f023ad0c 100644
--- a/engines/nancy/action/conversation.h
+++ b/engines/nancy/action/conversation.h
@@ -129,6 +129,11 @@ protected:
SoundDescription _sound;
SoundDescription _responseGenericSound;
+ // Nancy14 added concatenated lines: several sound files that play back to
+ // back as a single line of dialogue. Empty for an ordinary single-sound line.
+ Common::Array<Common::String> _concatSounds;
+ uint _curConcatSound = 0;
+
byte _conditionalResponseCharacterID;
byte _goodbyeResponseCharacterID;
byte _defaultNextScene = kDefaultNextSceneEnabled;
Commit: 7126d43b217d6cf47d2f2f3dfc0ec0e866091d21
https://github.com/scummvm/scummvm/commit/7126d43b217d6cf47d2f2f3dfc0ec0e866091d21
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:12+03:00
Commit Message:
NANCY: NANCY12: Handle no art scenes in secondary movies
Fixes character animations that are played in one scene, and their
reply options are in another videoless scene. Examples are Buddy Taffy
in Nancy12 and Minette in Nancy14.
Changed paths:
engines/nancy/action/secondarymovie.cpp
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index 3ff6f8e64a3..d37af7a41d9 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -58,12 +58,18 @@ PlaySecondaryMovie::~PlaySecondaryMovie() {
}
bool PlaySecondaryMovie::survivesSceneChange(bool nextSceneIsNoArt) const {
+ // A NO_ART_SCENE keeps every movie on screen: conversations that play the
+ // character's animation in one scene and show their reply options in a
+ // videoless one rely on it, so the character stays put instead of vanishing
+ // as the options come up.
+ if (nextSceneIsNoArt) {
+ return true;
+ }
+
// Nancy11's random movies can be ambient loops that intentionally keep
// playing across scene changes. Nancy13's per-character reaction movies
// (AR 42) are scene-local: they must stop when their scene is left, and are
- // reloaded if it's re-entered. A plain (non-random) cinematic movie is
- // self-contained and does not persist, not even into a NO_ART_SCENE â so the
- // NO_ART flag is deliberately ignored here.
+ // reloaded if it's re-entered.
return isRandom() && g_nancy->getGameType() < kGameTypeNancy13 && !_isDone && !_randomStopRequested;
}
Commit: b8649d3dcc88793a05b33162fba9e867a2d765e8
https://github.com/scummvm/scummvm/commit/b8649d3dcc88793a05b33162fba9e867a2d765e8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:14+03:00
Commit Message:
NANCY: Add save game version history
Kept for reference regarding the changes made in each version
Changed paths:
engines/nancy/nancy.h
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index ef230572c17..c53f07b93ab 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -54,6 +54,16 @@ class Serializer;
*/
namespace Nancy {
+// Save game version history:
+// - 1: Initial version
+// - 2: Conditional dialogue and hints moved to nancy.dat
+// - 3: Puzzle data stored as lazily initialized PuzzleData objects
+// - 4: Journal entries sync their scene ID (Nancy9+)
+// - 5: Nancy10 taskbar notification badges persisted
+// - 6: Nancy12 timers reworked
+// - 7: Nancy10 unnamed notebook task event flags added
+// - 8: Nancy12 DrivingPuzzle fuel state persisted
+// - 9: RippedLetterPuzzle stores its scene ID and tried flag
static const int kSavegameVersion = 9;
struct NancyGameDescription;
Commit: 4b801612641a5daf36694af181d7d15a97cdd085
https://github.com/scummvm/scummvm/commit/4b801612641a5daf36694af181d7d15a97cdd085
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:18+03:00
Commit Message:
NANCY: NANCY15: Rebuild text box and camera when changing characters
Changed paths:
engines/nancy/ui/camera.cpp
engines/nancy/ui/textbox.cpp
diff --git a/engines/nancy/ui/camera.cpp b/engines/nancy/ui/camera.cpp
index 841a4cc3877..3f206258e3a 100644
--- a/engines/nancy/ui/camera.cpp
+++ b/engines/nancy/ui/camera.cpp
@@ -43,6 +43,10 @@ void Camera::init() {
_cameraData = GetEngineData(UICM);
assert(_cameraData);
+ // Drop the cached viewfinder art, so a re-init after a Nancy15 player
+ // character switch picks up the incoming character's version of it
+ _image.free();
+
setTransparent(true);
setVisible(false);
}
diff --git a/engines/nancy/ui/textbox.cpp b/engines/nancy/ui/textbox.cpp
index 0fc6a8a1bb0..0ef3a200371 100644
--- a/engines/nancy/ui/textbox.cpp
+++ b/engines/nancy/ui/textbox.cpp
@@ -55,6 +55,9 @@ void Textbox::init() {
// Nancy 10, SCTB in Nancy 11). Delegate to a ScrollTextBox; the code below
// is the Nancy 1-9 bottom-right flat box.
if (g_nancy->getGameType() >= kGameTypeNancy10) {
+ // Nancy15+ rebuilds the text box whenever the player character changes,
+ // since its description comes out of that character's own data files
+ delete _scrollTextBox;
_scrollTextBox = new ScrollTextBox();
_scrollTextBox->init();
return;
Commit: 1d3e67bb3e6ced4536c7573c260bc903b012dc3f
https://github.com/scummvm/scummvm/commit/1d3e67bb3e6ced4536c7573c260bc903b012dc3f
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:20+03:00
Commit Message:
NANCY: NANCY15: More work on the LDSN chunk
Contains data for the "Design Select" screen, which picks the look
(outfit) the player character's UI and cutscenes use
Changed paths:
engines/nancy/enginedata.cpp
engines/nancy/enginedata.h
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 3ebdeefb695..40cebf1f5cd 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -1419,13 +1419,17 @@ LDSN::LDSN(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
readFilename(*chunkStream, backgroundImageName);
readFilename(*chunkStream, overlayImageName);
- // The remainder is a run of button/selection rects (16 bytes each),
- // followed by a short trailer whose fields aren't fully understood yet.
- while (chunkStream->pos() + 16 <= chunkStream->size()) {
- Common::Rect rect;
- readRect(*chunkStream, rect);
- rects.push_back(rect);
- }
+ // The accept button's "PLAYERCHAR DOWN" and "PLAYERCHAR HILITE" sprites
+ readRect(*chunkStream, acceptDownSrc);
+ readRect(*chunkStream, acceptDownDest);
+ readRect(*chunkStream, acceptHighlightSrc);
+ readRect(*chunkStream, acceptHighlightDest);
+
+ readRectArray(*chunkStream, buttonHotspots, kNumButtons);
+ readRectArray(*chunkStream, designRowDests, kNumDesignRows);
+
+ fontID = chunkStream->readSint16LE();
+ highlightFontID = chunkStream->readSint16LE();
}
PUIH::PUIH(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 28b2d24646c..fc7748d6588 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -909,15 +909,30 @@ struct PCUI : public EngineData {
Common::Array<Character> characters; // indexed by the on-disk slot byte
};
-// Fixed layout/graphics block for the Nancy 15 player-character ("Design
-// Select") switcher screen. Companion to PCUI. Supplies the background and
-// overlay image names plus the on-screen button/selection rects.
+// Fixed 314-byte layout block for the Nancy 15 "Design Select" screen, which
+// picks the look (outfit) the player character's UI and cutscenes use. Reached
+// from the in-game setup menu. Companion to PCUI.
struct LDSN : public EngineData {
+ static const uint kNumButtons = 2;
+ // The screen lists the available designs in a fixed column of rows
+ static const uint kNumDesignRows = 9;
+
LDSN(Common::SeekableReadStream *chunkStream);
- Common::String backgroundImageName; // "UI_DesignSelectBG"
- Common::String overlayImageName; // "UI_DesignSelect_OVL"
- Common::Array<Common::Rect> rects; // button + per-character selection rects
+ Common::Path backgroundImageName; // "UI_DesignSelectBG"
+ Common::Path overlayImageName; // "UI_DesignSelect_OVL"
+
+ // The accept button's two states, drawn out of the background image
+ Common::Rect acceptDownSrc;
+ Common::Rect acceptDownDest;
+ Common::Rect acceptHighlightSrc;
+ Common::Rect acceptHighlightDest;
+
+ // Hotspots: [0] accepts the highlighted design, [1] leaves without applying
+ Common::Array<Common::Rect> buttonHotspots;
+ Common::Array<Common::Rect> designRowDests; // where each design's name is drawn
+ int16 fontID = 0; // design names
+ int16 highlightFontID = 0; // ...and the selected one
};
// Player-UI header. Introduced in Nancy 15, first chunk of each character's
Commit: 923d9bddba362c40594f65cbbd102ba3294a9a76
https://github.com/scummvm/scummvm/commit/923d9bddba362c40594f65cbbd102ba3294a9a76
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:23+03:00
Commit Message:
NANCY: NANCY14: Implement new interactive video logic and format
Interactive videos are now named rather than numbered, and the flag and
cursor for each live in the action record instead of the file.
Handles the animations for the vermin (cockroaches) puzzle inside
Minette's room.
Changed paths:
engines/nancy/action/interactivevideo.cpp
engines/nancy/action/interactivevideo.h
engines/nancy/action/secondarymovie.cpp
engines/nancy/action/secondarymovie.h
diff --git a/engines/nancy/action/interactivevideo.cpp b/engines/nancy/action/interactivevideo.cpp
index a18746d51b6..571f3485ed3 100644
--- a/engines/nancy/action/interactivevideo.cpp
+++ b/engines/nancy/action/interactivevideo.cpp
@@ -60,6 +60,76 @@ void readInteractiveVideoFile(const Common::Path &filename, InteractiveVideoData
delete ivFile;
}
+// Length-prefixed string, the length being a 7-bits-per-byte varint
+static Common::String readIVString(Common::SeekableReadStream &stream) {
+ uint32 len = 0;
+ for (uint shift = 0; shift < 35; shift += 7) {
+ byte b = stream.readByte();
+ len |= (uint32)(b & 0x7f) << shift;
+ if (!(b & 0x80)) {
+ break;
+ }
+ }
+
+ Common::String ret;
+ while (len-- && !stream.eos()) {
+ ret += (char)stream.readByte();
+ }
+
+ return ret;
+}
+
+void readInteractiveVideoFileNancy14(const Common::Path &filename, InteractiveVideoData &data) {
+ // Nancy14 keeps this data inside a ciftree, under the bare name; earlier
+ // games ship it as a loose .iv file
+ Common::ScopedPtr<Common::SeekableReadStream> ivFile(
+ SearchMan.createReadStreamForMember(filename.append(".iv")));
+ if (!ivFile) {
+ ivFile.reset(SearchMan.createReadStreamForMember(filename));
+ }
+
+ if (!ivFile) {
+ warning("Could not open iv resource %s", filename.toString().c_str());
+ return;
+ }
+
+ if (readIVString(*ivFile) != "IVDataHI") {
+ warning("Invalid iv file %s", filename.toString().c_str());
+ return;
+ }
+
+ if (ivFile->readSint32LE() != 1 || ivFile->readSint32LE() != 1) {
+ warning("iv file %s is an old version", filename.toString().c_str());
+ return;
+ }
+
+ int32 numSets = ivFile->readSint32LE();
+ data.setNames.resize(MAX<int32>(numSets, 0));
+ for (int32 i = 0; i < numSets; ++i) {
+ data.setNames[i] = readIVString(*ivFile);
+ ivFile->readSint32LE(); // Legacy numeric set id, unused
+ }
+
+ // Frames run up to and including this id rather than being counted
+ int32 lastFrameID = ivFile->readSint32LE();
+ while (lastFrameID >= 0 && !ivFile->eos()) {
+ data.frames.push_back(InteractiveFrame());
+ InteractiveFrame &frame = data.frames.back();
+ frame.frameID = ivFile->readSint32LE();
+
+ int32 numHotspots = ivFile->readSint32LE();
+ frame.hotspots.resize(MAX<int32>(numHotspots, 0));
+ for (int32 i = 0; i < numHotspots; ++i) {
+ frame.hotspots[i].setID = ivFile->readSint32LE();
+ readRect(*ivFile, frame.hotspots[i].hotspot);
+ }
+
+ if ((int32)frame.frameID >= lastFrameID) {
+ break;
+ }
+ }
+}
+
void InteractiveVideo::readData(Common::SeekableReadStream &stream) {
Common::Path ivFilename;
readFilename(stream, ivFilename);
diff --git a/engines/nancy/action/interactivevideo.h b/engines/nancy/action/interactivevideo.h
index 57c4f70cc22..d9ebd714603 100644
--- a/engines/nancy/action/interactivevideo.h
+++ b/engines/nancy/action/interactivevideo.h
@@ -50,10 +50,20 @@ struct InteractiveFrame {
struct InteractiveVideoData {
Common::Path videoName;
Common::Array<InteractiveFrame> frames;
+
+ // Nancy14 only: the file's named hotspot sets, in file order. A hotspot's
+ // setID is an index into this array, and the name is what the action record
+ // keys its own set table by.
+ Common::Array<Common::String> setNames;
};
void readInteractiveVideoFile(const Common::Path &filename, InteractiveVideoData &data);
+// Nancy14 replaced the .iv format with an "IVDataHI" one: the sets are named
+// rather than numbered, and the flag and cursor for each live in the action
+// record instead of the file.
+void readInteractiveVideoFileNancy14(const Common::Path &filename, InteractiveVideoData &data);
+
class InteractiveVideo : public ActionRecord {
public:
InteractiveVideo() {}
diff --git a/engines/nancy/action/secondarymovie.cpp b/engines/nancy/action/secondarymovie.cpp
index d37af7a41d9..c9defb662bb 100644
--- a/engines/nancy/action/secondarymovie.cpp
+++ b/engines/nancy/action/secondarymovie.cpp
@@ -666,10 +666,14 @@ void PlaySecondaryMovie::readDataNancy14(Common::Serializer &ser, Common::Seekab
// the AR-44 movie data.
if (_movieType == kInteractiveMovie) {
readInteractiveData(ser);
+ readInteractiveVideoFileNancy14(_interactiveName, _interactiveVideo);
+ resolveInteractiveSets();
}
}
void PlaySecondaryMovie::readInteractiveData(Common::Serializer &ser) {
+ const bool named = g_nancy->getGameType() >= kGameTypeNancy14;
+
readFilename(ser, _interactiveName);
ser.skip(1); // Draws the hotspot rects on top of the movie when set
@@ -679,13 +683,34 @@ void PlaySecondaryMovie::readInteractiveData(Common::Serializer &ser) {
_interactiveSets.resize(numSets);
for (uint i = 0; i < numSets; ++i) {
InteractiveSet &set = _interactiveSets[i];
- ser.syncAsSint16LE(set.setID);
+
+ if (named) {
+ readFilename(ser, set.name);
+ } else {
+ int16 setID = 0;
+ ser.syncAsSint16LE(setID);
+ set.setID = setID;
+ }
+
ser.syncAsSint16LE(set.flagDesc.label);
ser.syncAsByte(set.flagDesc.flag);
ser.syncAsSint16LE(set.cursorID);
}
}
+// Turns the Nancy14 sets' names into the set indices the .iv file's hotspots use
+void PlaySecondaryMovie::resolveInteractiveSets() {
+ for (InteractiveSet &set : _interactiveSets) {
+ set.setID = -1;
+ for (uint i = 0; i < _interactiveVideo.setNames.size(); ++i) {
+ if (_interactiveVideo.setNames[i].equalsIgnoreCase(set.name)) {
+ set.setID = i;
+ break;
+ }
+ }
+ }
+}
+
void PlaySecondaryMovie::readData(Common::SeekableReadStream &stream) {
Common::Serializer ser(&stream, nullptr);
ser.setVersion(g_nancy->getGameType());
diff --git a/engines/nancy/action/secondarymovie.h b/engines/nancy/action/secondarymovie.h
index a2add21fe09..6ac166051f8 100644
--- a/engines/nancy/action/secondarymovie.h
+++ b/engines/nancy/action/secondarymovie.h
@@ -140,8 +140,11 @@ public:
// frame and tags each one with a set ID. The record itself carries the name
// of that file and the table below, which turns a set ID into the event flag
// a click sets and the cursor shown while the mouse is over the area.
+ // From Nancy14 the sets are named instead of numbered; setID is then the
+ // index of that name in the .iv file's own set list, resolved at load.
struct InteractiveSet {
- int16 setID = 0;
+ Common::String name;
+ int32 setID = 0;
FlagDescription flagDesc;
int16 cursorID = -1;
};
@@ -260,6 +263,7 @@ protected:
// AR 47 appends the name of its .iv file and the set table to the movie data.
void readInteractiveData(Common::Serializer &ser);
+ void resolveInteractiveSets();
// The set a hotspot belongs to, or nullptr if the record doesn't describe it.
const InteractiveSet *getInteractiveSet(int32 setID) const;
Commit: ab8b6978a7f1ad71a8e50960644b5787a42d87ff
https://github.com/scummvm/scummvm/commit/ab8b6978a7f1ad71a8e50960644b5787a42d87ff
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:24+03:00
Commit Message:
NANCY: Check the sound channel ID before use
Add a sanity check, to aid in debugging
Changed paths:
engines/nancy/sound.cpp
diff --git a/engines/nancy/sound.cpp b/engines/nancy/sound.cpp
index e58d16b1b95..db4e4f1eea0 100644
--- a/engines/nancy/sound.cpp
+++ b/engines/nancy/sound.cpp
@@ -288,6 +288,12 @@ void SoundManager::loadSound(const SoundDescription &description, SoundEffectDes
return;
}
+ if (description.channelID >= _channels.size()) {
+ warning("Sound '%s' asks for channel %u, but only %u exist", description.name.c_str(),
+ description.channelID, _channels.size());
+ return;
+ }
+
Channel &existing = _channels[description.channelID];
if (!forceReload && existing.stream != nullptr) {
// There's a channel already loaded. Check if we're trying to reload the exact same sound
Commit: eef0c7e6fff0bb6efd8bdd95eecfc006ac785b75
https://github.com/scummvm/scummvm/commit/eef0c7e6fff0bb6efd8bdd95eecfc006ac785b75
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:27+03:00
Commit Message:
NANCY: NANCY15: Allow changing the priority of a loaded CIF tree
Needed for the character changing functionality
Changed paths:
engines/nancy/resource.cpp
engines/nancy/resource.h
diff --git a/engines/nancy/resource.cpp b/engines/nancy/resource.cpp
index 9ae3bb11fba..089bf8e88d4 100644
--- a/engines/nancy/resource.cpp
+++ b/engines/nancy/resource.cpp
@@ -211,6 +211,14 @@ 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;
+ }
+ }
+
CifTree *tree = CifTree::makeCifTreeArchive(name, ext);
if (!tree) {
return false;
@@ -226,6 +234,12 @@ bool ResourceManager::readCifTree(const Common::String &name, const Common::Stri
return true;
}
+void ResourceManager::setCifTreePriority(const Common::String &name, int priority) {
+ Common::String upper = name;
+ upper.toUppercase();
+ SearchMan.setPriority(treePrefix + upper, priority);
+}
+
PatchTree *ResourceManager::readPatchTree(Common::SeekableReadStream *stream, const Common::String &name, int priority) {
if (!stream) {
return nullptr;
diff --git a/engines/nancy/resource.h b/engines/nancy/resource.h
index 1c2d3a75ce3..c436d71408c 100644
--- a/engines/nancy/resource.h
+++ b/engines/nancy/resource.h
@@ -48,6 +48,11 @@ public:
// Load a new ciftree
bool readCifTree(const Common::String &name, const Common::String &ext, int priority);
+
+ // 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.
+ void setCifTreePriority(const Common::String &name, int priority);
PatchTree *readPatchTree(Common::SeekableReadStream *stream, const Common::String &name, int priority);
// Debug functions
Commit: db8ae0e841669223d5de73ca0256a50601cd651e
https://github.com/scummvm/scummvm/commit/db8ae0e841669223d5de73ca0256a50601cd651e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:30+03:00
Commit Message:
NANCY: NANCY15: Initial work on switching characters
Changed paths:
engines/nancy/commontypes.h
engines/nancy/nancy.cpp
engines/nancy/nancy.h
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index bfc381d606c..d2178f784bc 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -50,6 +50,18 @@ static const int8 kEvNoEvent = -1;
static const int8 kFrNoFrame = -1;
static const uint16 kNoScene = 9999;
+// Nancy15 alternates between three protagonists (Nancy and the Hardy boys
+// Frank and Joe), each of whom keeps their own UI and journal. The two
+// brothers share their progress: whichever is played second inherits it
+// from the other.
+static const uint kMaxPlayerCharacters = 3;
+static const uint kPlayerCharacterFrank = 1;
+static const uint kPlayerCharacterJoe = 2;
+
+// Inventory action records name the character whose inventory they change.
+// This value stands for whoever is being played at the time.
+static const byte kPlayerCharacterActive = 9;
+
// Taskbar popup UI types. Shared by ControlUIItems (AR 29), UIPopupPrepScene
// (AR 32) and the Scene UI-prep-scene machinery.
enum UIType {
diff --git a/engines/nancy/nancy.cpp b/engines/nancy/nancy.cpp
index b40777ad715..2e3516694d1 100644
--- a/engines/nancy/nancy.cpp
+++ b/engines/nancy/nancy.cpp
@@ -223,6 +223,109 @@ const EngineData *NancyEngine::getEngineData(const Common::String &name) const {
return nullptr;
}
+Common::String NancyEngine::getPlayerCharacterDesign(uint characterIndex) const {
+ if (characterIndex < kMaxPlayerCharacters && !_playerCharacterDesigns[characterIndex].empty()) {
+ return _playerCharacterDesigns[characterIndex];
+ }
+
+ const PCUI *pcui = GetEngineData(PCUI);
+ if (pcui && characterIndex < pcui->characters.size()) {
+ return pcui->characters[characterIndex].defaultImageName;
+ }
+
+ return Common::String();
+}
+
+void NancyEngine::setPlayerCharacterDesign(uint characterIndex, const Common::String &designName) {
+ if (characterIndex < kMaxPlayerCharacters) {
+ _playerCharacterDesigns[characterIndex] = designName;
+ }
+}
+
+bool NancyEngine::playerCharacterNeedsReload(uint characterIndex) const {
+ const Common::String treeName = getPlayerCharacterDesign(characterIndex);
+ return !treeName.empty() && (treeName != _playerCharacterTree || characterIndex != _playerCharacter);
+}
+
+bool NancyEngine::setPlayerCharacter(uint characterIndex) {
+ const PCUI *pcui = GetEngineData(PCUI);
+ if (!pcui || characterIndex >= pcui->characters.size() || characterIndex >= kMaxPlayerCharacters) {
+ warning("Invalid player character %u", characterIndex);
+ return false;
+ }
+
+ const Common::String treeName = getPlayerCharacterDesign(characterIndex);
+ if (treeName.empty()) {
+ return false;
+ }
+
+ // A switch to the character who is already active still has work to do when
+ // their design has changed underneath them
+ if (!playerCharacterNeedsReload(characterIndex)) {
+ return false;
+ }
+
+ // Every character ships their own copy of the popup UI resources, so the
+ // incoming character's tree has to outrank the ones already loaded
+ if (_resource->readCifTree(treeName, "dat", 1)) {
+ if (!_playerCharacterTree.empty()) {
+ _resource->setCifTreePriority(_playerCharacterTree, 1);
+ }
+
+ _resource->setCifTreePriority(treeName, 2);
+ }
+
+ // The UI description chunks live in the character's own boot IFF, e.g.
+ // PUI_CRE_NANCY_DEFAULT_BOOT (PUI_ICE_NANCY_DEFAULT_BOOT in Nancy16)
+ IFF *iff = _resource->loadIFF(Common::Path(treeName + "_boot"));
+ if (!iff) {
+ if (_playerCharacterDesigns[characterIndex].empty()) {
+ // The character's default look is part of any working installation
+ error("Failed to load boot script for player character %s", treeName.c_str());
+ }
+
+ // A design named by a save that this installation doesn't have
+ warning("Missing player character design %s, falling back to the default", treeName.c_str());
+ _playerCharacterDesigns[characterIndex].clear();
+ return setPlayerCharacter(characterIndex);
+ }
+
+ Common::SeekableReadStream *chunkStream = nullptr;
+ #define LOAD_PLAYER_CHAR(t) if (chunkStream = iff->getChunkStream(#t), chunkStream) { \
+ delete _engineData.getValOrDefault(#t, nullptr); \
+ _engineData.setVal(#t, new t(chunkStream)); \
+ delete chunkStream; \
+ }
+
+ // Nancy16 moved the popup UI descriptions out into one IFF per widget
+ // (named by the PCUI and PUIH chunks), leaving only these behind
+ if (getGameType() <= kGameTypeNancy15) {
+ LOAD_PLAYER_CHAR(TASK)
+ LOAD_PLAYER_CHAR(UIIV)
+ LOAD_PLAYER_CHAR(UICO)
+ LOAD_PLAYER_CHAR(UICL)
+ LOAD_PLAYER_CHAR(UIBW)
+ LOAD_PLAYER_CHAR(UINB)
+ LOAD_PLAYER_CHAR(SCTB)
+ LOAD_PLAYER_CHAR(PUIV) // Player-UI random-sound bank ("can't" responses)
+ } else {
+ LOAD_PLAYER_CHAR(TSKL) // Task list sounds
+ }
+
+ LOAD_PLAYER_CHAR(UIRC)
+ LOAD_PLAYER_CHAR(UICM)
+ LOAD_PLAYER_CHAR(PUIH) // Player-UI header (theme name + swatch image)
+
+ #undef LOAD_PLAYER_CHAR
+
+ delete iff;
+
+ _playerCharacter = characterIndex;
+ _playerCharacterTree = treeName;
+
+ return true;
+}
+
// From Nancy12 the event flags are split into two ranges: 1000 generic engine
// flags (labels 1000-1999) followed by the game-specific flags (labels from 2000),
// whose names are listed in the EVNT chunk.
@@ -484,20 +587,10 @@ void NancyEngine::bootGameEngine() {
_resource->readCifTree("ciftree", "dat", 1);
_resource->readCifTree("promotree", "dat", 1);
- if (getGameType() == kGameTypeNancy15) {
- _resource->readCifTree("PUI_CRE_Nancy_Default", "dat", 1);
- // Other player character CIF trees are loaded on demand,
- // based on the PCUI chunk:
- // - PUI_CRE_Nancy_Jungle
- // - PUI_CRE_Nancy_Pink_Hibiscus
- // - PUI_CRE_Nancy_Teal_Hibiscus
- // - PUI_CRE_Frank_Default
- // - PUI_CRE_HB_Default
- // - PUI_CRE_Joe_Default
- } else if (getGameType() >= kGameTypeNancy16) {
- // Nancy16 only has a single player character, but kept the per-character tree
- _resource->readCifTree("PUI_ICE_Nancy_Default", "dat", 1);
- }
+ // Nancy15+ keeps its popup UI resources in one CIF tree per player character
+ // (PUI_CRE_Nancy_Default, PUI_CRE_Frank_Default, PUI_CRE_Joe_Default, ...).
+ // Those are loaded on demand by setPlayerCharacter(), once the PCUI chunk
+ // that names them is available.
// Read the static data. Up to Nancy11 it lives in nancy.dat; from Nancy12
// onwards the game ships it in its own data files, so the engine only needs
@@ -644,30 +737,8 @@ void NancyEngine::bootGameEngine() {
delete iff;
if (getGameType() >= kGameTypeNancy15) {
- const PCUI *pcui = GetEngineData(PCUI);
- // Note: the default character is Nancy, so we load her boot chunks here. Her CIF name is
- // PUI_CRE_NANCY_DEFAULT_BOOT (PUI_ICE_NANCY_DEFAULT_BOOT in Nancy16).
- iff = _resource->loadIFF(Common::Path(pcui->characters[0].defaultImageName + "_boot"));
-
- // Nancy16 moved the popup UI descriptions out into one IFF per widget
- // (named by the PCUI and PUIH chunks), leaving only these behind
- if (getGameType() <= kGameTypeNancy15) {
- LOAD_BOOT(TASK)
- LOAD_BOOT(UIIV)
- LOAD_BOOT(UICO)
- LOAD_BOOT(UICL)
- LOAD_BOOT(UIBW)
- LOAD_BOOT(UINB)
- LOAD_BOOT(SCTB)
- LOAD_BOOT(PUIV) // Player-UI random-sound bank ("can't" responses)
- } else {
- LOAD_BOOT(TSKL) // Task list sounds
- }
-
- LOAD_BOOT(UIRC)
- LOAD_BOOT(UICM)
- LOAD_BOOT(PUIH) // Player-UI header (theme name + swatch image)
- delete iff;
+ // The default player character is Nancy, who always occupies the first PCUI slot
+ setPlayerCharacter(0);
}
if (getGameType() >= kGameTypeNancy12) {
diff --git a/engines/nancy/nancy.h b/engines/nancy/nancy.h
index c53f07b93ab..e3ec6af9737 100644
--- a/engines/nancy/nancy.h
+++ b/engines/nancy/nancy.h
@@ -111,8 +111,25 @@ public:
const EngineData *getEngineData(const Common::String &name) const;
const Common::String getEventFlagName(uint flagID) const;
+ // Nancy15+ lets the player alternate between several protagonists, each of whom
+ // carries their own copy of the popup UI. Swaps the engine data describing it to
+ // the given PCUI character's, and returns whether anything actually changed.
+ // Scene::reloadPlayerCharacterUI() rebuilds the widgets themselves.
+ bool setPlayerCharacter(uint characterIndex);
+ uint getPlayerCharacter() const { return _playerCharacter; }
+
+ // A character's "design" is the look their UI wears; it names the CIF tree
+ // everything is loaded from. Nancy's can be changed on the Design Select
+ // screen, and defaults to the character's PCUI entry.
+ Common::String getPlayerCharacterDesign(uint characterIndex) const;
+ void setPlayerCharacterDesign(uint characterIndex, const Common::String &designName);
+
+ // Whether setPlayerCharacter() would actually have to load anything
+ bool playerCharacterNeedsReload(uint characterIndex) const;
+
void setState(NancyState::NancyState state, NancyState::NancyState overridePrevious = NancyState::kNone);
NancyState::NancyState getState() { return _gameFlow.curState; }
+ NancyState::NancyState getPreviousState() const { return _gameFlow.prevState; }
void setToPreviousState();
void setMouseEnabled(bool enabled);
@@ -169,6 +186,12 @@ private:
StaticData _staticData;
Common::HashMap<Common::String, EngineData *> _engineData;
+ // Nancy15+ active player character, the CIF tree their UI came from, and
+ // each character's chosen design (empty = their PCUI default)
+ uint _playerCharacter = 0;
+ Common::String _playerCharacterTree;
+ Common::String _playerCharacterDesigns[kMaxPlayerCharacters];
+
const byte _datFileMajorVersion;
const byte _datFileMinorVersion;
Commit: 13c74d348356be6d27521a12d51df474f20eb5cc
https://github.com/scummvm/scummvm/commit/13c74d348356be6d27521a12d51df474f20eb5cc
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:39+03:00
Commit Message:
NANCY: NANCY12 - NANCY14: Implement new dependency types
In Nancy12, dependencies 22-25 were implemented, for timer-related
functionality.
In Nancy14, the now unused type 13 has been repurposed into a value
table test (label = value index, milliseconds = threshold, condition =
comparison).
Changed paths:
engines/nancy/action/actionmanager.cpp
engines/nancy/action/actionrecord.h
engines/nancy/console.cpp
diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 92a98c7c482..7165ef35d71 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -271,6 +271,52 @@ void ActionManager::processActionRecords() {
debugDrawHotspots();
}
+// How a value-table test dependency (see below) compares the value against its
+// threshold. Matches the Nancy14 comparator's condition encoding.
+enum ValueTestComparison {
+ kValueEqual = 0,
+ kValueGreater = 1,
+ kValueGreaterOrEqual = 2,
+ kValueLess = 3,
+ kValueLessOrEqual = 4
+};
+
+// 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
+// it against the fighters' health. Type 13 was Nancy11's software-timer less-than
+// check; Nancy12 moved the timer checks to types 22-25, freeing it. Type 14 is
+// 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;
+ }
+
+ int32 value = table->getValue(dep.label);
+ if (value == 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;
+ case kValueGreater:
+ return value > threshold;
+ case kValueGreaterOrEqual:
+ return value >= threshold;
+ case kValueLess:
+ return value < threshold;
+ case kValueLessOrEqual:
+ return value <= threshold;
+ default:
+ return false;
+ }
+}
+
void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &record, bool doNotCheckCursor) {
if (dep.children.size()) {
// Recursively process child dependencies
@@ -525,7 +571,9 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
break;
case DependencyType::kTimerLessThanDependencyTime:
- if (g_nancy->getGameType() >= kGameTypeNancy11) {
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ dep.satisfied = evaluateValueTestDependency(dep);
+ } else if (g_nancy->getGameType() >= kGameTypeNancy11) {
// Nancy11+ checks a software-timer slot (label = slot index)
dep.satisfied = NancySceneState.isSoftwareTimerActive(dep.label) &&
NancySceneState.getSoftwareTimerElapsed(dep.label) <= (uint32)dep.timeData;
@@ -548,6 +596,27 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
dep.satisfied = NancySceneState.isSoftwareTimerActive(dep.label);
break;
+ case DependencyType::kTimerEqualsDependencyTime:
+ case DependencyType::kTimerBelowDependencyTime:
+ case DependencyType::kTimerAboveDependencyTime: {
+ // A stopped slot leaves the dependency as it was rather than
+ // failing it, so a record armed while the timer ran stays armed
+ if (!NancySceneState.isSoftwareTimerActive(dep.label)) {
+ break;
+ }
+
+ uint32 elapsed = NancySceneState.getSoftwareTimerElapsed(dep.label);
+
+ if (dep.type == DependencyType::kTimerEqualsDependencyTime) {
+ dep.satisfied = elapsed == (uint32)dep.timeData;
+ } else if (dep.type == DependencyType::kTimerBelowDependencyTime) {
+ dep.satisfied = elapsed < (uint32)dep.timeData;
+ } else {
+ dep.satisfied = (uint32)dep.timeData < elapsed;
+ }
+
+ break;
+ }
case DependencyType::kDifficultyLevel:
if (dep.condition == NancySceneState.getDifficulty()) {
dep.satisfied = true;
diff --git a/engines/nancy/action/actionrecord.h b/engines/nancy/action/actionrecord.h
index 43d54e3c3e7..8ef239435d8 100644
--- a/engines/nancy/action/actionrecord.h
+++ b/engines/nancy/action/actionrecord.h
@@ -53,6 +53,11 @@ enum struct DependencyType : int16 {
kElapsedPlayerDay = 10,
kCursorType = 11,
kPlayerTOD = 12,
+ // Nancy11 used types 13/14 for software-timer less-/greater-than checks (with 22
+ // for "is active"). Nancy12 moved the software-timer checks to types 22-25 and
+ // left 13/14 unused. Nancy14 then repurposed type 13 into a value-table test
+ // (label = value index, milliseconds = threshold, condition = comparison); 14
+ // stays unused.
kTimerLessThanDependencyTime = 13,
kTimerGreaterThanDependencyTime = 14,
kDifficultyLevel = 15,
@@ -62,7 +67,10 @@ enum struct DependencyType : int16 {
kCloseParenthesis = 19,
kRandom = 20,
kDefaultAR = 21,
- kTimerIsActive = 22 // Nancy11+ software-timer slot is running/counting
+ kTimerIsActive = 22, // Nancy11+ software-timer slot is running/counting
+ kTimerEqualsDependencyTime = 23, // The next three compare a running software
+ kTimerBelowDependencyTime = 24, // timer's elapsed time against the dependency's
+ kTimerAboveDependencyTime = 25 // own time, and only while that slot is running
};
// Describes a condition that needs to be fulfilled before the
diff --git a/engines/nancy/console.cpp b/engines/nancy/console.cpp
index 656d533a3b7..591ec5983df 100644
--- a/engines/nancy/console.cpp
+++ b/engines/nancy/console.cpp
@@ -565,7 +565,15 @@ void NancyConsole::recursePrintDependencies(const Action::DependencyRecord &reco
dep.label == 0 ? "kPlayerDay" : dep.label == 1 ? "kPLayerNight" : "kPLayerDuskDawn");
break;
case DependencyType::kTimerLessThanDependencyTime:
- debugPrintf("kTimerLessThanDependencyTime");
+ if (g_nancy->getGameType() >= kGameTypeNancy14) {
+ // Repurposed as a value-table test in Nancy14
+ static const char *const comparisons[] = { "==", ">", ">=", "<", "<=" };
+ debugPrintf("kValueTest, value %u %s %i", dep.label,
+ dep.condition < ARRAYSIZE(comparisons) ? comparisons[dep.condition] : "?",
+ dep.milliseconds);
+ } else {
+ debugPrintf("kTimerLessThanDependencyTime");
+ }
break;
case DependencyType::kTimerGreaterThanDependencyTime:
debugPrintf("kTimerGreaterThanDependencyTime");
Commit: e27243554a3372177a1a291262b0895b11d88fd8
https://github.com/scummvm/scummvm/commit/e27243554a3372177a1a291262b0895b11d88fd8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:42+03:00
Commit Message:
NANCY: NANCY14: Implement BlockingPuzzle - the final boss fight puzzle
Movie-driven fighting minigame (Nancy14, AR 180). The on-screen
opponent attacks from one of nine directions in a 3x3 grid; the player
blocks by hovering the matching cell. Directional movies play through
one MoviePlayer, picked by weighted random from an idle-dominated
sequence, with sound effects layered on top. Each fighter's health is a
value-table entry (shown by a Meter puzzle, AR 179); a ValueTest scene
change ends the fight when one is depleted.
Changed paths:
A engines/nancy/action/puzzle/blockingpuzzle.cpp
A engines/nancy/action/puzzle/blockingpuzzle.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 9cbd3085116..87b816632be 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -39,7 +39,7 @@
#include "engines/nancy/action/puzzle/assemblypuzzle.h"
#include "engines/nancy/action/puzzle/bballpuzzle.h"
#include "engines/nancy/action/puzzle/beadpuzzle.h"
-//#include "engines/nancy/action/puzzle/blockingpuzzle.h"
+#include "engines/nancy/action/puzzle/blockingpuzzle.h"
#include "engines/nancy/action/puzzle/blockspuzzle.h"
#include "engines/nancy/action/puzzle/boardgamepuzzle.h"
#include "engines/nancy/action/puzzle/buildpuzzle.h"
@@ -530,8 +530,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
case 179:
return new MeterPuzzle();
case 180:
- //return new BlockingPuzzle();
- return nullptr; // TODO
+ return new BlockingPuzzle();
case 181:
return new PaintPuzzle();
case 182:
diff --git a/engines/nancy/action/puzzle/blockingpuzzle.cpp b/engines/nancy/action/puzzle/blockingpuzzle.cpp
new file mode 100644
index 00000000000..8cd0999464c
--- /dev/null
+++ b/engines/nancy/action/puzzle/blockingpuzzle.cpp
@@ -0,0 +1,498 @@
+/* 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/system.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/puzzledata.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/blockingpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint32 kRecoilShakeDurationMs = 400;
+
+BlockingPuzzle::~BlockingPuzzle() {
+ g_system->setShakePos(0, 0);
+}
+
+void BlockingPuzzle::MovieDescription::readData(Common::SeekableReadStream &stream) {
+ readFilename(stream, name);
+ startFrame = stream.readSint16LE();
+ lastFrame = stream.readSint16LE();
+ minPauseMs = stream.readSint32LE();
+ maxPauseMs = stream.readSint32LE();
+ pauseChance = stream.readByte();
+}
+
+void BlockingPuzzle::readData(Common::SeekableReadStream &stream) {
+ _opponentHealthIndex = stream.readSint16LE();
+ _playerHealthIndex = stream.readSint16LE();
+ _field4 = stream.readSint16LE();
+ _field6 = stream.readSint16LE();
+ _field8 = stream.readByte();
+ _field9 = stream.readByte();
+ readFilename(stream, _imageName);
+ for (uint i = 0; i < 4; ++i) {
+ readRect(stream, _controlRects[i]);
+ }
+ _point.x = stream.readSint32LE();
+ _point.y = stream.readSint32LE();
+ _field51 = stream.readSint16LE();
+ readRect(stream, _movieSrc);
+ readRect(stream, _movieDest);
+ _flag = stream.readByte();
+
+ _fullBlockSounds.readData(stream);
+ _partBlockSounds.readData(stream);
+ _hitSounds.readData(stream);
+
+ int16 numCells = stream.readSint16LE();
+ _grid.resize(numCells);
+ for (int16 i = 0; i < numCells; ++i) {
+ GridCell &cell = _grid[i];
+ cell.id = stream.readByte();
+ readRect(stream, cell.rect);
+ for (uint j = 0; j < 8; ++j) {
+ cell.params[j] = stream.readSint16LE();
+ }
+ int16 numNeighbors = stream.readSint16LE();
+ cell.neighbors.resize(numNeighbors);
+ for (int16 j = 0; j < numNeighbors; ++j) {
+ cell.neighbors[j] = stream.readSint16LE();
+ }
+ }
+
+ _introMovie.readData(stream);
+ int16 numIntro = stream.readSint16LE();
+ _introSequence.resize(numIntro);
+ for (int16 i = 0; i < numIntro; ++i) {
+ readFilename(stream, _introSequence[i].name);
+ _introSequence[i].weight = stream.readSint16LE();
+ }
+
+ int16 numMoves = stream.readSint16LE();
+ _moves.resize(numMoves);
+ for (int16 i = 0; i < numMoves; ++i) {
+ AttackMove &move = _moves[i];
+ move.cellID = stream.readSint16LE();
+ move.field2 = stream.readSint16LE();
+ move.windup.readData(stream);
+ move.field_c = stream.readSint16LE();
+ move.field_d = stream.readSint16LE();
+ move.field_e = stream.readSint16LE();
+ move.attack.readData(stream);
+ move.strikeFrame = stream.readSint16LE();
+ move.response.readData(stream);
+ int16 numIdle = stream.readSint16LE();
+ move.idleNames.resize(numIdle);
+ for (int16 j = 0; j < numIdle; ++j) {
+ readFilename(stream, move.idleNames[j]);
+ }
+ move.idleWeight = stream.readSint16LE();
+ }
+}
+
+void BlockingPuzzle::init() {
+ Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
+ _drawSurface.create(vpBounds.width(), vpBounds.height(),
+ g_nancy->_graphics->getInputPixelFormat());
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+ setTransparent(true);
+ setVisible(true);
+ moveTo(vpBounds);
+
+ // The attack-telegraph box is a sprite in this sheet (source _controlRects[0]).
+ g_nancy->_resource->loadImage(_imageName, _overlayImage);
+ _overlayImage.setTransparentColor(_drawSurface.getTransparentColor());
+
+ // The eight params are four recoil-vector pairs, chosen by block outcome.
+ _cells.resize(_grid.size());
+ for (uint i = 0; i < _grid.size(); ++i) {
+ const GridCell &g = _grid[i];
+ RuntimeCell &c = _cells[i];
+ c.id = g.id;
+ c.rect = g.rect;
+ c.base = Common::Point(g.params[0], g.params[1]);
+ c.full = Common::Point(g.params[2], g.params[3]);
+ c.hit = Common::Point(g.params[4], g.params[5]);
+ c.partial = Common::Point(g.params[6], g.params[7]);
+ c.neighbors = g.neighbors;
+ }
+
+ // Health lives in the shared value table (indices _opponentHealthIndex/_playerHealthIndex); start at 0.
+ TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ if (table) {
+ if (_opponentHealthIndex != 0xff) {
+ table->setSingleValue(_opponentHealthIndex, 0);
+ }
+ if (_playerHealthIndex != 0xff) {
+ table->setSingleValue(_playerHealthIndex, 0);
+ }
+ }
+
+ _playerBlockCell = 0;
+ _activeMove = -1;
+ playNextMovie();
+}
+
+bool BlockingPuzzle::playMovie(const Common::Path &name) {
+ if (name.empty() || !_moviePlayer.loadFile(name) || _moviePlayer.getFrameCount() <= 0) {
+ if (!name.empty()) {
+ warning("BlockingPuzzle: couldn't load movie '%s'", name.toString().c_str());
+ }
+ return false;
+ }
+ _moviePlayer.playRange(0, _moviePlayer.getFrameCount() - 1);
+ redraw();
+ return true;
+}
+
+int BlockingPuzzle::findMoveByMovie(const Common::Path &name) const {
+ for (uint i = 0; i < _moves.size(); ++i) {
+ if (!_moves[i].response.name.empty() && _moves[i].response.name == name) {
+ return (int)i;
+ }
+ }
+ return -1;
+}
+
+const BlockingPuzzle::RuntimeCell *BlockingPuzzle::cellByID(int id) const {
+ for (uint i = 0; i < _cells.size(); ++i) {
+ if (_cells[i].id == id) {
+ return &_cells[i];
+ }
+ }
+ return nullptr;
+}
+
+void BlockingPuzzle::updateRecoil() {
+ if (_recoilStartMs == 0) {
+ return;
+ }
+
+ uint32 elapsed = g_system->getMillis() - _recoilStartMs;
+ if (elapsed >= kRecoilShakeDurationMs) {
+ g_system->setShakePos(0, 0);
+ _recoilStartMs = 0;
+ return;
+ }
+
+ // Amplitude falls to zero across the window; the sign flips a few times a
+ // second so the screen buzzes rather than slides.
+ int scale = 100 - (int)(elapsed * 100 / kRecoilShakeDurationMs);
+ int sign = ((elapsed / 40) & 1) ? -1 : 1;
+ g_system->setShakePos(_recoilAmp.x * scale * sign / 100,
+ _recoilAmp.y * scale * sign / 100);
+}
+
+// Picks the next clip by weighted random; whichever move owns it becomes active.
+void BlockingPuzzle::playNextMovie() {
+ _resolved = false;
+ _activeMove = -1;
+
+ Common::Path pick = _introMovie.name;
+ if (!_introSequence.empty()) {
+ int total = 0;
+ for (uint i = 0; i < _introSequence.size(); ++i) {
+ if (_introSequence[i].weight > 0) {
+ total += _introSequence[i].weight;
+ }
+ }
+
+ if (total > 0) {
+ int roll = g_nancy->_randomSource->getRandomNumber(total - 1);
+ int cumulative = 0;
+ for (uint i = 0; i < _introSequence.size(); ++i) {
+ if (_introSequence[i].weight <= 0) {
+ continue;
+ }
+ cumulative += _introSequence[i].weight;
+ if (roll < cumulative) {
+ pick = _introSequence[i].name;
+ break;
+ }
+ }
+ } else {
+ pick = _introSequence[g_nancy->_randomSource->getRandomNumber(_introSequence.size() - 1)].name;
+ }
+ }
+
+ if (!playMovie(pick)) {
+ return;
+ }
+
+ _activeMove = findMoveByMovie(pick);
+ if (_activeMove >= 0) {
+ // Off-screen fighter's windup sound; the frame player is silent.
+ playSoundBlock(_moves[_activeMove].windup);
+ }
+}
+
+int BlockingPuzzle::resolveBlock(int attackCell, int blockCell, Common::Point &recoil) const {
+ recoil = Common::Point(0, 0);
+
+ const RuntimeCell *attack = nullptr;
+ const RuntimeCell *block = nullptr;
+ for (uint i = 0; i < _cells.size(); ++i) {
+ if (_cells[i].id == attackCell) {
+ attack = &_cells[i];
+ }
+ if (_cells[i].id == blockCell) {
+ block = &_cells[i];
+ }
+ }
+
+ if (!attack) {
+ return kHit;
+ }
+ recoil += attack->base;
+
+ if (attackCell == blockCell) {
+ recoil += attack->full;
+ return kFullBlock;
+ }
+
+ // A guard on an adjacent direction is a partial block.
+ if (block) {
+ for (uint i = 0; i < block->neighbors.size(); ++i) {
+ if (block->neighbors[i] == attackCell) {
+ recoil += attack->partial;
+ return kPartialBlock;
+ }
+ }
+ }
+
+ recoil += attack->hit;
+ return kHit;
+}
+
+void BlockingPuzzle::applyDamage(const Common::Point &recoil) {
+ // recoil.x/.y are the damage to _opponentHealthIndex/_playerHealthIndex.
+ TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+ if (!table) {
+ return;
+ }
+ if (_opponentHealthIndex != 0xff) {
+ table->setSingleValue(_opponentHealthIndex, table->getSingleValue(_opponentHealthIndex) + recoil.x);
+ }
+ if (_playerHealthIndex != 0xff) {
+ table->setSingleValue(_playerHealthIndex, table->getSingleValue(_playerHealthIndex) + recoil.y);
+ }
+}
+
+int BlockingPuzzle::cellAtPoint(const Common::Point &mousePos) const {
+ for (uint i = 0; i < _cells.size(); ++i) {
+ if (_cells[i].rect.isEmpty()) {
+ continue;
+ }
+ if (NancySceneState.getViewport().convertViewportToScreen(_cells[i].rect).contains(mousePos)) {
+ return _cells[i].id;
+ }
+ }
+ return 0;
+}
+
+// Alpha-blends an overlay-sheet sprite over the movie frame (per-pixel, RGB-keyed).
+void BlockingPuzzle::drawTelegraph(const Common::Rect &srcRect, const Common::Point &destPos, byte alpha) {
+ if (srcRect.isEmpty() || !_overlayImage.getBounds().contains(srcRect)) {
+ return;
+ }
+
+ byte tr, tg, tb;
+ g_nancy->_graphics->getInputPixelFormat().colorToRGB(g_nancy->_graphics->getTransColor(), tr, tg, tb);
+
+ for (int y = 0; y < srcRect.height(); ++y) {
+ int destY = destPos.y + y;
+ if (destY < 0 || destY >= _drawSurface.h) {
+ continue;
+ }
+ for (int x = 0; x < srcRect.width(); ++x) {
+ int destX = destPos.x + x;
+ if (destX < 0 || destX >= _drawSurface.w) {
+ continue;
+ }
+
+ byte a, r, g, b;
+ _overlayImage.format.colorToARGB(_overlayImage.getPixel(srcRect.left + x, srcRect.top + y), a, r, g, b);
+ if (a == 0 || (r == tr && g == tg && b == tb)) {
+ continue;
+ }
+
+ byte da, dr, dg, db;
+ _drawSurface.format.colorToARGB(_drawSurface.getPixel(destX, destY), da, dr, dg, db);
+
+ // Source-over, so the box shows over both the character and the
+ // transparent movie background (not just the opaque silhouette).
+ int srcA = a * alpha / 255;
+ int dstA = da * (255 - srcA) / 255;
+ int outA = srcA + dstA;
+ if (outA == 0) {
+ continue;
+ }
+ byte outR = (byte)((r * srcA + dr * dstA) / outA);
+ byte outG = (byte)((g * srcA + dg * dstA) / outA);
+ byte outB = (byte)((b * srcA + db * dstA) / outA);
+ _drawSurface.setPixel(destX, destY, _drawSurface.format.ARGBToColor((byte)outA, outR, outG, outB));
+ }
+ }
+}
+
+void BlockingPuzzle::redraw() {
+ _drawSurface.clear(g_nancy->_graphics->getTransColor());
+ if (_moviePlayer.isVideoLoaded()) {
+ _moviePlayer.drawFrame(_drawSurface, Common::Point(_movieDest.left, _movieDest.top));
+ }
+
+ // Telegraph the target cell with the box sprite, fading bright to pale up to the
+ // strike frame.
+ if (_activeMove >= 0 && !_resolved && _moviePlayer.isVideoLoaded() && !_overlayImage.empty()) {
+ const RuntimeCell *cell = cellByID(_moves[_activeMove].cellID);
+ if (cell && !cell->rect.isEmpty()) {
+ int strike = _moves[_activeMove].strikeFrame;
+ int frame = _moviePlayer.getCurrentFrame();
+ byte alpha = 255;
+ if (strike > 0 && frame > 0) {
+ alpha = (byte)(64 + 191 * CLIP(strike - frame, 0, strike) / strike);
+ }
+ drawTelegraph(_controlRects[0], Common::Point(cell->rect.left, cell->rect.top), alpha);
+ }
+ }
+
+ _needsRedraw = true;
+}
+
+void BlockingPuzzle::execute() {
+ switch (_state) {
+ case kBegin:
+ init();
+ registerGraphics();
+ _state = kRun;
+ // fall through
+ case kRun: {
+ updateRecoil();
+
+ if (!_moviePlayer.isVideoLoaded()) {
+ playNextMovie();
+ break;
+ }
+
+ // Resolve the block when the attack clip reaches its strike frame: score the
+ // outcome, apply damage, play the attack and reaction sounds.
+ if (_activeMove >= 0 && !_resolved &&
+ _moviePlayer.getCurrentFrame() >= _moves[_activeMove].strikeFrame) {
+ _resolved = true;
+ playSoundBlock(_moves[_activeMove].attack);
+
+ Common::Point recoil;
+ int outcome = resolveBlock(_moves[_activeMove].cellID, _playerBlockCell, recoil);
+ applyDamage(recoil);
+
+ // Jolt the screen on impact; direction/strength track the recoil vector.
+ _recoilStartMs = g_system->getMillis();
+ _recoilAmp = Common::Point(CLIP<int>(ABS(recoil.x) / 8, 0, 12),
+ CLIP<int>(ABS(recoil.y) / 8, 0, 12));
+
+ switch (outcome) {
+ case kFullBlock:
+ playSoundBlock(_fullBlockSounds);
+ break;
+ case kPartialBlock:
+ playSoundBlock(_partBlockSounds);
+ break;
+ case kHit:
+ playSoundBlock(_hitSounds);
+ break;
+ default:
+ break;
+ }
+ }
+
+ bool changed = _moviePlayer.update();
+
+ if (!_moviePlayer.isRangePlaying()) {
+ playNextMovie();
+ } else if (changed) {
+ redraw();
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+void BlockingPuzzle::handleInput(NancyInput &input) {
+ if (_state != kRun) {
+ return;
+ }
+
+ // Hovering a cell guards that direction (mouse-over, not click).
+ int cell = cellAtPoint(input.mousePos);
+ _playerBlockCell = cell;
+ if (cell != 0) {
+ g_nancy->_cursor->setCursorType(CursorManager::kHotspot);
+ }
+}
+
+void BlockingPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+ if (block.names.empty()) {
+ return;
+ }
+
+ // Pick a random one of the block's names.
+ uint index = block.names.size() > 1 ?
+ g_nancy->_randomSource->getRandomNumber(block.names.size() - 1) : 0;
+ if (block.names[index].empty() || block.names[index] == "NO SOUND") {
+ return;
+ }
+
+ SoundDescription desc;
+ desc.name = block.names[index];
+ 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);
+
+ // The fighters' lines are CVTX captions keyed by the played sound's name
+ // (autotext searched first, then convo), shown as the fight goes on.
+ Common::String caption = resolveSubtitleText(desc.name, Common::String(), "AUTOTEXT");
+ if (caption.empty()) {
+ caption = resolveSubtitleText(desc.name, Common::String(), "CONVO");
+ }
+ if (!caption.empty()) {
+ showSubtitle(caption);
+ }
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/blockingpuzzle.h b/engines/nancy/action/puzzle/blockingpuzzle.h
new file mode 100644
index 00000000000..298d1a7ad53
--- /dev/null
+++ b/engines/nancy/action/puzzle/blockingpuzzle.h
@@ -0,0 +1,173 @@
+/* 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_BLOCKINGPUZZLE_H
+#define NANCY_ACTION_BLOCKINGPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+namespace Action {
+
+// Movie-driven fighting minigame (Nancy14, AR 180). The on-screen opponent attacks
+// from one of nine directions in a 3x3 grid; the player blocks by hovering the
+// matching cell. Directional movies play through one MoviePlayer, picked by weighted
+// random from an idle-dominated sequence, with sound effects layered on top. Each
+// fighter's health is a value-table entry (shown by a Meter puzzle, AR 179); a
+// ValueTest scene change ends the fight when one is depleted.
+class BlockingPuzzle : public RenderActionRecord {
+public:
+ BlockingPuzzle() : RenderActionRecord(7) {}
+ virtual ~BlockingPuzzle();
+
+ 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 "BlockingPuzzle"; }
+
+ // A movie playback descriptor: frame range plus a pauseChance% chance to pause a
+ // random [minPauseMs, maxPauseMs] between plays. This port only uses the name.
+ struct MovieDescription {
+ Common::Path name;
+ int16 startFrame = 0;
+ int16 lastFrame = 0;
+ int32 minPauseMs = 0;
+ int32 maxPauseMs = 0;
+ byte pauseChance = 0;
+
+ void readData(Common::SeekableReadStream &stream);
+ };
+
+ // One movie variant of a weighted random pick.
+ struct WeightedMovie {
+ Common::Path name;
+ int16 weight = 0;
+ };
+
+ // One cell of the 3x3 attack grid: a viewport rect, eight tuning values (four
+ // recoil-vector pairs, see init()), and the indices of the adjacent cells that
+ // count as a partial block.
+ struct GridCell {
+ byte id = 0;
+ Common::Rect rect;
+ int16 params[8] = {};
+ Common::Array<int16> neighbors;
+ };
+
+ // One attack from grid cell cellID (1-9). windup/attack are sound effects; response
+ // is the directional movie; strikeFrame is when the block is scored. field_c/d/e
+ // are unconfirmed (likely sound timing). idleNames/idleWeight are the weighted
+ // return-to-idle movie sequence.
+ struct AttackMove {
+ int16 cellID = 0;
+ int16 field2 = 0;
+ RandomSoundBlock windup;
+ int16 field_c = 0;
+ int16 field_d = 0;
+ int16 field_e = 0;
+ RandomSoundBlock attack;
+ int16 strikeFrame = 0; // 0xf5
+ MovieDescription response;
+ Common::Array<Common::Path> idleNames;
+ int16 idleWeight = 0;
+ };
+
+ // -- File data --
+ int16 _opponentHealthIndex = 0; // 0x17b - value-table slot damaged by recoil.x (Minette)
+ int16 _playerHealthIndex = 0; // 0x17d - value-table slot damaged by recoil.y (the player)
+ int16 _field4 = 0; // 0x183 - unknown
+ int16 _field6 = 0; // 0x154 - unknown
+ byte _field8 = 0; // 0x153 - combat gate flag (gates block input in the original); unused here
+ byte _field9 = 0; // 0x152 - combat gate flag; unused here
+ Common::Path _imageName; // MOU_Fight_OVL sprite sheet
+ Common::Rect _controlRects[4]; // [0] = the attack-telegraph box sprite (cell-sized); [1-3] other UI
+ Common::Point _point; // 0x1b3 - unknown
+ int16 _field51 = 0; // 0x51 - unknown
+ Common::Rect _movieSrc; // source rect within the fight movie
+ Common::Rect _movieDest; // on-screen destination for the fight movie
+ byte _flag = 0; // shared across the three reaction-sound blocks; unknown
+
+ // The attacker's reaction sound effects, picked by block outcome and shared
+ // across attacks.
+ RandomSoundBlock _fullBlockSounds;
+ RandomSoundBlock _partBlockSounds;
+ RandomSoundBlock _hitSounds;
+
+ Common::Array<GridCell> _grid;
+
+ MovieDescription _introMovie;
+ Common::Array<WeightedMovie> _introSequence;
+
+ Common::Array<AttackMove> _moves;
+
+ // -- Runtime state --
+ // A grid cell resolved for gameplay: its hit rect, the four recoil/damage
+ // vectors (indexed by block outcome), and the adjacent cells that count as a
+ // partial block.
+ struct RuntimeCell {
+ int16 id = 0;
+ Common::Rect rect;
+ Common::Point base; // always applied
+ Common::Point full; // exact-direction block
+ Common::Point hit; // unblocked
+ Common::Point partial; // adjacent-direction block
+ Common::Array<int16> neighbors;
+ };
+
+ // resolveBlock() outcomes.
+ enum BlockOutcome { kFullBlock = 1, kPartialBlock = 2, kHit = 3 };
+
+ void redraw();
+ void drawTelegraph(const Common::Rect &srcRect, const Common::Point &destPos, byte alpha);
+ void playNextMovie();
+ bool playMovie(const Common::Path &name);
+ void updateRecoil();
+ int findMoveByMovie(const Common::Path &name) const;
+ const RuntimeCell *cellByID(int id) const;
+ int cellAtPoint(const Common::Point &mousePos) const;
+ int resolveBlock(int attackCell, int blockCell, Common::Point &recoil) const;
+ void applyDamage(const Common::Point &recoil);
+ void playSoundBlock(const RandomSoundBlock &block);
+
+ MoviePlayer _moviePlayer;
+ Graphics::ManagedSurface _overlayImage; // MOU_Fight_OVL sprite sheet
+ Common::Array<RuntimeCell> _cells;
+ int _activeMove = -1; // index into _moves for the directional clip playing (-1 = milling)
+ int _playerBlockCell = 0; // cell ID the player is guarding (0 = none)
+ bool _resolved = false; // this attack's block already scored
+
+ // Impact screen-shake: decays to zero over its window.
+ uint32 _recoilStartMs = 0; // 0 = no active shake
+ Common::Point _recoilAmp; // initial shake amplitude, in pixels
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_BLOCKINGPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index e0060def98c..691c668016b 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -23,6 +23,7 @@ MODULE_OBJS = \
action/puzzle/assemblypuzzle.o \
action/puzzle/bballpuzzle.o \
action/puzzle/beadpuzzle.o \
+ action/puzzle/blockingpuzzle.o \
action/puzzle/blockspuzzle.o \
action/puzzle/boardgamepuzzle.o \
action/puzzle/buildpuzzle.o \
Commit: 28255652dea3044d32d41612dc1b9a3809142920
https://github.com/scummvm/scummvm/commit/28255652dea3044d32d41612dc1b9a3809142920
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:46+03:00
Commit Message:
NANCY: NANCY15: Initial implementation of character swapping (PlayChar)
This is an initial implementation of character swapping and AR 134
(PlayChar). This format will be expanded in subsequent commits, so
saved games written with these intermediate commits will contain junk
and crash.
Changed paths:
engines/nancy/action/arfactory.cpp
engines/nancy/action/miscrecords.cpp
engines/nancy/action/miscrecords.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/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 87b816632be..42ac7c5faf1 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -408,10 +408,8 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
return new CameraAction();
case 134: // Nancy15
// Switches the active player character (Nancy / Frank / Joe), the
- // dual-protagonist mechanic new to The Creature of Kapu Cave.
- // TODO: not yet implemented (depends on the PCUI/LDSN player-char UI)
- // return new PlayCharAR();
- return nullptr; // TODO
+ // dual-protagonist mechanic new to The Creature of Kapu Cave
+ return new PlayChar();
case 140:
if (g_nancy->getGameType() <= kGameTypeNancy11)
return new SetVolume(); // Moved to 149 in Nancy9, empty slot in Nancy9-11
diff --git a/engines/nancy/action/miscrecords.cpp b/engines/nancy/action/miscrecords.cpp
index c04255785d8..0377b6f9785 100644
--- a/engines/nancy/action/miscrecords.cpp
+++ b/engines/nancy/action/miscrecords.cpp
@@ -1083,5 +1083,43 @@ void ResourceUse::execute() {
}
}
+void PlayChar::readData(Common::SeekableReadStream &stream) {
+ _characterIndex = stream.readByte();
+ readFilename(stream, _videoFile);
+}
+
+void PlayChar::execute() {
+ const PCUI *pcui = GetEngineData(PCUI);
+ if (!pcui || _characterIndex >= pcui->characters.size()) {
+ warning("PlayChar: no player character %u", _characterIndex);
+ finishExecution();
+ return;
+ }
+
+ // Every character owns an event flag that marks them as the one being
+ // played; conditions elsewhere in the game branch on those
+ for (uint i = 0; i < pcui->characters.size(); ++i) {
+ const uint16 flagLabel = pcui->characters[i].id;
+ if (flagLabel != 0) {
+ NancySceneState.setEventFlag(flagLabel, i == _characterIndex ? g_nancy->_true : g_nancy->_false);
+ }
+ }
+
+ NancySceneState.changePlayerCharacter(_characterIndex);
+
+ auto *playerChar = (PlayerCharacterData *)NancySceneState.getPuzzleData(PlayerCharacterData::getTag());
+ if (playerChar) {
+ playerChar->characterIndex = _characterIndex;
+ }
+
+ // The scene itself doesn't change; only the video showing it does, so that
+ // the location is seen through the incoming character's eyes
+ if (!_videoFile.empty()) {
+ NancySceneState.changeSceneVideo(_videoFile);
+ }
+
+ finishExecution();
+}
+
} // End of namespace Action
} // End of namespace Nancy
diff --git a/engines/nancy/action/miscrecords.h b/engines/nancy/action/miscrecords.h
index 9af6b67c0c2..54870e953a4 100644
--- a/engines/nancy/action/miscrecords.h
+++ b/engines/nancy/action/miscrecords.h
@@ -534,6 +534,22 @@ protected:
bool _paymentApplied = false;
};
+// Added in Nancy15 (AR 134). Hands control to one of the game's protagonists
+// (Nancy, Frank or Joe), which swaps in that character's own copy of the popup
+// UI, and optionally replaces the current scene's background with one showing
+// the location from the incoming character's point of view.
+class PlayChar : public ActionRecord {
+public:
+ void readData(Common::SeekableReadStream &stream) override;
+ void execute() override;
+
+protected:
+ Common::String getRecordTypeName() const override { return "PlayChar"; }
+
+ byte _characterIndex = 0; // index into the PCUI character list
+ Common::Path _videoFile; // replacement scene background, empty to keep the current one
+};
+
} // End of namespace Action
} // End of namespace Nancy
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 306ed573341..f23ad25c9fa 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -499,6 +499,10 @@ void TaskbarData::synchronize(Common::Serializer &ser) {
}
}
+void PlayerCharacterData::synchronize(Common::Serializer &ser) {
+ ser.syncAsUint16LE(characterIndex);
+}
+
void WordFindPuzzleData::synchronize(Common::Serializer &ser) {
ser.syncAsSint16LE(currentWord);
}
@@ -577,6 +581,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
return new UIResourceData();
case TaskbarData::getTag():
return new TaskbarData();
+ case PlayerCharacterData::getTag():
+ return new PlayerCharacterData();
default:
return nullptr;
}
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index e6261e4435d..c6c868f734e 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -372,6 +372,19 @@ struct TaskbarData : public PuzzleData {
bool notifications[kNumButtons][kNumNotificationSubCategories] = {};
};
+// Nancy15+ active player character (Nancy / Frank / Joe), selected by AR 134.
+// The whole popup UI is rebuilt from the character's own data files, so the
+// selection has to survive a save/load for the right UI to come back.
+struct PlayerCharacterData : public PuzzleData {
+ PlayerCharacterData() {}
+ virtual ~PlayerCharacterData() {}
+
+ static constexpr uint32 getTag() { return MKTAG('P', 'C', 'H', 'R'); }
+ virtual void synchronize(Common::Serializer &ser);
+
+ uint16 characterIndex = 0;
+};
+
// Nancy13+ WordFindPuzzle (AR 170). The puzzle is solved one word at a time across
// several scene visits; this remembers which word is currently active so progress
// survives leaving and re-entering the scene (and saving/loading).
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index ed2145f0fde..527eda1edbb 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -185,6 +185,9 @@ void Scene::process() {
void Scene::onStateEnter(const NancyState::NancyState prevState) {
if (_state != kInit) {
+ // Picks up a look chosen on the Design Select screen while we were away
+ applyPlayerCharacter(g_nancy->getPlayerCharacter());
+
registerGraphics();
if (prevState != NancyState::kPause) {
@@ -878,6 +881,83 @@ void Scene::registerGraphics() {
}
}
+bool Scene::changePlayerCharacter(uint characterIndex) {
+ uint previousCharacter = g_nancy->getPlayerCharacter();
+
+ if (!applyPlayerCharacter(characterIndex)) {
+ return false;
+ }
+
+ return true;
+}
+
+bool Scene::applyPlayerCharacter(uint characterIndex) {
+ if (g_nancy->getGameType() < kGameTypeNancy15 || !g_nancy->playerCharacterNeedsReload(characterIndex)) {
+ return false;
+ }
+
+ // The open popups describe the outgoing character, so get them off the
+ // screen while the data they were built from is still around
+ closeActivePopups();
+
+ if (!g_nancy->setPlayerCharacter(characterIndex)) {
+ return false;
+ }
+
+ auto *taskData = GetEngineData(TASK);
+ assert(taskData);
+ _frame.init(taskData->imageName);
+
+ _textbox.init();
+ _inventoryPopup.init();
+ _notebookPopup.init();
+ _cellPhonePopup.init();
+ _conversationPopup.init();
+
+ delete _taskbar;
+ _taskbar = new UI::Taskbar();
+ _taskbar->init();
+ _taskbar->syncFromPuzzleData();
+ _taskbar->updateNotificationStates(_sceneState.currentScene.sceneID);
+
+ if (_camera) {
+ _camera->init();
+ }
+
+ registerGraphics();
+ g_nancy->_graphics->redrawAll();
+
+ return true;
+}
+
+void Scene::changeSceneVideo(const Common::Path &videoFile) {
+ _sceneState.summary.videoFile = videoFile;
+
+ const Common::Path palettePath = !_sceneState.summary.palettes.empty() ?
+ _sceneState.summary.palettes[(byte)_sceneState.currentScene.paletteID] :
+ Common::Path();
+
+ // The replacement covers the same location, so the vertical scroll carries
+ // over, but panning restarts from the video's first frame
+ _sceneState.currentScene.frameID = 0;
+ _viewport.loadVideo(videoFile,
+ 0,
+ _viewport.getCurVerticalScroll(),
+ _sceneState.summary.panningType,
+ _sceneState.summary.videoFormat,
+ palettePath);
+
+ // loadVideo() re-enables every edge, so the scene's own restrictions
+ // have to be reapplied on top of the new video
+ if (_viewport.getFrameCount() <= 1) {
+ _viewport.disableEdges(kLeft | kRight);
+ }
+
+ if (_viewport.getMaxScroll() == 0) {
+ _viewport.disableEdges(kUp | kDown);
+ }
+}
+
void Scene::synchronize(Common::Serializer &ser) {
if (_flags.eventFlags.empty())
init();
@@ -1053,6 +1133,18 @@ void Scene::synchronize(Common::Serializer &ser) {
_taskbar->syncFromPuzzleData();
_taskbar->updateNotificationStates(_sceneState.currentScene.sceneID);
}
+
+ // Nancy15+ builds its popup UI out of the active player character's own
+ // data files, so bring that data back before the widgets are used again.
+ // Only the UI is swapped: the inventory restored above already is the
+ // saved character's own, while the other characters' stay parked in the
+ // PlayerCharacterData.
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (playerChar) {
+ applyPlayerCharacter(playerChar->characterIndex);
+ }
+ }
}
_isRunningAd = false;
@@ -1071,6 +1163,12 @@ UI::Clock *Scene::getClock() {
}
void Scene::init() {
+ // A design may have been picked before the game itself started, so refresh
+ // the engine data the widgets below are built from
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ g_nancy->setPlayerCharacter(g_nancy->getPlayerCharacter());
+ }
+
auto *bootSummary = GetEngineData(BSUM)
auto *hintData = GetEngineData(HINT)
assert(bootSummary);
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 31607b3399f..d6be84df650 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -201,6 +201,17 @@ public:
void registerGraphics();
+ // Nancy15+ AR 134. Hands the game over to another protagonist: swaps in that
+ // character's own copy of the popup UI data and rebuilds every widget built
+ // from it, then swaps the inventories. Returns whether the character
+ // actually changed.
+ bool changePlayerCharacter(uint characterIndex);
+
+ // Replaces the current scene's background video without leaving the scene.
+ // Nancy15+ uses this to show the same location from the newly selected
+ // player character's point of view.
+ void changeSceneVideo(const Common::Path &videoFile);
+
void synchronize(Common::Serializer &serializer);
UI::FullScreenImage &getFrame() { return _frame; }
@@ -292,6 +303,10 @@ private:
// Maps an event flag label to its index in the eventFlags array
int16 eventFlagToIndex(int16 label) const;
+ // Rebuilds the popup UI from a Nancy15+ player character's own data files,
+ // without touching the inventory. Returns whether the character changed.
+ bool applyPlayerCharacter(uint characterIndex);
+
struct SceneState {
SceneSummary summary;
SceneChangeDescription currentScene;
Commit: fe453064ff60af4a7f782b3aa6495a1d6eb57b7d
https://github.com/scummvm/scummvm/commit/fe453064ff60af4a7f782b3aa6495a1d6eb57b7d
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:49+03:00
Commit Message:
NANCY: NANCY15: Implement new eventFlags handling semantics
Changed paths:
engines/nancy/action/datarecords.cpp
diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index d557d35b5cc..b76d20a4d76 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -324,7 +324,23 @@ void ValueTest::execute() {
void EventFlags::readData(Common::SeekableReadStream &stream) {
if (_flagsType == kEventFlags) {
- _flags.readData(stream);
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ // Nancy15 writes only the flags it actually sets, preceded by their
+ // number, instead of a fixed block of 10 descriptions
+ uint16 numFlags = stream.readUint16LE();
+
+ for (uint i = 0; i < numFlags; ++i) {
+ int16 label = stream.readSint16LE();
+ uint16 flag = stream.readUint16LE();
+
+ if (i < ARRAYSIZE(_flags.descs)) {
+ _flags.descs[i].label = label;
+ _flags.descs[i].flag = flag;
+ }
+ }
+ } else {
+ _flags.readData(stream);
+ }
} else {
// Terse version only has 2 flags
_flags.descs[0].label = stream.readSint16LE();
Commit: c4560640f4da68f2916a46b058d1065e16dc1870
https://github.com/scummvm/scummvm/commit/c4560640f4da68f2916a46b058d1065e16dc1870
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:53+03:00
Commit Message:
NANCY: NANCY15: Give each player character their own data
Each player character has his/her own data:
- A separate journal
- A separate inventory
- Separate UI resources (i.e. money, but that's not used in Nancy15)
- A separate UI design, chosen on the Design Select screen
Changed paths:
engines/nancy/action/autotext.cpp
engines/nancy/action/datarecords.cpp
engines/nancy/action/inventoryrecords.cpp
engines/nancy/action/inventoryrecords.h
engines/nancy/puzzledata.cpp
engines/nancy/puzzledata.h
engines/nancy/state/scene.cpp
engines/nancy/state/scene.h
engines/nancy/ui/notebookpopup.cpp
diff --git a/engines/nancy/action/autotext.cpp b/engines/nancy/action/autotext.cpp
index 9707156d702..f724f15ca91 100644
--- a/engines/nancy/action/autotext.cpp
+++ b/engines/nancy/action/autotext.cpp
@@ -105,7 +105,7 @@ void Autotext::execute() {
}
Common::String stringToPush;
- auto &entriesForSurface = journalData->journalEntries[_surfaceID];
+ auto &entriesForSurface = journalData->entries(_surfaceID);
bool foundThisKey = false;
for (auto &entry : entriesForSurface) {
Common::String &stringID = entry.stringID;
diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index b76d20a4d76..0c9ae4798c4 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -469,7 +469,7 @@ void ModifyListEntry::execute() {
JournalData *journalData = (JournalData *)NancySceneState.getPuzzleData(JournalData::getTag());
assert(journalData);
- Common::Array<JournalData::Entry> &array = journalData->journalEntries[_surfaceID];
+ Common::Array<JournalData::Entry> &array = journalData->entries(_surfaceID);
JournalData::Entry *found = nullptr;
for (uint i = 0; i < array.size(); ++i) {
diff --git a/engines/nancy/action/inventoryrecords.cpp b/engines/nancy/action/inventoryrecords.cpp
index 7f6948b1dd9..bc26adc8146 100644
--- a/engines/nancy/action/inventoryrecords.cpp
+++ b/engines/nancy/action/inventoryrecords.cpp
@@ -72,12 +72,17 @@ void AddInventoryNoHS::execute() {
void RemoveInventoryNoHS::readData(Common::SeekableReadStream &stream) {
_itemID = stream.readUint16LE();
+
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ _characterIndex = stream.readByte();
+ }
}
void RemoveInventoryNoHS::execute() {
- if (NancySceneState.hasItem(_itemID) == g_nancy->_true) {
- NancySceneState.removeItemFromInventory(_itemID, false);
- }
+ uint characterIndex = _characterIndex == kPlayerCharacterActive ?
+ g_nancy->getPlayerCharacter() : _characterIndex;
+
+ NancySceneState.removeItemFromCharacterInventory(characterIndex, _itemID);
_isDone = true;
}
diff --git a/engines/nancy/action/inventoryrecords.h b/engines/nancy/action/inventoryrecords.h
index eeb26dff73b..8977901ea1b 100644
--- a/engines/nancy/action/inventoryrecords.h
+++ b/engines/nancy/action/inventoryrecords.h
@@ -41,7 +41,7 @@ protected:
Common::String getRecordTypeName() const override { return "AddInventoryNoHS"; }
};
-// Simply removes an item from the player's inventory.
+// Simply removes an item from a player character's inventory.
class RemoveInventoryNoHS : public ActionRecord {
public:
void readData(Common::SeekableReadStream &stream) override;
@@ -49,6 +49,10 @@ public:
uint _itemID;
+ // Nancy15+ names the character to take the item from; every earlier game
+ // has a single protagonist, so the item always leaves the active inventory
+ byte _characterIndex = kPlayerCharacterActive;
+
protected:
Common::String getRecordTypeName() const override { return "RemoveInventoryNoHS"; }
};
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index f23ad25c9fa..88be7f7c6eb 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -141,8 +141,40 @@ void SimplePuzzleData::synchronize(Common::Serializer &ser) {
ser.syncAsByte(solvedPuzzle);
}
+// PCUI has room for more characters than any game actually ships, so the
+// active one is kept inside the journals we keep
+static uint activeJournalSlot() {
+ return MIN<uint>(g_nancy->getPlayerCharacter(), kMaxPlayerCharacters - 1);
+}
+
+Common::Array<JournalData::Entry> &JournalData::entries(uint16 surfaceID) {
+ return journalEntries[activeJournalSlot()][surfaceID];
+}
+
+bool JournalData::hasEntries(uint16 surfaceID) const {
+ return journalEntries[activeJournalSlot()].contains(surfaceID);
+}
+
+void JournalData::inheritEntries(uint from, uint to) {
+ if (from < kMaxPlayerCharacters && to < kMaxPlayerCharacters) {
+ journalEntries[to] = journalEntries[from];
+ }
+}
+
void JournalData::synchronize(Common::Serializer &ser) {
- uint16 numEntries = journalEntries.size();
+ syncOneJournal(ser, journalEntries[0]);
+
+ // Nancy15+ protagonists each keep their own journal. Only their slots are
+ // written, so the save format of every earlier game is untouched.
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ for (uint i = 1; i < kMaxPlayerCharacters; ++i) {
+ syncOneJournal(ser, journalEntries[i]);
+ }
+ }
+}
+
+void JournalData::syncOneJournal(Common::Serializer &ser, Common::HashMap<uint16, Common::Array<Entry>> &journal) {
+ uint16 numEntries = journal.size();
ser.syncAsUint16LE(numEntries);
if (ser.isLoading()) {
@@ -151,7 +183,7 @@ void JournalData::synchronize(Common::Serializer &ser) {
ser.syncAsUint16LE(id);
uint16 numStrings = 0;
ser.syncAsUint16LE(numStrings);
- auto &entry = journalEntries[id];
+ auto &entry = journal[id];
for (uint j = 0; j < numStrings; ++j) {
entry.push_back(Entry());
ser.syncString(entry.back().stringID);
@@ -169,7 +201,7 @@ void JournalData::synchronize(Common::Serializer &ser) {
}
}
} else {
- for (auto &a : journalEntries) {
+ for (auto &a : journal) {
uint16 id = a._key;
ser.syncAsUint16LE(id);
uint16 numStrings = a._value.size();
@@ -466,6 +498,14 @@ void TimerData::synchronize(Common::Serializer &ser) {
}
}
+Common::Array<int32> &UIResourceData::getCharacterValues(uint character) {
+ if (character >= characterValues.size()) {
+ characterValues.resize(character + 1);
+ }
+
+ return characterValues[character];
+}
+
void UIResourceData::synchronize(Common::Serializer &ser) {
ser.syncAsByte(seeded);
@@ -478,6 +518,34 @@ void UIResourceData::synchronize(Common::Serializer &ser) {
for (uint16 i = 0; i < numValues; ++i) {
ser.syncAsSint32LE(values[i]);
}
+
+ // Only Nancy15 has more than one protagonist, so no earlier game's saves
+ // carry this block -- and the chunks are written back to back, so reading
+ // it where it was never written would desync the ones after
+ if (g_nancy->getGameType() < kGameTypeNancy15) {
+ return;
+ }
+
+ uint16 numCharacters = (uint16)characterValues.size();
+ ser.syncAsUint16LE(numCharacters);
+ if (ser.isLoading()) {
+ characterValues.clear();
+ characterValues.resize(numCharacters);
+ }
+
+ for (uint16 i = 0; i < numCharacters; ++i) {
+ Common::Array<int32> &characterSet = characterValues[i];
+
+ numValues = (uint16)characterSet.size();
+ ser.syncAsUint16LE(numValues);
+ if (ser.isLoading()) {
+ characterSet.resize(numValues);
+ }
+
+ for (uint16 j = 0; j < numValues; ++j) {
+ ser.syncAsSint32LE(characterSet[j]);
+ }
+ }
}
void TaskbarData::synchronize(Common::Serializer &ser) {
@@ -499,8 +567,57 @@ void TaskbarData::synchronize(Common::Serializer &ser) {
}
}
+PlayerCharacterData::Inventory &PlayerCharacterData::getInventory(uint character) {
+ if (character >= inventories.size()) {
+ inventories.resize(character + 1);
+ }
+
+ return inventories[character];
+}
+
void PlayerCharacterData::synchronize(Common::Serializer &ser) {
ser.syncAsUint16LE(characterIndex);
+
+ for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
+ ser.syncString(designs[i]);
+ }
+
+ uint16 numInventories = inventories.size();
+ ser.syncAsUint16LE(numInventories);
+
+ if (ser.isLoading()) {
+ inventories.clear();
+ inventories.resize(numInventories);
+ }
+
+ for (uint i = 0; i < numInventories; ++i) {
+ Inventory &inventory = inventories[i];
+
+ ser.syncAsByte(inventory.isValid);
+ ser.syncAsSint16LE(inventory.heldItem);
+
+ uint16 numItems = inventory.items.size();
+ ser.syncAsUint16LE(numItems);
+ if (ser.isLoading()) {
+ inventory.items.resize(numItems);
+ inventory.disabledItems.resize(numItems);
+ }
+
+ uint16 orderSize = inventory.order.size();
+ ser.syncAsUint16LE(orderSize);
+ if (ser.isLoading()) {
+ inventory.order.resize(orderSize);
+ }
+
+ if (numItems) {
+ ser.syncArray(inventory.items.data(), numItems, Common::Serializer::Byte);
+ ser.syncArray(inventory.disabledItems.data(), numItems, Common::Serializer::Byte);
+ }
+
+ if (orderSize) {
+ ser.syncArray(inventory.order.data(), orderSize, Common::Serializer::Sint16LE);
+ }
+ }
}
void WordFindPuzzleData::synchronize(Common::Serializer &ser) {
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index c6c868f734e..3f7153b995b 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -196,7 +196,20 @@ struct JournalData : public PuzzleData {
static constexpr uint32 getTag() { return MKTAG('J', 'O', 'U', 'R'); }
virtual void synchronize(Common::Serializer &ser);
- Common::HashMap<uint16, Common::Array<Entry>> journalEntries;
+ // From Nancy15 every protagonist keeps their own journal, so entries are
+ // always reached through the active player character's slot. Earlier games
+ // have a single character and only ever touch slot 0.
+ Common::Array<Entry> &entries(uint16 surfaceID);
+ bool hasEntries(uint16 surfaceID) const;
+
+ // Hands the journal of one character to another. Nancy15 seeds a Hardy
+ // boy's journal from his brother's the first time he is played.
+ void inheritEntries(uint from, uint to);
+
+ Common::HashMap<uint16, Common::Array<Entry>> journalEntries[kMaxPlayerCharacters];
+
+private:
+ static void syncOneJournal(Common::Serializer &ser, Common::HashMap<uint16, Common::Array<Entry>> &journal);
};
// Contains variables that can be read and modified through action records.
@@ -342,6 +355,14 @@ struct UIResourceData : public PuzzleData {
// Set true once seeded from UIRC, so a loaded save isn't re-seeded.
bool seeded = false;
Common::Array<int32> values;
+
+ // Nancy15+ gives every protagonist their own resources, seeded from their
+ // own UIRC. `values` holds the active character's; these are the others'.
+ // An empty entry means that character has never been played.
+ Common::Array<Common::Array<int32>> characterValues;
+
+ // Grows the array as needed
+ Common::Array<int32> &getCharacterValues(uint character);
};
// Nancy 10+ taskbar button-disable overrides, set by AR 29 (ControlUIItems).
@@ -375,14 +396,32 @@ struct TaskbarData : public PuzzleData {
// Nancy15+ active player character (Nancy / Frank / Joe), selected by AR 134.
// The whole popup UI is rebuilt from the character's own data files, so the
// selection has to survive a save/load for the right UI to come back.
+// Every character also carries their own inventory: the active character's is
+// the live one inside Scene, while the other characters' are parked here.
struct PlayerCharacterData : public PuzzleData {
+ struct Inventory {
+ bool isValid = false; // False until the character has been played
+ int16 heldItem = -1;
+ Common::Array<byte> items;
+ Common::Array<byte> disabledItems;
+ Common::Array<int16> order; // Display order of the inventory popup
+ };
+
PlayerCharacterData() {}
virtual ~PlayerCharacterData() {}
static constexpr uint32 getTag() { return MKTAG('P', 'C', 'H', 'R'); }
virtual void synchronize(Common::Serializer &ser);
+ // Grows the array as needed, so a character that has never been played
+ // still gets an (empty) inventory
+ Inventory &getInventory(uint character);
+
uint16 characterIndex = 0;
+ Common::Array<Inventory> inventories;
+ // The look each character wears, chosen on the Design Select screen.
+ // Empty means their PCUI default.
+ Common::String designs[kMaxPlayerCharacters];
};
// Nancy13+ WordFindPuzzle (AR 170). The puzzle is solved one word at a time across
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 527eda1edbb..7945a138e8c 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -465,6 +465,42 @@ void Scene::removeItemFromInventory(int16 id, bool pickUp) {
}
}
+void Scene::removeItemFromCharacterInventory(uint characterIndex, int16 id) {
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ if (hasItem(id) == g_nancy->_true) {
+ removeItemFromInventory(id, false);
+ }
+
+ return;
+ }
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (!playerChar) {
+ return;
+ }
+
+ // A character who hasn't been played yet owns nothing to take away
+ PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+ if (!inventory.isValid) {
+ return;
+ }
+
+ if ((uint)id < inventory.items.size()) {
+ inventory.items[id] = g_nancy->_false;
+ }
+
+ for (uint i = 0; i < inventory.order.size(); ++i) {
+ if (inventory.order[i] == id) {
+ inventory.order.remove_at(i);
+ break;
+ }
+ }
+
+ if (inventory.heldItem == id) {
+ inventory.heldItem = -1;
+ }
+}
+
void Scene::setHeldItem(int16 id) {
_flags.heldItem = id; g_nancy->_cursor->setCursorItemID(id);
}
@@ -490,6 +526,43 @@ byte Scene::hasItem(int16 id) const {
}
}
+byte Scene::hasCharacterItem(uint characterIndex, int16 id) {
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ return hasItem(id);
+ }
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (!playerChar) {
+ return g_nancy->_false;
+ }
+
+ const PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+ if (inventory.heldItem == id) {
+ return g_nancy->_true;
+ }
+
+ if (id >= 0 && (uint)id < inventory.items.size()) {
+ return inventory.items[id];
+ }
+
+ return g_nancy->_false;
+}
+
+int32 Scene::getCharacterUIResource(uint characterIndex, uint index) {
+ if (characterIndex == g_nancy->getPlayerCharacter()) {
+ return getUIResource(index);
+ }
+
+ auto *resourceData = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
+ if (!resourceData) {
+ return 0;
+ }
+
+ // A character who hasn't been played yet has no resources of their own yet
+ const Common::Array<int32> &characterSet = resourceData->getCharacterValues(characterIndex);
+ return index < characterSet.size() ? characterSet[index] : 0;
+}
+
void Scene::installInventorySoundOverride(byte command, const SoundDescription &sound, const Common::String &caption, uint16 itemID) {
InventorySoundOverride newOverride;
@@ -888,9 +961,123 @@ bool Scene::changePlayerCharacter(uint characterIndex) {
return false;
}
+ // Each protagonist carries their own items and resources, so the outgoing
+ // character's are parked and the incoming character's are made live
+ storeCharacterInventory(previousCharacter);
+ storeCharacterResources(previousCharacter);
+ inheritBrotherProgress(characterIndex);
+ loadCharacterInventory(characterIndex);
+ loadCharacterResources(characterIndex);
+
return true;
}
+void Scene::inheritBrotherProgress(uint characterIndex) {
+ if (characterIndex != kPlayerCharacterFrank && characterIndex != kPlayerCharacterJoe) {
+ return;
+ }
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ auto *journalData = (JournalData *)getPuzzleData(JournalData::getTag());
+ if (!playerChar || !journalData) {
+ return;
+ }
+
+ // The Hardy boys work the case as a team, so whichever brother is played
+ // second takes over the notes the other has already made instead of
+ // starting a fresh journal. Nancy always keeps her own. Their resources
+ // (the money they carry) pass over the same way; their items don't.
+ const uint brother = characterIndex == kPlayerCharacterFrank ? kPlayerCharacterJoe : kPlayerCharacterFrank;
+ if (!playerChar->getInventory(characterIndex).isValid && playerChar->getInventory(brother).isValid) {
+ journalData->inheritEntries(brother, characterIndex);
+
+ auto *resourceData = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
+ if (resourceData) {
+ resourceData->getCharacterValues(characterIndex) = resourceData->getCharacterValues(brother);
+ }
+ }
+}
+
+void Scene::storeCharacterInventory(uint characterIndex) {
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (!playerChar) {
+ return;
+ }
+
+ PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+ inventory.isValid = true;
+ inventory.heldItem = _flags.heldItem;
+ inventory.items = _flags.items;
+ inventory.disabledItems = _flags.disabledItems;
+ inventory.order = _inventoryBox.getOrder();
+}
+
+void Scene::loadCharacterInventory(uint characterIndex) {
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (!playerChar) {
+ return;
+ }
+
+ const uint numItems = g_nancy->getStaticData().numItems;
+ PlayerCharacterData::Inventory &inventory = playerChar->getInventory(characterIndex);
+
+ if (inventory.isValid) {
+ _flags.items = inventory.items;
+ _flags.disabledItems = inventory.disabledItems;
+ _inventoryBox.getOrder() = inventory.order;
+ setHeldItem(inventory.heldItem);
+ } else {
+ // A character that hasn't been played yet starts out empty-handed
+ _flags.items.clear();
+ _flags.disabledItems.clear();
+ _inventoryBox.getOrder().clear();
+ setHeldItem(-1);
+ }
+
+ _flags.items.resize(numItems, g_nancy->_false);
+ _flags.disabledItems.resize(numItems, 0);
+
+ if (_inventoryPopup.isOpen()) {
+ _inventoryPopup.refreshGrid();
+ }
+}
+
+void Scene::storeCharacterResources(uint characterIndex) {
+ auto *resourceData = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
+ if (!resourceData || !resourceData->seeded) {
+ return;
+ }
+
+ resourceData->getCharacterValues(characterIndex) = resourceData->values;
+}
+
+void Scene::loadCharacterResources(uint characterIndex) {
+ auto *resourceData = (UIResourceData *)getPuzzleData(UIResourceData::getTag());
+ if (!resourceData) {
+ return;
+ }
+
+ Common::Array<int32> &characterSet = resourceData->getCharacterValues(characterIndex);
+ resourceData->values = characterSet;
+
+ // A character who hasn't been played yet starts from the resource values in
+ // their own UIRC, which the switch has just loaded
+ resourceData->seeded = !characterSet.empty();
+}
+
+void Scene::setPlayerCharacterDesign(uint characterIndex, const Common::String &designName) {
+ g_nancy->setPlayerCharacterDesign(characterIndex, designName);
+
+ auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
+ if (playerChar && characterIndex < kMaxPlayerCharacters) {
+ playerChar->designs[characterIndex] = designName;
+ }
+
+ // The rebuild is left to onStateEnter(). The Design Select screen is a
+ // different state, and tearing the scene's widgets down from underneath it
+ // would draw them over that screen for a frame.
+}
+
bool Scene::applyPlayerCharacter(uint characterIndex) {
if (g_nancy->getGameType() < kGameTypeNancy15 || !g_nancy->playerCharacterNeedsReload(characterIndex)) {
return false;
@@ -1142,6 +1329,10 @@ void Scene::synchronize(Common::Serializer &ser) {
if (g_nancy->getGameType() >= kGameTypeNancy15) {
auto *playerChar = (PlayerCharacterData *)getPuzzleData(PlayerCharacterData::getTag());
if (playerChar) {
+ for (uint i = 0; i < kMaxPlayerCharacters; ++i) {
+ g_nancy->setPlayerCharacterDesign(i, playerChar->designs[i]);
+ }
+
applyPlayerCharacter(playerChar->characterIndex);
}
}
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index d6be84df650..31b157ec369 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -143,6 +143,13 @@ public:
void addItemToInventory(int16 id);
void removeItemFromInventory(int16 id, bool pickUp = true);
+
+ // 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 removeItemFromCharacterInventory(uint characterIndex, int16 id);
+ byte hasCharacterItem(uint characterIndex, int16 id);
+ int32 getCharacterUIResource(uint characterIndex, uint index);
int16 getHeldItem() const { return _flags.heldItem; }
void setHeldItem(int16 id);
void setNoHeldItem();
@@ -207,6 +214,10 @@ public:
// actually changed.
bool changePlayerCharacter(uint characterIndex);
+ // Nancy15+ Design Select screen. Records the look a character wears; the UI
+ // is rebuilt from it the next time the scene is entered.
+ void setPlayerCharacterDesign(uint characterIndex, const Common::String &designName);
+
// Replaces the current scene's background video without leaving the scene.
// Nancy15+ uses this to show the same location from the newly selected
// player character's point of view.
@@ -307,6 +318,18 @@ private:
// without touching the inventory. Returns whether the character changed.
bool applyPlayerCharacter(uint characterIndex);
+ // Nancy15+ per-character inventories and UI resources. The active
+ // character's are the live ones (_flags plus the inventory box order, and
+ // UIResourceData::values); the others are parked in the same puzzle data.
+ void storeCharacterInventory(uint characterIndex);
+ void loadCharacterInventory(uint characterIndex);
+ void storeCharacterResources(uint characterIndex);
+ void loadCharacterResources(uint characterIndex);
+
+ // Seeds a Hardy boy's journal and UI resources from his brother's the first
+ // time he is played.
+ void inheritBrotherProgress(uint characterIndex);
+
struct SceneState {
SceneSummary summary;
SceneChangeDescription currentScene;
diff --git a/engines/nancy/ui/notebookpopup.cpp b/engines/nancy/ui/notebookpopup.cpp
index c78eccb5d3d..ac07e65bd3d 100644
--- a/engines/nancy/ui/notebookpopup.cpp
+++ b/engines/nancy/ui/notebookpopup.cpp
@@ -496,13 +496,13 @@ void NotebookPopup::buildTextLines() {
return;
}
- if (!journalData->journalEntries.contains(surfaceID))
+ if (!journalData->hasEntries(surfaceID))
return;
// Newest-first. All entries go into one addTextLine â separate
// calls would put every mark on its own "first line" and stack
// them at the textbox top.
- const Common::Array<JournalData::Entry> &entries = journalData->journalEntries[surfaceID];
+ const Common::Array<JournalData::Entry> &entries = journalData->entries(surfaceID);
Common::String combined;
for (int i = (int)entries.size() - 1; i >= 0; --i) {
Common::String stringID = entries[i].stringID;
@@ -616,10 +616,10 @@ void NotebookPopup::redrawScroll() {
void NotebookPopup::buildCheckboxRects(const Common::Rect &localTextRect, int scrollY, int visibleH) {
JournalData *journalData = (JournalData *)NancySceneState.getPuzzleData(JournalData::getTag());
- if (!journalData || !journalData->journalEntries.contains(kNotebookTabTasks)) {
+ if (!journalData || !journalData->hasEntries(kNotebookTabTasks)) {
return;
}
- const Common::Array<JournalData::Entry> &entries = journalData->journalEntries[kNotebookTabTasks];
+ const Common::Array<JournalData::Entry> &entries = journalData->entries(kNotebookTabTasks);
const Common::Rect visibleWindow(localTextRect.left, localTextRect.top,
localTextRect.left + localTextRect.width(),
@@ -650,10 +650,10 @@ void NotebookPopup::buildCheckboxRects(const Common::Rect &localTextRect, int sc
void NotebookPopup::toggleCheckbox(uint entryIndex) {
JournalData *journalData = (JournalData *)NancySceneState.getPuzzleData(JournalData::getTag());
- if (!journalData || !journalData->journalEntries.contains(kNotebookTabTasks)) {
+ if (!journalData || !journalData->hasEntries(kNotebookTabTasks)) {
return;
}
- Common::Array<JournalData::Entry> &entries = journalData->journalEntries[kNotebookTabTasks];
+ Common::Array<JournalData::Entry> &entries = journalData->entries(kNotebookTabTasks);
if (entryIndex >= entries.size() || entries[entryIndex].mark != 7) {
return;
}
Commit: 7f23d9c99aaf88b7deb7f5162bb0d8e1aad12185
https://github.com/scummvm/scummvm/commit/7f23d9c99aaf88b7deb7f5162bb0d8e1aad12185
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:58:57+03:00
Commit Message:
NANCY: NANCY15: Implement new player character AR dependency
Changed paths:
engines/nancy/action/actionmanager.cpp
engines/nancy/action/actionrecord.h
diff --git a/engines/nancy/action/actionmanager.cpp b/engines/nancy/action/actionmanager.cpp
index 7165ef35d71..0f388cd1d35 100644
--- a/engines/nancy/action/actionmanager.cpp
+++ b/engines/nancy/action/actionmanager.cpp
@@ -30,6 +30,8 @@
#include "engines/nancy/font.h"
#include "engines/nancy/graphics.h"
+#include "engines/nancy/puzzledata.h"
+
#include "engines/nancy/action/actionmanager.h"
#include "engines/nancy/action/actionrecord.h"
@@ -317,6 +319,17 @@ static bool evaluateValueTestDependency(const DependencyRecord &dep) {
}
}
+// Nancy15+ dependencies that act on a player character name them in the
+// otherwise unused hours field, with kPlayerCharacterActive standing for
+// whoever is being played at the time.
+static uint dependencyCharacter(const DependencyRecord &dep) {
+ if (g_nancy->getGameType() >= kGameTypeNancy15 && dep.hours >= 0 && dep.hours != kPlayerCharacterActive) {
+ return dep.hours;
+ }
+
+ return g_nancy->getPlayerCharacter();
+}
+
void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &record, bool doNotCheckCursor) {
if (dep.children.size()) {
// Recursively process child dependencies
@@ -370,7 +383,7 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
dep.satisfied = true;
break;
case DependencyType::kInventory:
- dep.satisfied = NancySceneState.hasItem(dep.label) == dep.condition;
+ dep.satisfied = NancySceneState.hasCharacterItem(dependencyCharacter(dep), dep.label) == dep.condition;
break;
case DependencyType::kEvent:
@@ -477,7 +490,7 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
if (g_nancy->getGameType() >= kGameTypeNancy12) {
// Nancy12 repurposed dependency type 10 as a resource check (e.g. the
// car's gas gauge): resource value vs. threshold, by condition modifier.
- int32 resVal = NancySceneState.getUIResource(dep.label);
+ int32 resVal = NancySceneState.getCharacterUIResource(dependencyCharacter(dep), dep.label);
int32 threshold = dep.milliseconds;
switch (dep.condition) {
case 0: // equal
@@ -665,6 +678,12 @@ void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &recor
break;
case DependencyType::kDefaultAR:
dep.satisfied = !_previousRecordWasExecuted;
+ break;
+ case DependencyType::kPlayerCharacter:
+ // Nancy15+ only: gates a record on who is being played, so the
+ // three protagonists can share a scene and each get their own ARs
+ dep.satisfied = (g_nancy->getPlayerCharacter() == (uint)dep.label) == (dep.condition != 0);
+
break;
default:
warning("Unimplemented Dependency type %i", (int)dep.type);
diff --git a/engines/nancy/action/actionrecord.h b/engines/nancy/action/actionrecord.h
index 8ef239435d8..f6ee7cbc1e6 100644
--- a/engines/nancy/action/actionrecord.h
+++ b/engines/nancy/action/actionrecord.h
@@ -70,7 +70,8 @@ enum struct DependencyType : int16 {
kTimerIsActive = 22, // Nancy11+ software-timer slot is running/counting
kTimerEqualsDependencyTime = 23, // The next three compare a running software
kTimerBelowDependencyTime = 24, // timer's elapsed time against the dependency's
- kTimerAboveDependencyTime = 25 // own time, and only while that slot is running
+ kTimerAboveDependencyTime = 25, // own time, and only while that slot is running
+ kPlayerCharacter = 26 // Nancy15+ which protagonist is being played
};
// Describes a condition that needs to be fulfilled before the
Commit: 9d2afa8740be6f2dd65ff46ce1b7f5feaaf8ea47
https://github.com/scummvm/scummvm/commit/9d2afa8740be6f2dd65ff46ce1b7f5feaaf8ea47
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-08T02:59:00+03:00
Commit Message:
NANCY: NANCY15: Add new button for the Design Select screen
This has been added to the setup screen
Changed paths:
engines/nancy/commontypes.h
engines/nancy/enginedata.cpp
engines/nancy/enginedata.h
engines/nancy/state/setupmenu.cpp
engines/nancy/state/setupmenu.h
diff --git a/engines/nancy/commontypes.h b/engines/nancy/commontypes.h
index d2178f784bc..455ad93b485 100644
--- a/engines/nancy/commontypes.h
+++ b/engines/nancy/commontypes.h
@@ -165,6 +165,7 @@ enum NancyState {
kBoot, kLogo, kCredits, kMap,
kMainMenu, kLoadSave, kSetup,
kHelp, kScene, kSaveDialog,
+ kDesignSelect, // Nancy15 only
// Not real states
kNone,
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 40cebf1f5cd..3c91921bd37 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -479,6 +479,30 @@ SET::SET(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
}
readRectArray(*chunkStream, _scrollbarBounds, 3);
+
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ // Nancy15 added a button to the setup screen. Nothing in the chunk says
+ // how many there are, so take the count from the space left over once
+ // everything that follows them is accounted for: the Done button's
+ // highlight (16), three scrollbar sources (48), the scrollbars' centre
+ // positions (18) and three menu sound descriptions (141).
+ static const int32 kBytesAfterButtons = 16 + 48 + 18 + 141;
+ static const int32 kBytesPerButton = 2 * 16; // one dest and one source
+
+ // The extra button brings a second highlight source with it
+ const int32 remaining = (int32)chunkStream->size() - (int32)chunkStream->pos() - kBytesAfterButtons - 16;
+ if (remaining >= kBytesPerButton) {
+ numButtons = remaining / kBytesPerButton;
+
+ if (remaining % kBytesPerButton > 1) {
+ warning("SET chunk has %d bytes left over after %u buttons, the setup screen may be misread",
+ remaining % kBytesPerButton, numButtons);
+ }
+ } else {
+ warning("Unexpected SET chunk size %d, the setup screen will be misread", (int)chunkStream->size());
+ }
+ }
+
readRectArray(*chunkStream, _buttonDests, numButtons);
readRectArray(*chunkStream, _buttonDownSrcs, numButtons);
@@ -486,6 +510,10 @@ SET::SET(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
readRect(*chunkStream, _doneButtonHighlightSrc);
}
+ if (g_nancy->getGameType() >= kGameTypeNancy15) {
+ readRect(*chunkStream, _extraButtonHighlightSrc);
+ }
+
readRectArray(*chunkStream, _scrollbarSrcs, 3);
_scrollbarsCenterYPos.resize(3);
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index fc7748d6588..df7173b8e38 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -267,6 +267,8 @@ struct SET : public EngineData {
Common::Array<Common::Rect> _buttonDests;
Common::Array<Common::Rect> _buttonDownSrcs;
Common::Rect _doneButtonHighlightSrc;
+ // Nancy15's extra (Design Select) button has a highlight of its own
+ Common::Rect _extraButtonHighlightSrc;
Common::Array<Common::Rect> _scrollbarSrcs;
Common::Array<uint16> _scrollbarsCenterYPos;
diff --git a/engines/nancy/state/setupmenu.cpp b/engines/nancy/state/setupmenu.cpp
index 21842ed751f..17ae46316bf 100644
--- a/engines/nancy/state/setupmenu.cpp
+++ b/engines/nancy/state/setupmenu.cpp
@@ -39,6 +39,9 @@ DECLARE_SINGLETON(Nancy::State::SetupMenu);
namespace Nancy {
namespace State {
+// Toggles the engine has ConfMan keys for; see getToggleConfManKey()
+static const uint kNumKnownToggles = 2;
+
void SetupMenu::process() {
switch (_state) {
case kInit:
@@ -75,6 +78,10 @@ void SetupMenu::registerGraphics() {
if (_exitButton) {
_exitButton->registerGraphics();
}
+
+ if (_designSelectButton) {
+ _designSelectButton->registerGraphics();
+ }
}
const Common::String SetupMenu::getToggleConfManKey(uint id) {
@@ -141,8 +148,28 @@ void SetupMenu::init() {
}
}
- _toggles.resize(_setupData->_buttonDests.size() - 1);
- for (uint i = 0; i < _setupData->_buttonDests.size() - 1; ++i) {
+ // The buttons run toggles first, then Done. Nancy15 appends an "Interface
+ // Designs" button after Done, which opens the Design Select screen. Keying
+ // on LDSN keeps Nancy16, which dropped both the chunk and the screen, out
+ // of this.
+ const uint numButtons = _setupData->_buttonDests.size();
+ uint numToggles = numButtons - 1;
+ uint doneIndex = numButtons - 1;
+
+ const LDSN *designData = GetEngineData(LDSN)
+ if (designData && numToggles > kNumKnownToggles && numButtons <= _setupData->_buttonDownSrcs.size()) {
+ numToggles = numButtons - 2;
+ doneIndex = numButtons - 2;
+
+ _designSelectButton.reset(new UI::Button(5, _background._drawSurface,
+ _setupData->_buttonDownSrcs[numButtons - 1], _setupData->_buttonDests[numButtons - 1],
+ _setupData->_extraButtonHighlightSrc));
+ _designSelectButton->init();
+ _designSelectButton->setVisible(false);
+ }
+
+ _toggles.resize(numToggles);
+ for (uint i = 0; i < numToggles; ++i) {
_toggles[i].reset(new UI::Toggle(5, _background._drawSurface,
_setupData->_buttonDownSrcs[i], _setupData->_buttonDests[i]));
@@ -169,7 +196,7 @@ void SetupMenu::init() {
_scrollbars[2]->setPosition(ConfMan.getInt("sfx_volume") / 255.0);
_exitButton.reset(new UI::Button(5, _background._drawSurface,
- _setupData->_buttonDownSrcs.back(), _setupData->_buttonDests.back(),
+ _setupData->_buttonDownSrcs[doneIndex], _setupData->_buttonDests[doneIndex],
_setupData->_doneButtonHighlightSrc));
_exitButton->init();
_exitButton->setVisible(false);
@@ -225,6 +252,19 @@ void SetupMenu::run() {
}
}
+ if (_designSelectButton) {
+ _designSelectButton->handleInput(input);
+
+ if (_designSelectButton->_isClicked) {
+ g_nancy->_sound->playSound("BUOK");
+
+ // Keep hold of the state this menu will return to, so closing the
+ // design screen and then this menu lands back in the game
+ g_nancy->setState(NancyState::kDesignSelect, g_nancy->getPreviousState());
+ return;
+ }
+ }
+
if (_exitButton) {
_exitButton->handleInput(input);
diff --git a/engines/nancy/state/setupmenu.h b/engines/nancy/state/setupmenu.h
index b1cbfc744a3..6c216e8e61a 100644
--- a/engines/nancy/state/setupmenu.h
+++ b/engines/nancy/state/setupmenu.h
@@ -60,6 +60,8 @@ private:
Common::Array<Common::ScopedPtr<UI::Toggle>> _toggles;
Common::Array<Common::ScopedPtr<UI::Scrollbar>> _scrollbars;
Common::ScopedPtr<UI::Button> _exitButton;
+ // Nancy15 only: opens the Design Select screen
+ Common::ScopedPtr<UI::Button> _designSelectButton;
const SET *_setupData = nullptr;
};
More information about the Scummvm-git-logs
mailing list