scummvm/engines/twp/motor.cpp
Apollon Oikonomopoulos 1317afc79e TWP: ignore empty text lines
The SayLineAt class assumes that lines are non-empty strings and
accesses the first line character liberally. There is however at least
one instance of an empty line returned by the game script that causes
the engine to crash:

    saylineAt: (160,85) text=@25923 color=rgba(1.000000,0.800000,0.000000,1.000000) duration=5.000000
    sayLine 'Long minutes of furious berry picking later...'
    add breakwhilecond name=breakwhiletalking(all) pid=300115, Helpers.bnut (1103)
    add breakwhilecond name=breakwhilesound(-16408) pid=300110, playBearSounds ForestMaze.bnut (444)
    talking @25923: ended
    Resume task: 300115, Helpers.bnut (1103)
    cutsceneOverride
    create cutscene override
    saylineAt: (160,85) text= color=rgba(1.000000,0.800000,0.000000,1.000000) duration=0.000000
    scummvm: ./common/str-base.h:183: Common::BaseString<T>::value_type Common::BaseString<T>::operator[](int) const [with T = char; value_type = char]: Assertion `idx < (int)_size' failed.

The empty line comes from the fadeRoomMessage helper function
(Helpers.bnut:1115) and is meant to reset the talk state after the
cutscene is over, so the engine should be able to handle it without
crashing. Fix this by making SayLineAt::say() return early if called
with an empty script and adjust the cleanup code to handle the fact that
_node might not be initialized due to the early return.
2024-07-26 13:15:51 +02:00

696 lines
18 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/config-manager.h"
#include "twp/twp.h"
#include "twp/detection.h"
#include "twp/object.h"
#include "twp/resmanager.h"
#include "twp/room.h"
#include "twp/squtil.h"
#include "twp/tsv.h"
namespace Twp {
void Motor::update(float elapsed) {
if (!isEnabled())
return;
onUpdate(elapsed);
}
OffsetTo::~OffsetTo() = default;
OffsetTo::OffsetTo(float duration, Common::SharedPtr<Object> obj, const Math::Vector2d &pos, InterpolationMethod im)
: _obj(obj),
_tween(obj->_node->getOffset(), pos, duration, im) {
}
void OffsetTo::onUpdate(float elapsed) {
_tween.update(elapsed);
_obj->_node->setOffset(_tween.current());
if (!_tween.running())
disable();
}
MoveTo::~MoveTo() = default;
MoveTo::MoveTo(float duration, Common::SharedPtr<Object> obj, const Math::Vector2d &pos, InterpolationMethod im)
: _obj(obj),
_tween(obj->_node->getPos(), pos, duration, im) {
}
void MoveTo::onUpdate(float elapsed) {
_tween.update(elapsed);
_obj->_node->setPos(_tween.current());
if (!_tween.running())
disable();
}
AlphaTo::~AlphaTo() = default;
AlphaTo::AlphaTo(float duration, Common::SharedPtr<Object> obj, float to, InterpolationMethod im)
: _obj(obj),
_tween(obj->_node->getAlpha(), to, duration, im) {
}
void AlphaTo::onUpdate(float elapsed) {
_tween.update(elapsed);
float alpha = _tween.current();
_obj->_node->setAlpha(alpha);
if (!_tween.running())
disable();
}
RotateTo::~RotateTo() = default;
RotateTo::RotateTo(float duration, Node *node, float to, InterpolationMethod im)
: _node(node),
_tween(node->getRotation(), to, duration, im) {
}
void RotateTo::onUpdate(float elapsed) {
_tween.update(elapsed);
_node->setRotation(_tween.current());
if (!_tween.running())
disable();
}
RoomRotateTo::~RoomRotateTo() = default;
RoomRotateTo::RoomRotateTo(Common::SharedPtr<Room> room, float to)
: _room(room),
_tween(room->_rotation, to, 0.200f, intToInterpolationMethod(0)) {
}
void RoomRotateTo::onUpdate(float elapsed) {
_tween.update(elapsed);
_room->_rotation = _tween.current();
if (!_tween.running())
disable();
}
ScaleTo::~ScaleTo() = default;
ScaleTo::ScaleTo(float duration, Node *node, float to, InterpolationMethod im)
: _node(node),
_tween(node->getScale().getX(), to, duration, im) {
}
void ScaleTo::onUpdate(float elapsed) {
_tween.update(elapsed);
float x = _tween.current();
_node->setScale(Math::Vector2d(x, x));
if (!_tween.running())
disable();
}
Shake::~Shake() = default;
Shake::Shake(Node *node, float amount)
: _node(node),
_amount(amount) {
}
void Shake::onUpdate(float elapsed) {
_shakeTime += 40.f * elapsed;
_elapsed += elapsed;
_node->setShakeOffset(Math::Vector2d(_amount * cos(_shakeTime + 0.3f), _amount * sin(_shakeTime)));
}
OverlayTo::OverlayTo(float duration, Common::SharedPtr<Room> room, const Color &to)
: _room(room),
_to(to),
_tween(g_twp->_room->getOverlay(), to, duration, InterpolationMethod()) {
}
OverlayTo::~OverlayTo() = default;
void OverlayTo::onUpdate(float elapsed) {
_tween.update(elapsed);
_room->setOverlay(_tween.current());
if (!_tween.running())
disable();
}
ReachAnim::ReachAnim(Common::SharedPtr<Object> actor, Common::SharedPtr<Object> obj)
: _actor(actor), _obj(obj) {
}
ReachAnim::~ReachAnim() = default;
void ReachAnim::playReachAnim() {
Common::String anim = _actor->getAnimName(REACH_ANIMNAME + _obj->getReachAnim());
_actor->play(anim);
}
void ReachAnim::onUpdate(float elapsed) {
switch (_state) {
case 0:
playReachAnim();
_state = 1;
break;
case 1:
_elapsed += elapsed;
if (_elapsed > 0.10)
_state = 2;
break;
case 2:
_actor->stand();
Object::execVerb(_actor);
disable();
_state = 3;
break;
default:
break;
}
}
WalkTo::WalkTo(Common::SharedPtr<Object> obj, const Math::Vector2d &dest, int facing)
: _obj(obj), _facing(facing) {
if (obj->_useWalkboxes) {
_path = obj->_room->calculatePath(obj->_node->getAbsPos(), dest);
} else {
_path = {obj->_node->getAbsPos(), dest};
}
// don't know yet why walkspeed is so slow, so I cheat
Math::Vector2d walkSpeed = obj->_walkSpeed * 2;
_wsd = sqrt(walkSpeed.getX() * walkSpeed.getX() + walkSpeed.getY() * walkSpeed.getY());
if (sqrawexists(obj->_table, "preWalking"))
sqcall(obj->_table, "preWalking");
}
void WalkTo::disable() {
Motor::disable();
if (!_path.empty()) {
debugC(kDebugGame, "actor walk cancelled");
}
if (_obj->isWalking())
_obj->play("stand");
}
static bool needsReachAnim(int verbId) {
return (verbId == VERB_PICKUP) || (verbId == VERB_OPEN) || (verbId == VERB_CLOSE) || (verbId == VERB_PUSH) || (verbId == VERB_PULL) || (verbId == VERB_USE);
}
void WalkTo::actorArrived() {
bool needsReach = _obj->_exec.enabled && needsReachAnim(_obj->_exec.verb.id);
if (!needsReach)
disable();
debugC(kDebugGame, "actorArrived");
_obj->play("stand");
// the faces to the specified direction (if any)
if (_facing) {
debugC(kDebugGame, "actor arrived with facing %d", _facing);
_obj->setFacing((Facing)_facing);
}
// call `actorArrived` callback
if (sqrawexists(_obj->_table, "actorArrived")) {
debugC(kDebugGame, "call actorArrived callback");
sqcall(_obj->_table, "actorArrived");
}
// we need to execute a sentence when arrived ?
if (_obj->_exec.enabled) {
VerbId verb = _obj->_exec.verb;
Common::SharedPtr<Object> noun1 = _obj->_exec.noun1;
Common::SharedPtr<Object> noun2 = _obj->_exec.noun2;
// call `postWalk`callback
Common::String funcName = g_twp->_resManager->isActor(noun1->getId()) ? "actorPostWalk" : "objectPostWalk";
if (sqrawexists(_obj->_table, funcName)) {
debugC(kDebugGame, "call %s callback", funcName.c_str());
HSQOBJECT n2Table;
if (noun2)
n2Table = noun2->_table;
else
sq_resetobject(&n2Table);
sqcall(_obj->_table, funcName.c_str(), (SQInteger)verb.id, noun1->_table, n2Table);
}
if (needsReach)
_obj->setReach(Common::SharedPtr<ReachAnim>(new ReachAnim(_obj, noun1)));
else
Object::execVerb(_obj);
}
}
void WalkTo::onUpdate(float elapsed) {
if (!_enabled)
return;
if (_state == kWalking && !_path.empty()) {
Math::Vector2d dest = _path[0];
float d = distance(dest, _obj->_node->getAbsPos());
// arrived at destination ?
if (d < 1.0) {
_obj->_node->setPos((Math::Vector2d)_path[0]);
_path.remove_at(0);
if (_path.empty()) {
_state = kArrived;
actorArrived();
return;
}
} else {
Math::Vector2d delta(dest - _obj->_node->getAbsPos());
float duration = d / _wsd;
float factor = CLIP(elapsed / duration, 0.f, 1.f);
Math::Vector2d dd = delta * factor;
_obj->_node->setPos(_obj->_node->getPos() + dd);
if (abs(delta.getX()) >= abs(delta.getY())) {
_obj->setFacing(delta.getX() >= 0 ? Facing::FACE_RIGHT : Facing::FACE_LEFT);
} else {
_obj->setFacing(delta.getY() > 0 ? Facing::FACE_BACK : Facing::FACE_FRONT);
}
}
}
if (_state == kArrived) {
Common::SharedPtr<Motor> reach = _obj->getReach();
if (reach && reach->isEnabled()) {
reach->update(elapsed);
_state = kReach;
return;
}
}
if (_state == kReach) {
Common::SharedPtr<Motor> reach = _obj->getReach();
if (reach) {
if (reach->isEnabled()) {
reach->update(elapsed);
} else {
disable();
}
}
}
}
TalkingBase::TalkingBase(Common::SharedPtr<Object> actor, float duration)
: _actor(actor), _duration(duration) {
}
int TalkingBase::loadActorSpeech(const Common::String &name) {
if (ConfMan.getBool("speech_mute")) {
debugC(kDebugGame, "talking %s: speech_mute: true", _actor->_key.c_str());
return 0;
}
debugC(kDebugGame, "loadActorSpeech %s.ogg", name.c_str());
Common::String filename(name);
filename.toUppercase();
filename += ".ogg";
if (g_twp->_pack->assetExists(filename.c_str())) {
Common::SharedPtr<SoundDefinition> soundDefinition(new SoundDefinition(filename));
if (!soundDefinition) {
debugC(kDebugGame, "File %s.ogg not found", name.c_str());
} else {
g_twp->_audio->_soundDefs.push_back(soundDefinition);
int id = g_twp->_audio->play(soundDefinition, Audio::Mixer::SoundType::kSpeechSoundType, 0, 0, 1.f);
int duration = g_twp->_audio->getDuration(id);
debugC(kDebugGame, "talking %s audio id: %d, dur: %d", _actor->_key.c_str(), id, duration);
if (duration)
_duration = static_cast<float>(duration) / 1000.f;
return id;
}
}
return 0;
}
int TalkingBase::onTalkieId(int id) {
SQInteger result = 0;
sqcallfunc(result, "onTalkieID", _actor->_table, id);
if (result == 0)
result = id;
return result;
}
Common::String TalkingBase::talkieKey() {
Common::String result;
if (sqrawexists(_actor->_table, "_talkieKey") && SQ_FAILED(sqgetf(_actor->_table, "_talkieKey", result))) {
error("Failed to get talkie key");
}
if (sqrawexists(_actor->_table, "_key") && SQ_FAILED(sqgetf(_actor->_table, "_key", result))) {
error("Failed to get talkie key (2)");
}
return result;
}
void TalkingBase::setDuration(const Common::String &text) {
_elapsed = 0;
// let sayLineBaseTime = prefs(SayLineBaseTime);
float sayLineBaseTime = 1.5f;
// let sayLineCharTime = prefs(SayLineCharTime);
float sayLineCharTime = 0.025f;
// let sayLineMinTime = prefs(SayLineMinTime);
float sayLineMinTime = 0.2f;
// let sayLineSpeed = prefs(SayLineSpeed);
float sayLineSpeed = 0.5f;
float duration = (sayLineBaseTime + sayLineCharTime * static_cast<float>(text.size())) / (0.2f + sayLineSpeed);
_duration = MAX(duration, sayLineMinTime);
}
float TalkingBase::getTalkSpeed() const {
return (_actor && _actor->_sound) ? 1.f : (ConfMan.getInt("talkspeed") + 1) / 60.f;
}
Talking::Talking(Common::SharedPtr<Object> obj, const Common::StringArray &texts, const Color &color) : TalkingBase(obj, 0.f) {
_color = color;
_texts.assign(texts.begin() + 1, texts.end());
say(texts[0]);
}
void Talking::append(const Common::StringArray &texts, const Color &color) {
_color = color;
_texts.push_back(texts);
_enabled = !_texts.empty();
}
static int letterToIndex(char c) {
switch (c) {
case 'A':
return 1;
case 'B':
return 2;
case 'C':
return 3;
case 'D':
return 4;
case 'E':
return 5;
case 'F':
return 6;
case 'G':
return 1;
case 'H':
return 4;
case 'X':
return 1;
default:
error("unknown letter %c", c);
}
}
void Talking::onUpdate(float elapsed) {
if (!isEnabled())
return;
_elapsed += elapsed * getTalkSpeed();
if (_actor->_sound) {
if (!g_twp->_audio->playing(_actor->_sound)) {
debugC(kDebugGame, "talking %s audio stopped", _actor->_key.c_str());
_actor->_sound = 0;
} else {
float e = static_cast<float>(g_twp->_audio->getElapsed(_actor->_sound)) / 1000.f;
char letter = _lip.letter(e);
_actor->setHeadIndex(letterToIndex(letter));
}
} else if (_elapsed < _duration) {
char letter = _lip.letter(_elapsed);
_actor->setHeadIndex(letterToIndex(letter));
} else if (!_texts.empty()) {
debugC(kDebugGame, "talking %s: %s", _actor->_key.c_str(), _texts[0].c_str());
say(_texts[0]);
_texts.remove_at(0);
} else {
debugC(kDebugGame, "talking %s: ended", _actor->_key.c_str());
disable();
}
}
void Talking::say(const Common::String &text) {
if (text.empty())
return;
Common::String txt(text);
if (txt[0] == '$') {
HSQUIRRELVM v = g_twp->getVm();
SQInteger top = sq_gettop(v);
sq_pushroottable(v);
Common::String code(Common::String::format("return %s", text.substr(1, text.size() - 1).c_str()));
if (SQ_FAILED(sq_compilebuffer(v, code.c_str(), code.size(), "execCode", SQTrue))) {
error("Error executing code %s", code.c_str());
} else {
sq_push(v, -2);
// call
if (SQ_FAILED(sq_call(v, 1, SQTrue, SQTrue))) {
error("Error calling code %s", code.c_str());
} else {
if (SQ_FAILED(sqget(v, -1, txt))) {
error("Error getting call result %s", code.c_str());
}
sq_settop(v, top);
}
}
}
if (txt[0] == '@') {
int id = atoi(txt.c_str() + 1);
txt = g_twp->_textDb->getText(id);
id = onTalkieId(id);
Common::String key = talkieKey();
key.toUppercase();
Common::String name = Common::String::format("%s_%d", key.c_str(), id);
Common::String path = name + ".lip";
debugC(kDebugGame, "Load lip %s", path.c_str());
if (g_twp->_pack->assetExists(path.c_str())) {
GGPackEntryReader entry;
entry.open(*g_twp->_pack, path);
_lip.load(&entry);
debugC(kDebugGame, "Lip %s loaded", path.c_str());
}
if (_actor->_sound) {
g_twp->_audio->stop(_actor->_sound);
}
_actor->_sound = loadActorSpeech(name);
} else if (txt[0] == '^') {
txt = txt.substr(1);
}
// remove text in parentheses
if (txt[0] == '(') {
uint32 i = txt.find(')');
if (i != Common::String::npos)
txt = txt.substr(i + 1);
}
debugC(kDebugGame, "sayLine '%s'", txt.c_str());
if (sqrawexists(_actor->_table, "sayingLine")) {
const char *anim = _actor->_animName.empty() ? nullptr : _actor->_animName.c_str();
sqcall(_actor->_table, "sayingLine", anim, txt);
}
// modify state ?
Common::String state;
if (!txt.empty() && txt[0] == '{') {
uint32 i = txt.find('}');
if (i != Common::String::npos) {
state = txt.substr(1, i - 1);
debugC(kDebugGame, "Set state from anim '%s'", state.c_str());
if (state != "notalk") {
_actor->play(state);
}
txt = txt.substr(i + 1);
}
}
if (!_actor->_sound)
setDuration(txt);
if (_actor->_sayNode) {
_actor->_sayNode->remove();
}
if (ConfMan.getBool("subtitles")) {
Text text2("sayline", txt, thCenter, tvTop, SCREEN_WIDTH * 3.f / 4.f, _color);
_actor->_sayNode = Common::SharedPtr<TextNode>(new TextNode());
_actor->_sayNode->setText(text2);
_actor->_sayNode->setColor(_color);
_node = _actor->_sayNode;
Math::Vector2d pos = g_twp->roomToScreen(_actor->_node->getAbsPos() + _actor->_talkOffset);
// clamp position to keep it on screen
pos.setX(CLIP(pos.getX(), 10.f + text2.getBounds().getX() / 2.f, SCREEN_WIDTH - text2.getBounds().getX() / 2.f));
pos.setY(CLIP(pos.getY(), 10.f + text2.getBounds().getY(), SCREEN_HEIGHT - text2.getBounds().getY()));
_actor->_sayNode->setPos(pos);
_actor->_sayNode->setAnchorNorm(Math::Vector2d(0.5f, 0.0f));
g_twp->_screenScene->addChild(_actor->_sayNode.get());
}
_elapsed = 0.f;
}
void Talking::disable() {
Motor::disable();
if (_actor->_sound) {
g_twp->_audio->stop(_actor->_sound);
}
_texts.clear();
_actor->setHeadIndex(1);
if (_node)
_node->remove();
_elapsed = 0.f;
_duration = 0.f;
}
SayLineAt::SayLineAt(const Math::Vector2d &pos, const Color &color, Common::SharedPtr<Object> actor, float duration, const Common::String &text)
: TalkingBase(actor, duration), _pos(pos), _color(color), _text(text) {
say(text);
}
void SayLineAt::say(const Common::String &text) {
Common::String txt(text);
if (txt.size() == 0) {
debugC(kDebugGame, "say: skipping empty line");
return;
}
if (txt[0] == '$') {
HSQUIRRELVM v = g_twp->getVm();
SQInteger top = sq_gettop(v);
sq_pushroottable(v);
Common::String code(Common::String::format("return %s", text.substr(1, text.size() - 1).c_str()));
if (SQ_FAILED(sq_compilebuffer(v, code.c_str(), code.size(), "execCode", SQTrue))) {
error("Error executing code %s", code.c_str());
} else {
sq_push(v, -2);
// call
if (SQ_FAILED(sq_call(v, 1, SQTrue, SQTrue))) {
error("Error calling code %s", code.c_str());
} else {
if (SQ_FAILED(sqget(v, -1, txt))) {
error("Error getting call result %s", code.c_str());
}
sq_settop(v, top);
}
}
}
if (txt[0] == '@') {
int id = atoi(txt.c_str() + 1);
txt = g_twp->_textDb->getText(id);
if (_actor) {
id = onTalkieId(id);
Common::String key(talkieKey());
key.toUppercase();
Common::String name = Common::String::format("%s_%d", key.c_str(), id);
Common::String path(name + ".lip");
debugC(kDebugGame, "Load lip %s", path.c_str());
if (g_twp->_pack->assetExists(path.c_str())) {
GGPackEntryReader entry;
entry.open(*g_twp->_pack, path);
Lip lip;
lip.load(&entry);
debugC(kDebugGame, "Lip %s loaded", path.c_str());
}
if (_actor->_sound) {
g_twp->_audio->stop(_actor->_sound);
}
_actor->_sound = loadActorSpeech(name);
}
} else if (txt[0] == '^') {
txt = txt.substr(1);
}
// remove text in parentheses
if (txt[0] == '(') {
uint32 i = txt.find(')');
if (i != Common::String::npos)
txt = txt.substr(i + 1);
}
if (_actor && !_actor->_sound)
setDuration(txt);
debugC(kDebugGame, "sayLine '%s'", txt.c_str());
// transform talking position to screen pos
Math::Vector2d talkingSize(320.f, 180.f);
Math::Vector2d pos(Math::Vector2d(SCREEN_WIDTH, SCREEN_HEIGHT) * _pos / talkingSize);
Text text2("sayline", txt, thCenter, tvTop, SCREEN_WIDTH * 3.f / 4.f, _color);
_node = Common::SharedPtr<TextNode>(new TextNode());
_node->setText(text2);
_node->setPos(pos);
_node->setColor(_color);
_node->setAnchorNorm(Math::Vector2d(0.5f, 0.0f));
g_twp->_screenScene->addChild(_node.get());
_elapsed = 0.f;
}
void SayLineAt::onUpdate(float elapsed) {
if (!isEnabled())
return;
_elapsed += elapsed * getTalkSpeed();
if (_actor && _actor->_sound) {
if (!g_twp->_audio->playing(_actor->_sound)) {
debugC(kDebugGame, "talking %s audio stopped", _actor->_key.c_str());
_actor->_sound = 0;
}
} else if (_elapsed >= _duration) {
debugC(kDebugGame, "talking %s: ended", _text.c_str());
disable();
}
}
void SayLineAt::disable() {
Motor::disable();
if (_node)
_node->remove();
}
Jiggle::Jiggle(Node *node, float amount) : _amount(amount), _node(node) {
}
Jiggle::~Jiggle() = default;
void Jiggle::onUpdate(float elapsed) {
_jiggleTime += 20.f * elapsed;
_node->setRotationOffset(_amount * sin(_jiggleTime));
}
MoveCursorTo::MoveCursorTo(const Math::Vector2d &pos, float time)
: _pos(pos),
_tween(g_twp->_cursor.pos, pos, time, intToInterpolationMethod(IK_LINEAR)) {
}
void MoveCursorTo::onUpdate(float elapsed) {
_tween.update(elapsed);
g_twp->_cursor.pos = _tween.current();
if (!_tween.running())
disable();
}
} // namespace Twp