Files
scummvm-cursorfix/engines/nancy/action/actionmanager.cpp
2026-02-02 04:50:13 +01:00

724 lines
22 KiB
C++

/* 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/serializer.h"
#include "common/stack.h"
#include "common/config-manager.h"
#include "common/random.h"
#include "engines/nancy/nancy.h"
#include "engines/nancy/input.h"
#include "engines/nancy/sound.h"
#include "engines/nancy/font.h"
#include "engines/nancy/graphics.h"
#include "engines/nancy/action/actionmanager.h"
#include "engines/nancy/action/actionrecord.h"
#include "engines/nancy/action/secondarymovie.h"
#include "engines/nancy/action/soundrecords.h"
#include "engines/nancy/state/scene.h"
namespace Nancy {
namespace Action {
ActionManager::~ActionManager() {
clearActionRecords();
}
void ActionManager::handleInput(NancyInput &input) {
bool setHoverCursor = false;
for (auto &rec : _records) {
if (rec->_isActive && !rec->_isDone) {
// First, loop through all records and handle special cases.
// This needs to be a separate loop to handle Overlays as a special case
// (see note in Overlay::handleInput())
rec->handleInput(input);
}
}
for (auto &rec : _records) {
if ( rec->_isActive &&
!rec->_isDone &&
rec->_hasHotspot &&
rec->_hotspot.isValidRect() && // Needed for nancy2 scene 1600
NancySceneState.getViewport().convertViewportToScreen(rec->_hotspot).contains(input.mousePos)) {
if (!setHoverCursor) {
// Hotspots may overlap, but we want the hover cursor for the first one we encounter
// This fixes the stairs in nancy3
g_nancy->_cursor->setCursorType(rec->getHoverCursor());
setHoverCursor = true;
}
if (input.input & NancyInput::kLeftMouseButtonUp) {
input.input &= ~NancyInput::kLeftMouseButtonUp;
rec->_cursorDependency = nullptr;
processDependency(rec->_dependencies, *rec, false);
if (!rec->_dependencies.satisfied) {
if (rec->_cursorDependency != nullptr) {
NancySceneState.playItemCantSound(
rec->_cursorDependency->label,
(g_nancy->getGameType() <= kGameTypeNancy2 && rec->_cursorDependency->condition == kCursInvNotHolding));
} else {
continue;
}
} else {
rec->_state = ActionRecord::ExecutionState::kActionTrigger;
input.eatMouseInput();
if (rec->_cursorDependency) {
int16 item = rec->_cursorDependency->label;
// Re-add the object to the inventory unless it's marked as a one-time use
if (item == NancySceneState.getHeldItem() && item != -1) {
auto *inventoryData = GetEngineData(INV);
assert(inventoryData);
switch (inventoryData->itemDescriptions[item].keepItem) {
case kInvItemKeepAlways :
if (g_nancy->getGameType() >= kGameTypeNancy3) {
// In nancy3 and up this means the object remains in hand, so do nothing
// Older games had the kInvItemReturn behavior instead
break;
}
// fall through
case kInvItemReturn :
NancySceneState.addItemToInventory(item);
// fall through
case kInvItemUseThenLose :
NancySceneState.setHeldItem(-1);
break;
}
}
rec->_cursorDependency = nullptr;
}
}
break;
}
}
}
}
void ActionManager::addNewActionRecord(Common::SeekableReadStream &inputData) {
ActionRecord *newRecord = createAndLoadNewRecord(inputData);
if (!newRecord) {
inputData.seek(0x30);
byte ARType = inputData.readByte();
warning("Action Record type %i is unimplemented or invalid!", ARType);
return;
}
_records.push_back(newRecord);
}
ActionRecord *ActionManager::createAndLoadNewRecord(Common::SeekableReadStream &inputData) {
inputData.seek(0);
char descBuf[0x30];
inputData.read(descBuf, 0x30);
descBuf[0x2F] = '\0';
byte ARType = inputData.readByte();
byte execType = inputData.readByte();
ActionRecord *newRecord = createActionRecord(ARType, &inputData);
if (!newRecord) {
newRecord = new Unimplemented();
}
newRecord->_description = descBuf;
newRecord->_type = ARType;
newRecord->_execType = (ActionRecord::ExecutionType)execType;
newRecord->readData(inputData);
// If the remaining data is less than the total data, there must be dependencies at the end of the chunk
int64 dataRemaining = inputData.size() - inputData.pos();
if (dataRemaining > 0 && newRecord->getRecordTypeName() != "Unimplemented") {
// Each dependency is 12 (up to nancy2) or 16 (nancy3 and up) bytes long
uint singleDepSize = g_nancy->getGameType() <= kGameTypeNancy2 ? 12 : 16;
uint numDependencies = dataRemaining / singleDepSize;
if (dataRemaining % singleDepSize) {
warning("Action record type %u, %s has incorrect read size!\ndescription:\n%s",
newRecord->_type,
newRecord->getRecordTypeName().c_str(),
newRecord->_description.c_str());
delete newRecord;
newRecord = new Unimplemented();
newRecord->_description = descBuf;
newRecord->_type = ARType;
newRecord->_execType = (ActionRecord::ExecutionType)execType;
}
if (numDependencies == 0) {
newRecord->_dependencies.satisfied = true;
}
Common::Stack<DependencyRecord *> depStack;
depStack.push(&newRecord->_dependencies);
// Initialize the dependencies data
for (uint16 i = 0; i < numDependencies; ++i) {
depStack.top()->children.push_back(DependencyRecord());
DependencyRecord &dep = depStack.top()->children.back();
if (singleDepSize == 12) {
dep.type = (DependencyType)inputData.readByte();
dep.label = inputData.readByte();
dep.condition = inputData.readByte();
dep.orFlag = inputData.readByte();
} else if (singleDepSize == 16) {
dep.type = (DependencyType)inputData.readUint16LE();
dep.label = inputData.readUint16LE();
dep.condition = inputData.readUint16LE();
dep.orFlag = inputData.readUint16LE();
}
dep.hours = inputData.readSint16LE();
dep.minutes = inputData.readSint16LE();
dep.seconds = inputData.readSint16LE();
dep.milliseconds = inputData.readSint16LE();
switch (dep.type) {
case DependencyType::kElapsedPlayerTime:
dep.timeData = dep.hours * 3600000 + dep.minutes * 60000;
if (g_nancy->getGameType() < kGameTypeNancy3) {
// Older titles only checked if the time is less than the one in the dependency
dep.condition = 0;
}
break;
case DependencyType::kSceneCount:
break;
case DependencyType::kOpenParenthesis:
depStack.push(&dep);
break;
case DependencyType::kCloseParenthesis:
depStack.top()->children.pop_back();
depStack.pop();
break;
default:
if (dep.hours != -1 || dep.minutes != -1 || dep.seconds != -1) {
dep.timeData = ((dep.hours * 60 + dep.minutes) * 60 + dep.seconds) * 1000 + dep.milliseconds;
}
break;
}
}
} else {
// Set new record to active if it doesn't depend on anything
newRecord->_isActive = true;
}
return newRecord;
}
void ActionManager::processActionRecords() {
bool activeRecordsThisFrame = false;
_activatedRecordsThisFrame.clear();
for (auto record : _records) {
if (record->_isDone) {
continue;
}
// Process dependencies every call. We make sure to ignore cursor dependencies,
// as they are only handled when calling from handleInput()
processDependency(record->_dependencies, *record, record->canHaveHotspot());
record->_isActive = record->_dependencies.satisfied;
if (record->_isActive) {
if(record->_state == ActionRecord::kBegin) {
_activatedRecordsThisFrame.push_back(record);
}
record->execute();
_recordsWereExecuted = true;
activeRecordsThisFrame = true;
}
if (g_nancy->getGameType() >= kGameTypeNancy4 && NancySceneState._state == State::Scene::kLoad) {
// changeScene() must have been called, abort any further processing.
// Both old and new behavior is needed (nancy3 intro narration, nancy4 garden gate)
return;
}
}
if (!activeRecordsThisFrame) {
// No active records were found for this frame.
// This will lead to an infinite loop without
// anything happening, so we reset the
// _recordsWereExecuted flag, to fall back to
// the kDefaultAR dependency. This is needed for
// some scenes in Nancy 8, where SetVolume() is
// called, but no other action records are active.
_recordsWereExecuted = false;
}
synchronizeMovieWithSound();
debugDrawHotspots();
}
void ActionManager::processDependency(DependencyRecord &dep, ActionRecord &record, bool doNotCheckCursor) {
if (dep.children.size()) {
// Recursively process child dependencies
for (uint i = 0; i < dep.children.size(); ++i) {
processDependency(dep.children[i], record, doNotCheckCursor);
}
// An orFlag marks that its corresponding dependency and the one after it
// mutually satisfy each other; if one is satisfied, so is the other. The effect
// can be chained indefinitely (for example, the chiming clock in nancy3)
for (uint i = 0; i < dep.children.size(); ++i) {
if (dep.children[i].orFlag) {
// Found an orFlag, start going down the chain of dependencies with orFlags
bool foundSatisfied = false;
for (uint j = i; j < dep.children.size(); ++j) {
if (dep.children[j].satisfied) {
// A dependency has been satisfied
foundSatisfied = true;
break;
}
if (!dep.children[j].orFlag) {
// orFlag chain ended, no satisfied dependencies
break;
}
}
if (foundSatisfied) {
for (; i < dep.children.size(); ++i) {
dep.children[i].satisfied = true;
if (!dep.children[i].orFlag) {
// Last element of orFlag chain
break;
}
}
}
}
}
// If all children are satisfied, so is the parent
dep.satisfied = true;
for (uint i = 0; i < dep.children.size(); ++i) {
if (!dep.children[i].satisfied) {
dep.satisfied = false;
break;
}
}
} else {
switch (dep.type) {
case DependencyType::kNone:
dep.satisfied = true;
break;
case DependencyType::kInventory:
if (dep.condition == g_nancy->_false) {
// Item not in possession or held
if (NancySceneState._flags.items[dep.label] == g_nancy->_false &&
dep.label != NancySceneState._flags.heldItem) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
} else {
if (NancySceneState._flags.items[dep.label] == g_nancy->_true ||
dep.label == NancySceneState._flags.heldItem) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
}
break;
case DependencyType::kEvent:
if (NancySceneState.getEventFlag(dep.label, dep.condition)) {
// nancy1 has code for some timer array that never gets used
// and is discarded from nancy2 onward
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case DependencyType::kLogic:
if (g_nancy->getGameType() <= kGameTypeNancy2) {
// First few games used 2 for false and 1 for true, but we store them the
// other way around here. So, we need to check for inequality
if (!NancySceneState.getLogicCondition(dep.label, dep.condition)) {
// Wait for specified time before satisfying dependency condition
Time elapsed = NancySceneState._timers.lastTotalTime - NancySceneState._flags.logicConditions[dep.label].timestamp;
if (elapsed >= dep.timeData) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
} else {
dep.satisfied = false;
}
} else {
dep.satisfied = NancySceneState.getLogicCondition(dep.label, dep.condition);
}
break;
case DependencyType::kElapsedGameTime:
if (NancySceneState._timers.lastTotalTime >= dep.timeData) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case DependencyType::kElapsedSceneTime:
if (NancySceneState._timers.sceneTime >= dep.timeData) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case DependencyType::kElapsedPlayerTime: {
// We're only interested in the hours and minutes
Time playerTime = NancySceneState._timers.playerTime.getHours() * 3600000 +
NancySceneState._timers.playerTime.getMinutes() * 60000;
switch (dep.condition) {
case 0:
dep.satisfied = dep.timeData < playerTime;
break;
case 1:
dep.satisfied = dep.timeData > playerTime;
break;
case 2:
dep.satisfied = dep.timeData == playerTime;
}
break;
}
case DependencyType::kSceneCount: {
// Check how many times a scene has been visited.
// This dependency type keeps its data in the time variables
// Note: nancy7 completely flipped the meaning of 1 and 2
int count = NancySceneState._flags.sceneCounts.contains(dep.hours) ?
NancySceneState._flags.sceneCounts[dep.hours] : 0;
switch (dep.milliseconds) {
case 1:
if ( (dep.minutes < count && g_nancy->getGameType() <= kGameTypeNancy6) ||
(dep.minutes > count && g_nancy->getGameType() >= kGameTypeNancy7)) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case 2:
if ( (dep.minutes > count && g_nancy->getGameType() <= kGameTypeNancy6) ||
(dep.minutes < count && g_nancy->getGameType() >= kGameTypeNancy7)) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case 3:
if (dep.minutes == count) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
}
break;
}
case DependencyType::kElapsedPlayerDay:
if (record._days == -1) {
record._days = NancySceneState._timers.playerTime.getDays();
dep.satisfied = true;
break;
}
if (record._days < NancySceneState._timers.playerTime.getDays()) {
record._days = NancySceneState._timers.playerTime.getDays();
// This is not used in nancy3 and up, so it's a safe assumption that we
// do not need to check types recursively
for (uint j = 0; j < record._dependencies.children.size(); ++j) {
if (record._dependencies.children[j].type == DependencyType::kElapsedPlayerTime) {
record._dependencies.children[j].satisfied = false;
}
}
}
break;
case DependencyType::kCursorType: {
if (doNotCheckCursor) {
dep.satisfied = true;
} else {
bool isSatisfied = false;
int heldItem = NancySceneState.getHeldItem();
if (heldItem == -1 && dep.label == kCursStandard) {
isSatisfied = true;
} else {
if (g_nancy->getGameType() <= kGameTypeNancy2 && dep.condition == kCursInvNotHolding) {
// Activate if _not_ holding the specified item. Dropped in nancy3
if (heldItem != dep.label) {
isSatisfied = true;
}
} else {
// Activate if holding the specified item.
if (heldItem == dep.label) {
isSatisfied = true;
}
}
}
dep.satisfied = isSatisfied;
if (isSatisfied) {
// A satisfied dependency must be moved into the _cursorDependency slot, to make sure
// the remove from/re-add to inventory logic works correctly
record._cursorDependency = &dep;
} else {
if (record._cursorDependency == nullptr) {
// However, if the current dependency was not satisfied, we only move it into
// the _cursorDependency slot if nothing else was there before. This ensures
// the "can't" sound played is the first dependency's
record._cursorDependency = &dep;
}
}
}
break;
}
case DependencyType::kPlayerTOD:
if (dep.label == NancySceneState.getPlayerTOD()) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case DependencyType::kTimerLessThanDependencyTime:
if (NancySceneState._timers.timerTime <= dep.timeData) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case DependencyType::kTimerGreaterThanDependencyTime:
if (NancySceneState._timers.timerTime > dep.timeData) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case DependencyType::kDifficultyLevel:
if (dep.condition == NancySceneState._difficulty) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
break;
case DependencyType::kClosedCaptioning:
if (ConfMan.getBool("subtitles")) {
if (dep.condition == 2) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
} else {
if (dep.condition == 1) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
}
break;
case DependencyType::kSound:
if (g_nancy->_sound->isSoundPlaying(dep.label)) {
dep.satisfied = dep.condition == 1;
} else {
dep.satisfied = dep.condition == 0;
}
break;
case DependencyType::kRandom:
// Pick a random number and compare it with the value in condition
// This is only executed once
if (!dep.stopEvaluating) {
if ((int)g_nancy->_randomSource->getRandomNumber(99) < dep.condition) {
dep.satisfied = true;
} else {
dep.satisfied = false;
}
dep.stopEvaluating = true;
}
break;
case DependencyType::kDefaultAR:
// Only execute if no other AR has executed yet
if (_recordsWereExecuted) {
dep.satisfied = false;
} else {
dep.satisfied = true;
}
break;
default:
warning("Unimplemented Dependency type %i", (int)dep.type);
break;
}
}
}
void ActionManager::clearActionRecords() {
for (auto &r : _records) {
delete r;
}
_records.clear();
_recordsWereExecuted = false;
}
void ActionManager::onPause(bool pause) {
for (auto &r : _records) {
if (r->_isActive && !r->_isDone) {
r->onPause(pause);
}
}
}
void ActionManager::synchronize(Common::Serializer &ser) {
// When loading, the records should already have been initialized by scene
for (auto &rec : _records) {
ser.syncAsByte(rec->_isActive);
ser.syncAsByte(rec->_isDone);
// Forcefully re-activate Autotext records, since we need to regenerate the surface
if (ser.isLoading() && g_nancy->getGameType() >= kGameTypeNancy6 && rec->_type == 61) {
rec->_isDone = false;
}
}
}
void ActionManager::synchronizeMovieWithSound() {
// Improvement:
// The original engine had really bad timing issues with AVF videos,
// as it set the next frame time by adding the frame length to the current evaluation
// time, instead of to the time the previous frame was drawn. As a result, all
// movie (and SecondaryVideos) frames play about 12 ms slower than they should.
// This results in some unfortunate issues in nancy4: if we do as the original
// engine did and just make frames 12 ms slower, some dialogue scenes (like scene 1400)
// are very visibly not in sync; also, the entire videocam sequence suffers from
// visible stitches where the scene changes not at the time it was intended to.
// On the other hand, if instead we don't add those 12ms, that same videocam
// sequence has a really nasty sound cutoff in the middle of a character speaking.
// This function intends to fix this issue by subtly manipulating the playback rate
// of the movie so its length ends up matching that of the sound; if the sound rate was
// changed instead, we would get slightly off-pitch dialogue, which would be undesirable.
// The heuristic for catching these cases relies on the scene having a movie and a sound
// record start at the same frame, and have a (valid) scene change to the same scene.
PlaySecondaryMovie *movie = nullptr;
PlaySound *sound = nullptr;
for (uint i = 0; i < _activatedRecordsThisFrame.size(); ++i) {
byte type = _activatedRecordsThisFrame[i]->_type;
// Rely on _type for cheaper type check
if (type == 53) {
movie = dynamic_cast<PlaySecondaryMovie *>(_activatedRecordsThisFrame[i]);
} else if (type == 150 || type == 151 || type == 157) {
sound = dynamic_cast<PlaySound *>(_activatedRecordsThisFrame[i]);
}
if (movie && sound) {
break;
}
}
if (movie && sound && movie->_sound.name != "NO SOUND") {
// A movie and a sound both got activated this frame, check if their scene changes match
if ( movie->_videoSceneChange == PlaySecondaryMovie::kMovieSceneChange &&
movie->_sceneChange.sceneID == sound->_sceneChange.sceneID &&
movie->_sceneChange.sceneID != kNoScene) {
// They match, check how long the sound is...
Audio::Timestamp length = g_nancy->_sound->getLength(sound->_sound);
if (length.msecs() != 0) {
// ..and set the movie's playback speed to match
movie->_decoder->setRate(Common::Rational(movie->_decoder->getDuration().msecs(), length.msecs()));
}
}
}
}
void ActionManager::debugDrawHotspots() {
// Draws a rectangle around (non-puzzle) hotspots as well as the id
// and type of the owning ActionRecord. Hardcoded to font 0 since that's
// the smallest one available in the engine.
RenderObject &obj = NancySceneState._hotspotDebug;
if (ConfMan.getBool("debug_hotspots", Common::ConfigManager::kTransientDomain)) {
const Font *font = g_nancy->_graphics->getFont(0);
assert(font);
uint16 yOffset = NancySceneState.getViewport().getCurVerticalScroll();
obj.setVisible(true);
obj._drawSurface.clear(obj._drawSurface.getTransparentColor());
for (uint i = 0; i < _records.size(); ++i) {
ActionRecord *rec = _records[i];
if (rec->_isActive && !rec->_isDone && rec->_hasHotspot) {
Common::Rect hotspot = rec->_hotspot;
hotspot.translate(0, -yOffset);
hotspot.clip(obj._drawSurface.getBounds());
if (!hotspot.isEmpty()) {
font->drawString(&obj._drawSurface, Common::String::format("%u, %s", i, rec->getRecordTypeName().c_str()),
hotspot.left, hotspot.bottom - font->getFontHeight() - 2, hotspot.width(), 0,
Graphics::kTextAlignCenter, 0, true);
obj._drawSurface.frameRect(hotspot, 0xFFFFFF);
}
}
}
} else {
if (obj.isVisible()) {
obj.setVisible(false);
}
}
}
} // End of namespace Action
} // End of namespace Nancy