[Scummvm-git-logs] scummvm master -> 588da93d0c489bdb88a462f3627427f142346575

bluegr noreply at scummvm.org
Fri Sep 25 18:45:33 UTC 2026


This automated email contains information about 2 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .

Summary:
574344b89c NANCY: NANCY15: Implement EscapeGridPuzzle
588da93d0c NANCY: NANCY14+: Implement the new credits renderer


Commit: 574344b89cd85f500fa9e7cee2137fb2d8c39677
    https://github.com/scummvm/scummvm/commit/574344b89cd85f500fa9e7cee2137fb2d8c39677
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-25T21:37:44+03:00

Commit Message:
NANCY: NANCY15: Implement EscapeGridPuzzle

This is the puzzle used for the endgame challenge, against the baddie

Changed paths:
  A engines/nancy/action/puzzle/escapegridpuzzle.cpp
  A engines/nancy/action/puzzle/escapegridpuzzle.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 d0d5f2249cf..d1854f6397d 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -53,7 +53,7 @@
 #include "engines/nancy/action/puzzle/dotconnectpuzzle.h"
 #include "engines/nancy/action/puzzle/drivingpuzzle.h"
 #include "engines/nancy/action/puzzle/dropsortpuzzle.h"
-//#include "engines/nancy/action/puzzle/escapegridpuzzle.h"
+#include "engines/nancy/action/puzzle/escapegridpuzzle.h"
 #include "engines/nancy/action/puzzle/gridmappuzzle.h"
 #include "engines/nancy/action/puzzle/magicboxpuzzle.h"
 #include "engines/nancy/action/puzzle/matchpuzzle.h"
@@ -539,8 +539,7 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 	case 183:
 		return new MagicBoxPuzzle();
 	case 184:
-		//return new EscapeGridPuzzle();
-		return nullptr;	// TODO
+		return new EscapeGridPuzzle();
 	case 185:
 		return new WeightSortPuzzle();
 	case 200:
diff --git a/engines/nancy/action/puzzle/escapegridpuzzle.cpp b/engines/nancy/action/puzzle/escapegridpuzzle.cpp
new file mode 100644
index 00000000000..55f6e880aa0
--- /dev/null
+++ b/engines/nancy/action/puzzle/escapegridpuzzle.cpp
@@ -0,0 +1,1123 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/random.h"
+
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/resource.h"
+#include "engines/nancy/sound.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/movieplayer.h"
+#include "engines/nancy/util.h"
+
+#include "engines/nancy/state/scene.h"
+#include "engines/nancy/action/puzzle/escapegridpuzzle.h"
+
+namespace Nancy {
+namespace Action {
+
+static const uint32 kTickLength = 20;
+static const int kTileRefTolerance = 8;
+
+static const Common::Point kNoCell(-1, -1);
+
+// The jump animation of each hex direction.
+static const uint kCompassDirection[EscapeGridPuzzle::kNumDirections] = { 0, 1, 3, 4, 5, 7 };
+
+EscapeGridPuzzle::~EscapeGridPuzzle() {
+	for (uint i = 0; i < _jumpMovies.size(); ++i) {
+		delete _jumpMovies[i];
+	}
+}
+
+void EscapeGridPuzzle::readAnimation(Common::SeekableReadStream &stream, Animation &dst) {
+	readFilename(stream, dst.name);
+	int16 num = stream.readSint16LE();
+	dst.srcs.resize(num > 0 ? num : 0);
+	for (uint i = 0; i < dst.srcs.size(); ++i) {
+		readRect(stream, dst.srcs[i]);
+	}
+}
+
+void EscapeGridPuzzle::readData(Common::SeekableReadStream &stream) {
+	_busyCursorType = stream.readUint16LE();
+	_hoverCursorType = stream.readUint16LE();
+	readFilename(stream, _tileRefName);
+	_defaultSinkTime = stream.readSint32LE();
+	_defaultRiseTime = stream.readSint32LE();
+
+	int16 numTypes = stream.readSint16LE();
+	_tileTypes.resize(numTypes > 0 ? numTypes : 0);
+	for (uint i = 0; i < _tileTypes.size(); ++i) {
+		TileType &type = _tileTypes[i];
+		type.id = stream.readSint32LE();
+		readAnimation(stream, type.sink);
+		type.sinkSound.readData(stream);
+		readAnimation(stream, type.rise);
+		type.riseSound.readData(stream);
+		readFilename(stream, type.imageName);
+		readRect(stream, type.src);
+	}
+
+	readRect(stream, _originCell);
+	readRect(stream, _rowStepCell);
+	readRect(stream, _colStepCell);
+
+	_unknownGridFlag = stream.readByte();
+	_numCols = stream.readSint32LE();
+	_numRows = stream.readSint32LE();
+	_hexDelay = (int32)(stream.readFloatLE() * (1000 / kTickLength) + 0.5f); // in seconds
+
+	uint numCells = (uint)MAX<int32>(0, _numCols) * (uint)MAX<int32>(0, _numRows);
+	_grid.resize(numCells);
+	for (uint i = 0; i < numCells; ++i) {
+		_grid[i] = stream.readSint32LE();
+	}
+
+	// Characters are counted in animations, eight each.
+	int16 numBinks = stream.readSint16LE();
+	if (numBinks % 8 != 0) {
+		error("EscapeGridPuzzle: improper number of animations per character type (should be 8)");
+	}
+
+	_characters.resize(numBinks / 8);
+	for (uint i = 0; i < _characters.size(); ++i) {
+		Character &character = _characters[i];
+		readFilename(stream, character.imageName);
+		readRect(stream, character.src);
+		character.offsetX = stream.readSint32LE();
+		character.offsetY = stream.readSint32LE();
+
+		// Counted in animations too, three per tile type.
+		int16 numTileAnims = stream.readSint16LE();
+		if (numTileAnims % 3 != 0) {
+			error("EscapeGridPuzzle: unclear amount of character animations on tiles");
+		}
+
+		character.tileAnims.resize(numTileAnims / 3);
+		for (uint j = 0; j < character.tileAnims.size(); ++j) {
+			CharacterTileAnims &tileAnims = character.tileAnims[j];
+			tileAnims.tileTypeID = stream.readSint32LE();
+			for (uint k = 0; k < kNumCharTileAnims; ++k) {
+				readAnimation(stream, tileAnims.anims[k]);
+			}
+		}
+
+		for (uint j = 0; j < kNumCompassDirections; ++j) {
+			readFilename(stream, character.jumpNames[j]);
+			character.jumpOffsetX[j] = stream.readSint32LE();
+			character.jumpOffsetY[j] = stream.readSint32LE();
+		}
+	}
+
+	uint16 numActors = stream.readUint16LE();
+	_actors.resize(numActors);
+	for (uint i = 0; i < _actors.size(); ++i) {
+		Actor &actor = _actors[i];
+		actor.characterID = stream.readSint32LE();
+		actor.start.x = (int16)stream.readSint32LE();
+		actor.start.y = (int16)stream.readSint32LE();
+
+		int16 numGoals = stream.readSint16LE();
+		actor.goals.resize(numGoals > 0 ? numGoals : 0);
+		for (uint j = 0; j < actor.goals.size(); ++j) {
+			actor.goals[j].x = (int16)stream.readSint32LE();
+			actor.goals[j].y = (int16)stream.readSint32LE();
+		}
+
+		actor.isPlayer = (stream.readByte() == 1);
+	}
+
+	_solveScene.sceneID = stream.readUint16LE();
+	_solveScene.frameID = stream.readUint16LE();
+	_solveScene.continueSceneSound = kContinueSceneSound;
+	_solveFlag.label = stream.readSint16LE();
+	_solveFlag.flag = stream.readByte();
+	_solveSound.readData(stream);
+
+	_failScene.sceneID = stream.readUint16LE();
+	_failScene.frameID = stream.readUint16LE();
+	_failScene.continueSceneSound = kContinueSceneSound;
+	_failFlag.label = stream.readSint16LE();
+	_failFlag.flag = stream.readByte();
+	_failSound.readData(stream);
+
+	readExitHotspot(stream, _exitHotspot, _exitCursorType, _exitScene, _exitFlag);
+	_exitScene.continueSceneSound = kContinueSceneSound;
+}
+
+static void loadPuzzleImage(const Common::Path &name, Graphics::ManagedSurface &surface, uint32 transColor) {
+	if (name.empty()) {
+		return;
+	}
+
+	g_nancy->_resource->loadImage(name, surface);
+	surface.setTransparentColor(transColor);
+}
+
+void EscapeGridPuzzle::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);
+
+	uint32 transColor = _drawSurface.getTransparentColor();
+
+	if (!_tileRefName.empty()) {
+		g_nancy->_resource->loadImage(_tileRefName, _tileRef);
+	}
+
+	_tileImages.clear();
+	_tileImages.resize(_tileTypes.size());
+	for (uint i = 0; i < _tileTypes.size(); ++i) {
+		loadPuzzleImage(_tileTypes[i].imageName, _tileImages[i].staticImage, transColor);
+		loadPuzzleImage(_tileTypes[i].sink.name, _tileImages[i].sink, transColor);
+		loadPuzzleImage(_tileTypes[i].rise.name, _tileImages[i].rise, transColor);
+	}
+
+	for (uint i = 0; i < _jumpMovies.size(); ++i) {
+		delete _jumpMovies[i];
+	}
+	_jumpMovies.clear();
+	_jumpMovies.resize(_characters.size() * kNumCompassDirections, nullptr);
+
+	_characterImages.clear();
+	_characterImages.resize(_characters.size());
+	for (uint i = 0; i < _characters.size(); ++i) {
+		const Character &character = _characters[i];
+		CharacterImages &images = _characterImages[i];
+		loadPuzzleImage(character.imageName, images.root, transColor);
+
+		images.tileAnims.resize(character.tileAnims.size() * kNumCharTileAnims);
+		for (uint j = 0; j < character.tileAnims.size(); ++j) {
+			for (uint k = 0; k < kNumCharTileAnims; ++k) {
+				loadPuzzleImage(character.tileAnims[j].anims[k].name, images.tileAnims[j * kNumCharTileAnims + k], transColor);
+			}
+		}
+
+		for (uint j = 0; j < kNumCompassDirections; ++j) {
+			if (character.jumpNames[j].empty()) {
+				continue;
+			}
+
+			MoviePlayer *movie = new MoviePlayer();
+			if (movie->loadFile(character.jumpNames[j])) {
+				_jumpMovies[i * kNumCompassDirections + j] = movie;
+			} else {
+				delete movie;
+			}
+		}
+	}
+
+	_actorCells.resize(_actors.size());
+	for (uint i = 0; i < _actors.size(); ++i) {
+		_actorCells[i] = _actors[i].start;
+	}
+	_lastTargets.clear();
+	_lastTargets.resize(_actors.size(), kNoCell);
+	_previousTargets.clear();
+	_previousTargets.resize(_actors.size(), kNoCell);
+
+	_cellAnims.clear();
+	_cellAnims.resize(_grid.size());
+	_actorAnims.clear();
+	_actorAnims.resize(_actors.size());
+	_landedJumps.clear();
+
+	_turn = 0;
+	_moveIssued = false;
+	_slidingPhase = false;
+	_clickedCell = kNoCell;
+	_jumpFrameMovie = nullptr;
+	_jumpFrameIndex = -1;
+	_lastTickTime = g_nancy->getTotalPlayTime();
+	_outcome = -1;
+	_exitRequested = false;
+
+	NancySceneState.setNoHeldItem();
+
+	redraw();
+	registerGraphics();
+}
+
+// Odd columns hang half a row lower than even ones.
+Common::Point EscapeGridPuzzle::step(const Common::Point &cell, Direction dir) {
+	bool odd = (cell.x & 1) != 0;
+
+	switch (dir) {
+	case kNorth:
+		return Common::Point(cell.x, cell.y - 1);
+	case kNorthEast:
+		return Common::Point(cell.x + 1, odd ? cell.y : cell.y - 1);
+	case kSouthEast:
+		return Common::Point(cell.x + 1, odd ? cell.y + 1 : cell.y);
+	case kSouth:
+		return Common::Point(cell.x, cell.y + 1);
+	case kSouthWest:
+		return Common::Point(cell.x - 1, odd ? cell.y + 1 : cell.y);
+	default:
+		return Common::Point(cell.x - 1, odd ? cell.y : cell.y - 1);
+	}
+}
+
+bool EscapeGridPuzzle::directionTo(const Common::Point &from, const Common::Point &to, Direction &dir) {
+	for (int i = 0; i < kNumDirections; ++i) {
+		if (step(from, (Direction)i) == to) {
+			dir = (Direction)i;
+			return true;
+		}
+	}
+	return false;
+}
+
+// Animations run at 15 frames a second, rounded to whole ticks.
+int32 EscapeGridPuzzle::framesToTicks(uint numFrames) {
+	return (int32)(numFrames * 10 + 1) / 3;
+}
+
+uint EscapeGridPuzzle::frameAt(uint numFrames, int32 elapsed) {
+	int32 duration = framesToTicks(numFrames);
+	if (duration <= 0) {
+		return 0;
+	}
+	return MIN<uint>(numFrames - 1, (uint)(numFrames * elapsed / duration));
+}
+
+Common::Rect EscapeGridPuzzle::cellRect(const Common::Point &cell) const {
+	int stepX = _colStepCell.left - _originCell.left;
+	int stepY = _rowStepCell.top - _originCell.top;
+	int stagger = _colStepCell.top - _originCell.top;
+
+	int left = _originCell.left + cell.x * stepX;
+	int top = _originCell.top + cell.y * stepY + ((cell.x & 1) ? stagger : 0);
+	return Common::Rect((int16)left, (int16)top,
+		(int16)(left + _originCell.width()), (int16)(top + _originCell.height()));
+}
+
+bool EscapeGridPuzzle::validCell(const Common::Point &cell) const {
+	return cell.x >= 0 && cell.y >= 0 && cell.x < _numCols && cell.y < _numRows;
+}
+
+int EscapeGridPuzzle::tileTypeIndex(int32 id) const {
+	for (uint i = 0; i < _tileTypes.size(); ++i) {
+		if (_tileTypes[i].id == id) {
+			return i;
+		}
+	}
+	return -1;
+}
+
+const EscapeGridPuzzle::TileType *EscapeGridPuzzle::tileType(int32 id) const {
+	int index = tileTypeIndex(id);
+	return index == -1 ? nullptr : &_tileTypes[index];
+}
+
+const EscapeGridPuzzle::CharacterTileAnims *EscapeGridPuzzle::characterTileAnims(uint actor, int32 tileID, int &index) const {
+	int32 character = _actors[actor].characterID;
+	if (character < 0 || (uint)character >= _characters.size()) {
+		return nullptr;
+	}
+
+	const Common::Array<CharacterTileAnims> &tileAnims = _characters[character].tileAnims;
+	for (uint i = 0; i < tileAnims.size(); ++i) {
+		if (tileAnims[i].tileTypeID == tileID) {
+			index = i;
+			return &tileAnims[i];
+		}
+	}
+	return nullptr;
+}
+
+int EscapeGridPuzzle::actorAt(const Common::Point &cell) const {
+	for (uint i = 0; i < _actorCells.size(); ++i) {
+		if (_actorCells[i] == cell) {
+			return i;
+		}
+	}
+	return -1;
+}
+
+bool EscapeGridPuzzle::canStepOnto(const Common::Array<int32> &grid, const Common::Array<Common::Point> &cells,
+		const Common::Point &cell) const {
+	if (!validCell(cell)) {
+		return false;
+	}
+
+	int32 id = grid[cell.y * _numCols + cell.x];
+	if (!(id & kTileWalkable) || (id & kTileMissing)) {
+		return false;
+	}
+
+	for (uint i = 0; i < cells.size(); ++i) {
+		if (cells[i] == cell) {
+			return false;
+		}
+	}
+	return true;
+}
+
+// The run of movable hexes ahead of the landing hex, then the run behind it farthest
+// first, so the line closes back on the landing hex. Nothing slides, and the line is left
+// empty, when there is no movable hex ahead of the one landed on.
+void EscapeGridPuzzle::collectLine(const Common::Array<int32> &grid, const Common::Point &from, const Common::Point &to,
+		Common::Array<Common::Point> &line) const {
+	line.clear();
+
+	Direction dir = kNorth;
+	if (!validCell(to) || !(grid[to.y * _numCols + to.x] & kTileMovable) || !directionTo(from, to, dir)) {
+		return;
+	}
+
+	for (Common::Point p = to; validCell(p) && (grid[p.y * _numCols + p.x] & kTileMovable); p = step(p, dir)) {
+		line.push_back(p);
+	}
+
+	if (line.size() == 1) {
+		line.clear();
+		return;
+	}
+
+	Direction back = (Direction)((dir + kNumDirections / 2) % kNumDirections);
+	Common::Array<Common::Point> behind;
+	for (Common::Point p = to; validCell(p) && (grid[p.y * _numCols + p.x] & kTileMovable); p = step(p, back)) {
+		behind.push_back(p);
+	}
+	for (uint i = behind.size(); i-- > 0;) {
+		line.push_back(behind[i]);
+	}
+}
+
+// Every hex takes the place of the one ahead of it, the front one wrapping around to the
+// back, and anyone standing on the line rides along.
+void EscapeGridPuzzle::rotateLine(Common::Array<int32> &grid, Common::Array<Common::Point> &cells,
+		const Common::Array<Common::Point> &line) const {
+	int32 carried = grid[line[0].y * _numCols + line[0].x];
+	for (uint i = 1; i < line.size(); ++i) {
+		SWAP(grid[line[i].y * _numCols + line[i].x], carried);
+	}
+
+	for (uint a = 0; a < cells.size(); ++a) {
+		for (uint i = 0; i + 1 < line.size(); ++i) {
+			if (cells[a] == line[i]) {
+				cells[a] = line[i + 1];
+				break;
+			}
+		}
+	}
+}
+
+// Slides the line on the real board. The hexes sink one after the other along the line;
+// each then rises holding the hex that was behind it, once that one has sunk. Characters
+// on the line sink and rise with their hex, and the hex itself isn't drawn meanwhile.
+void EscapeGridPuzzle::slideBoard(const Common::Point &from, const Common::Point &to) {
+	Common::Array<Common::Point> line;
+	collectLine(_grid, from, to, line);
+	if (line.empty()) {
+		return;
+	}
+
+	const uint count = line.size();
+
+	for (uint i = 0; i + 1 < count; ++i) {
+		const Common::Point &cell = line[i];
+		int32 id = tileAt(cell);
+		int32 delay = _hexDelay * i;
+
+		if (i > 0) {
+			pushTileAnim(cell, kTileStay, id, delay);
+		}
+
+		int actor = actorAt(cell);
+		if (actor != -1) {
+			if (delay > 0) {
+				pushActorAnim(actor, kActorStay, cell, 0, delay);
+			}
+			int32 time = characterSinkTime(actor, id);
+			pushActorAnim(actor, i > 0 ? kActorSinkRider : kActorSink, cell, id, time);
+			pushTileAnim(cell, kTileBlank, 0, time);
+		} else {
+			pushTileAnim(cell, kTileSink, id, tileSinkTime(id));
+		}
+	}
+
+	Common::Array<int32> queued;
+	queued.resize(count, 0);
+	for (uint i = 1; i < count; ++i) {
+		queued[i] = cellQueueTime(line[i]);
+	}
+
+	rotateLine(_grid, _actorCells, line);
+
+	for (uint i = 1; i < count; ++i) {
+		const Common::Point &cell = line[i];
+		int32 id = tileAt(cell);
+		int32 delay = MAX<int32>(0, _hexDelay - queued[i] + queued[i - 1]);
+
+		int actor = actorAt(cell);
+		if (actor != -1) {
+			if (delay > 0) {
+				pushActorAnim(actor, kActorStay, cell, 0, delay);
+			}
+			pushActorAnim(actor, kActorRise, cell, id, characterRiseTime(actor, id));
+			pushTileAnim(cell, kTileBlank, 0, MAX<int32>(0, actorQueueTime(actor) - cellQueueTime(cell) - 1));
+		} else {
+			if (delay > 0) {
+				pushTileAnim(cell, kTileBlank, 0, delay);
+			}
+			pushTileAnim(cell, kTileRise, id, tileRiseTime(id));
+		}
+	}
+}
+
+int32 EscapeGridPuzzle::tileSinkTime(int32 tileID) const {
+	const TileType *type = tileType(tileID);
+	return (type && !type->sink.srcs.empty()) ? framesToTicks(type->sink.srcs.size()) : _defaultSinkTime;
+}
+
+int32 EscapeGridPuzzle::tileRiseTime(int32 tileID) const {
+	const TileType *type = tileType(tileID);
+	return (type && !type->rise.srcs.empty()) ? framesToTicks(type->rise.srcs.size()) : _defaultRiseTime;
+}
+
+// A character without animations for the tile takes the default sink time either way.
+int32 EscapeGridPuzzle::characterSinkTime(uint actor, int32 tileID) const {
+	int index;
+	const CharacterTileAnims *anims = characterTileAnims(actor, tileID, index);
+	if (!anims || anims->anims[kCharSink].srcs.empty()) {
+		return _defaultSinkTime;
+	}
+	return framesToTicks(anims->anims[kCharSink].srcs.size());
+}
+
+int32 EscapeGridPuzzle::characterRiseTime(uint actor, int32 tileID) const {
+	int index;
+	const CharacterTileAnims *anims = characterTileAnims(actor, tileID, index);
+	if (!anims) {
+		return _defaultSinkTime;
+	}
+	if (anims->anims[kCharRise].srcs.empty()) {
+		return _defaultRiseTime;
+	}
+	return framesToTicks(anims->anims[kCharRise].srcs.size());
+}
+
+void EscapeGridPuzzle::pushTileAnim(const Common::Point &cell, TileAnimKind kind, int32 tileID, int32 time) {
+	TileAnim anim;
+	anim.kind = kind;
+	anim.tileID = tileID;
+	anim.total = anim.remaining = time;
+	_cellAnims[cell.y * _numCols + cell.x].push_back(anim);
+}
+
+void EscapeGridPuzzle::pushActorAnim(uint actor, ActorAnimKind kind, const Common::Point &cell, int32 tileID, int32 time) {
+	ActorAnim anim;
+	anim.kind = kind;
+	anim.from = anim.to = cell;
+	anim.tileID = tileID;
+	anim.total = anim.remaining = time;
+	_actorAnims[actor].push_back(anim);
+}
+
+int32 EscapeGridPuzzle::cellQueueTime(const Common::Point &cell) const {
+	const Common::Array<TileAnim> &queue = _cellAnims[cell.y * _numCols + cell.x];
+	int32 time = 0;
+	for (uint i = 0; i < queue.size(); ++i) {
+		time += queue[i].remaining;
+	}
+	return time;
+}
+
+int32 EscapeGridPuzzle::actorQueueTime(uint actor) const {
+	const Common::Array<ActorAnim> &queue = _actorAnims[actor];
+	int32 time = 0;
+	for (uint i = 0; i < queue.size(); ++i) {
+		time += queue[i].remaining;
+	}
+	return time;
+}
+
+bool EscapeGridPuzzle::actorsAnimating() const {
+	for (uint i = 0; i < _actorAnims.size(); ++i) {
+		if (!_actorAnims[i].empty()) {
+			return true;
+		}
+	}
+	return false;
+}
+
+// Only the animation at the front of each queue advances. A finished jump is kept, so
+// that its line gets slid once everyone has come to rest.
+void EscapeGridPuzzle::updateActorAnims(int32 ticks) {
+	for (uint i = 0; i < _actorAnims.size(); ++i) {
+		Common::Array<ActorAnim> &queue = _actorAnims[i];
+		if (queue.empty()) {
+			continue;
+		}
+
+		ActorAnim &anim = queue[0];
+		if (anim.remaining == anim.total && anim.kind != kActorStay && anim.kind != kActorJump) {
+			const TileType *type = tileType(anim.tileID);
+			if (type) {
+				playSoundBlock(anim.kind == kActorRise ? type->riseSound : type->sinkSound);
+			}
+		}
+
+		anim.remaining = MAX<int32>(0, anim.remaining - ticks);
+		if (anim.remaining == 0) {
+			if (anim.from != anim.to) {
+				_landedJumps.push_back(anim);
+			}
+			queue.remove_at(0);
+		}
+	}
+}
+
+// Returns whether any hex was still moving.
+bool EscapeGridPuzzle::updateTileAnims(int32 ticks) {
+	bool moving = false;
+
+	for (uint i = 0; i < _cellAnims.size(); ++i) {
+		Common::Array<TileAnim> &queue = _cellAnims[i];
+		if (queue.empty()) {
+			continue;
+		}
+
+		moving = true;
+		TileAnim &anim = queue[0];
+		if (anim.remaining == anim.total && (anim.kind == kTileSink || anim.kind == kTileRise)) {
+			const TileType *type = tileType(anim.tileID);
+			if (type) {
+				playSoundBlock(anim.kind == kTileRise ? type->riseSound : type->sinkSound);
+			}
+		}
+
+		anim.remaining = MAX<int32>(0, anim.remaining - ticks);
+		if (anim.remaining == 0) {
+			queue.remove_at(0);
+		}
+	}
+
+	return moving;
+}
+
+bool EscapeGridPuzzle::hasAnyMove(uint actor) const {
+	for (int i = 0; i < kNumDirections; ++i) {
+		if (canStepOnto(_grid, _actorCells, step(_actorCells[actor], (Direction)i))) {
+			return true;
+		}
+	}
+	return false;
+}
+
+// Looks up to two jumps ahead for whatever gets closest to a goal; the first jump of the
+// best line is taken. Failing that, a random neighboring hex is picked.
+Common::Point EscapeGridPuzzle::chooseOpponentMove(uint actor) {
+	Common::Array<int32> grid = _grid;
+	Common::Array<Common::Point> cells = _actorCells;
+	Common::Array<Common::Point> path;
+	Common::Array<Common::Point> bestPath;
+	int32 bestDist = 0x7FFFFFFF;
+
+	searchPath(actor, 0, grid, cells, path, bestDist, bestPath);
+
+	Common::Point target = kNoCell;
+	if (!bestPath.empty() && bestPath[0] != _previousTargets[actor]) {
+		target = bestPath[0];
+	} else {
+		Common::Array<Common::Point> candidates;
+		for (int i = 0; i < kNumDirections; ++i) {
+			candidates.push_back(step(_actorCells[actor], (Direction)i));
+		}
+
+		while (!candidates.empty()) {
+			uint i = g_nancy->_randomSource->getRandomNumber(candidates.size() - 1);
+			if (candidates[i] == _previousTargets[actor] && candidates.size() > 1) {
+				continue;
+			}
+			if (canStepOnto(_grid, _actorCells, candidates[i])) {
+				target = candidates[i];
+				break;
+			}
+			candidates.remove_at(i);
+		}
+	}
+
+	_previousTargets[actor] = _lastTargets[actor];
+	_lastTargets[actor] = target;
+	return target;
+}
+
+// Distance is counted in board columns plus rows. A tie goes to the shorter line.
+void EscapeGridPuzzle::searchPath(uint actor, int depth, Common::Array<int32> &grid, Common::Array<Common::Point> &cells,
+		Common::Array<Common::Point> &path, int32 &bestDist, Common::Array<Common::Point> &bestPath) const {
+	const Common::Point pos = cells[actor];
+
+	const Common::Array<Common::Point> &goals = _actors[actor].goals;
+	for (uint i = 0; i < goals.size(); ++i) {
+		int32 dist = ABS(goals[i].y - pos.y) + ABS(goals[i].x - pos.x);
+		if ((dist < bestDist && !path.empty()) || (dist == bestDist && path.size() < bestPath.size())) {
+			bestDist = dist;
+			bestPath = path;
+		}
+	}
+
+	if (depth == 2 || bestDist == 0) {
+		return;
+	}
+
+	for (int i = 0; i < kNumDirections; ++i) {
+		Common::Point next = step(pos, (Direction)i);
+		if (!canStepOnto(grid, cells, next)) {
+			continue;
+		}
+
+		Common::Array<int32> nextGrid = grid;
+		Common::Array<Common::Point> nextCells = cells;
+		nextCells[actor] = next;
+
+		Common::Array<Common::Point> line;
+		collectLine(nextGrid, pos, next, line);
+		if (!line.empty()) {
+			rotateLine(nextGrid, nextCells, line);
+		}
+
+		path.push_back(next);
+		searchPath(actor, depth + 1, nextGrid, nextCells, path, bestDist, bestPath);
+		path.pop_back();
+	}
+}
+
+// A target off the board means the actor has nowhere to go and sits the turn out.
+void EscapeGridPuzzle::startMove(const Common::Point &target) {
+	Direction dir = kNorth;
+	const Common::Point from = _actorCells[_turn];
+	if (!validCell(target) || !directionTo(from, target, dir)) {
+		return;
+	}
+
+	uint compassDir = kCompassDirection[dir];
+	const MoviePlayer *movie = _jumpMovies[_actors[_turn].characterID * kNumCompassDirections + compassDir];
+
+	ActorAnim anim;
+	anim.kind = kActorJump;
+	anim.from = from;
+	anim.to = target;
+	anim.compassDir = compassDir;
+	anim.total = anim.remaining = framesToTicks(movie ? movie->getFrameCount() : 0);
+	_actorAnims[_turn].push_back(anim);
+
+	_actorCells[_turn] = target;
+}
+
+// One step of the turn cycle: the actor to move jumps, the line it landed on slides once
+// everyone has come to rest, and the turn passes on once the hexes have settled.
+void EscapeGridPuzzle::updateTurn(int32 ticks) {
+	updateActorAnims(ticks);
+	bool animating = actorsAnimating();
+
+	if (_moveIssued) {
+		if (animating) {
+			return;
+		}
+
+		int outcome = checkOutcome();
+		if (outcome != -1) {
+			endPuzzle(outcome);
+			return;
+		}
+
+		_moveIssued = false;
+		_slidingPhase = true;
+		for (uint i = 0; i < _landedJumps.size(); ++i) {
+			slideBoard(_landedJumps[i].from, _landedJumps[i].to);
+		}
+		_landedJumps.clear();
+		return;
+	}
+
+	if (_slidingPhase) {
+		_slidingPhase = updateTileAnims(ticks);
+		if (!_slidingPhase) {
+			_turn = (_turn + 1) % _actors.size();
+
+			int outcome = checkOutcome();
+			if (outcome != -1) {
+				endPuzzle(outcome);
+			}
+		}
+		return;
+	}
+
+	if (animating) {
+		return;
+	}
+
+	if (!_actors[_turn].isPlayer) {
+		startMove(chooseOpponentMove(_turn));
+	} else if (_clickedCell != kNoCell) {
+		startMove(_clickedCell);
+		_clickedCell = kNoCell;
+	} else if (!hasAnyMove(_turn)) {
+		startMove(kNoCell);
+	} else {
+		return;
+	}
+
+	_moveIssued = true;
+}
+
+// An opponent reaching its goal beats the player reaching theirs at the same time.
+int EscapeGridPuzzle::checkOutcome() const {
+	bool playerArrived = false;
+	bool opponentArrived = false;
+
+	for (uint i = 0; i < _actors.size(); ++i) {
+		for (uint j = 0; j < _actors[i].goals.size(); ++j) {
+			if (_actorCells[i] == _actors[i].goals[j]) {
+				if (_actors[i].isPlayer) {
+					playerArrived = true;
+				} else {
+					opponentArrived = true;
+				}
+				break;
+			}
+		}
+	}
+
+	if (opponentArrived) {
+		return 1;
+	}
+	return playerArrived ? 0 : -1;
+}
+
+void EscapeGridPuzzle::endPuzzle(int outcome) {
+	_outcome = outcome;
+	_endSound = playSoundBlock(outcome == 0 ? _solveSound : _failSound);
+}
+
+bool EscapeGridPuzzle::tileRefColor(const Common::Point &cell, const Common::Point &pos, byte &r, byte &g, byte &b) const {
+	if (_tileRef.empty()) {
+		return false;
+	}
+
+	Common::Rect rect = cellRect(cell);
+	int x = pos.x - rect.left;
+	int y = pos.y - rect.top;
+	if (x < 0 || y < 0 || x >= _tileRef.w || y >= _tileRef.h) {
+		return false;
+	}
+
+	_tileRef.format.colorToRGB(_tileRef.getPixel(x, y), r, g, b);
+	return true;
+}
+
+static bool colorNear(byte r, byte g, byte b, byte refR, byte refG, byte refB) {
+	return ABS(r - refR) < kTileRefTolerance && ABS(g - refG) < kTileRefTolerance && ABS(b - refB) < kTileRefTolerance;
+}
+
+bool EscapeGridPuzzle::isCellPixel(const Common::Point &cell, const Common::Point &pos) const {
+	byte r, g, b;
+	return validCell(cell) && tileRefColor(cell, pos, r, g, b) && colorNear(r, g, b, 0xff, 0xff, 0xff);
+}
+
+// The cursor is first placed in a cell by its bounding box alone. The hit mask then tells
+// whether it is really over that hex (white) or over one of the four diagonal neighbors
+// overlapping its corners: red for north-east, black for south-east, green for south-west
+// and blue for north-west. The target has to be a hex the player can jump onto.
+bool EscapeGridPuzzle::findHoveredCell(const Common::Point &mousePos, Common::Point &outCell) const {
+	Common::Rect mouseRect = NancySceneState.getViewport().convertScreenToViewport(
+		Common::Rect(mousePos.x, mousePos.y, mousePos.x + 1, mousePos.y + 1));
+	Common::Point pos(mouseRect.left, mouseRect.top);
+
+	int stepX = _colStepCell.left - _originCell.left;
+	int stepY = _rowStepCell.top - _originCell.top;
+	int stagger = _colStepCell.top - _originCell.top;
+
+	int dx = pos.x - _originCell.left;
+	if (dx < 0) {
+		dx -= stepX;
+	}
+	int col = dx / stepX;
+
+	int dy = pos.y - _originCell.top - ((col & 1) ? stagger : 0);
+	if (dy < 0) {
+		dy -= stepY;
+	}
+	int row = dy / stepY;
+
+	Common::Point cell(col, row);
+	if (!isCellPixel(cell, pos)) {
+		byte r, g, b;
+		if (!tileRefColor(cell, pos, r, g, b)) {
+			return false;
+		}
+
+		if (colorNear(r, g, b, 0xff, 0, 0)) {
+			cell = step(cell, kNorthEast);
+		} else if (colorNear(r, g, b, 0, 0, 0)) {
+			cell = step(cell, kSouthEast);
+		} else if (colorNear(r, g, b, 0, 0xff, 0)) {
+			cell = step(cell, kSouthWest);
+		} else if (colorNear(r, g, b, 0, 0, 0xff)) {
+			cell = step(cell, kNorthWest);
+		} else {
+			return false;
+		}
+
+		if (!isCellPixel(cell, pos)) {
+			return false;
+		}
+	}
+
+	Direction dir;
+	if (!directionTo(_actorCells[_turn], cell, dir) || !canStepOnto(_grid, _actorCells, cell)) {
+		return false;
+	}
+
+	outCell = cell;
+	return true;
+}
+
+SoundDescription EscapeGridPuzzle::playSoundBlock(const RandomSoundBlock &block) {
+	SoundDescription desc;
+	if (block.names.empty()) {
+		return desc;
+	}
+
+	uint idx = block.names.size() == 1 ? 0 : g_nancy->_randomSource->getRandomNumber(block.names.size() - 1);
+	const Common::String &name = block.names[idx];
+	if (name.empty() || name == "NO SOUND") {
+		return desc;
+	}
+
+	desc.name = name;
+	desc.channelID = block.channel;
+	desc.numLoops = block.numLoops > 0 ? block.numLoops : 1;
+	desc.volume = block.volume;
+
+	g_nancy->_sound->loadSound(desc);
+	g_nancy->_sound->playSound(desc);
+	return desc;
+}
+
+void EscapeGridPuzzle::drawAnimFrame(const Graphics::ManagedSurface &image, const Animation &anim, int32 elapsed,
+		const Common::Point &pos) {
+	if (image.empty() || anim.srcs.empty()) {
+		return;
+	}
+
+	_drawSurface.blitFrom(image, anim.srcs[frameAt(anim.srcs.size(), elapsed)], pos);
+}
+
+void EscapeGridPuzzle::drawJump(uint actor, const ActorAnim &anim) {
+	const Character &character = _characters[_actors[actor].characterID];
+	MoviePlayer *movie = _jumpMovies[_actors[actor].characterID * kNumCompassDirections + anim.compassDir];
+	if (!movie || movie->getFrameCount() <= 0) {
+		return;
+	}
+
+	int frame = frameAt(movie->getFrameCount(), anim.total - anim.remaining);
+	if (movie != _jumpFrameMovie || frame != _jumpFrameIndex) {
+		const Graphics::Surface *decoded = movie->decodeNextFrame(frame);
+		if (!decoded) {
+			return;
+		}
+
+		GraphicsManager::copyToManaged(*decoded, _jumpFrame);
+		_jumpFrame.setTransparentColor(_drawSurface.getTransparentColor());
+		_jumpFrameMovie = movie;
+		_jumpFrameIndex = frame;
+	}
+
+	Common::Rect from = cellRect(anim.from);
+	_drawSurface.blitFrom(_jumpFrame, Common::Point(from.left + character.jumpOffsetX[anim.compassDir],
+		from.top + character.jumpOffsetY[anim.compassDir]));
+}
+
+void EscapeGridPuzzle::drawActor(uint actor) {
+	int32 id = _actors[actor].characterID;
+	if (id < 0 || (uint)id >= _characters.size()) {
+		return;
+	}
+
+	const Character &character = _characters[id];
+	const CharacterImages &images = _characterImages[id];
+	const Common::Array<ActorAnim> &queue = _actorAnims[actor];
+
+	if (!queue.empty() && queue[0].kind == kActorJump) {
+		drawJump(actor, queue[0]);
+		return;
+	}
+
+	Common::Rect cell = cellRect(queue.empty() ? _actorCells[actor] : queue[0].from);
+	Common::Point pos(cell.left + character.offsetX, cell.top + character.offsetY);
+
+	if (queue.empty() || queue[0].kind == kActorStay) {
+		if (!images.root.empty()) {
+			_drawSurface.blitFrom(images.root, character.src, pos);
+		}
+		return;
+	}
+
+	const ActorAnim &anim = queue[0];
+	int index;
+	const CharacterTileAnims *anims = characterTileAnims(actor, anim.tileID, index);
+	if (!anims) {
+		return;
+	}
+
+	uint which = anim.kind == kActorRise ? kCharRise : (anim.kind == kActorSinkRider ? kCharSinkRider : kCharSink);
+	drawAnimFrame(images.tileAnims[index * kNumCharTileAnims + which], anims->anims[which], anim.total - anim.remaining, pos);
+}
+
+void EscapeGridPuzzle::drawCell(const Common::Point &cell) {
+	const Common::Array<TileAnim> &queue = _cellAnims[cell.y * _numCols + cell.x];
+	Common::Rect dest = cellRect(cell);
+	Common::Point pos(dest.left, dest.top);
+
+	int32 id = queue.empty() ? tileAt(cell) : queue[0].tileID;
+	int typeIndex = tileTypeIndex(id);
+	if (typeIndex == -1) {
+		return;
+	}
+
+	const TileType &type = _tileTypes[typeIndex];
+	const TileImages &images = _tileImages[typeIndex];
+	TileAnimKind kind = queue.empty() ? kTileStay : queue[0].kind;
+
+	switch (kind) {
+	case kTileStay:
+		if (!images.staticImage.empty() && !type.src.isEmpty()) {
+			_drawSurface.blitFrom(images.staticImage, type.src, pos);
+		}
+		break;
+	case kTileSink:
+		drawAnimFrame(images.sink, type.sink, queue[0].total - queue[0].remaining, pos);
+		break;
+	case kTileRise:
+		drawAnimFrame(images.rise, type.rise, queue[0].total - queue[0].remaining, pos);
+		break;
+	default:
+		break;
+	}
+}
+
+void EscapeGridPuzzle::redraw() {
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+
+	for (int row = 0; row < _numRows; ++row) {
+		for (int col = 0; col < _numCols; ++col) {
+			drawCell(Common::Point(col, row));
+		}
+	}
+
+	for (uint i = 0; i < _actors.size(); ++i) {
+		drawActor(i);
+	}
+
+	_needsRedraw = true;
+}
+
+void EscapeGridPuzzle::execute() {
+	switch (_state) {
+	case kBegin:
+		init();
+		_state = kRun;
+		// fall through
+	case kRun: {
+		if (_exitRequested) {
+			_state = kActionTrigger;
+			break;
+		}
+
+		if (_outcome != -1) {
+			if (_endSound.name.empty() || !g_nancy->_sound->isSoundPlaying(_endSound)) {
+				_state = kActionTrigger;
+			}
+			break;
+		}
+
+		uint32 ticks = (g_nancy->getTotalPlayTime() - _lastTickTime) / kTickLength;
+		if (ticks == 0) {
+			break;
+		}
+
+		_lastTickTime += ticks * kTickLength;
+		updateTurn(ticks);
+		redraw();
+		break;
+	}
+	case kActionTrigger:
+		if (_exitRequested) {
+			NancySceneState.setEventFlag(_exitFlag);
+			NancySceneState.changeScene(_exitScene);
+		} else if (_outcome == 0) {
+			NancySceneState.setEventFlag(_solveFlag);
+			NancySceneState.changeScene(_solveScene);
+		} else {
+			NancySceneState.setEventFlag(_failFlag);
+			NancySceneState.changeScene(_failScene);
+		}
+
+		finishExecution();
+		break;
+	}
+}
+
+void EscapeGridPuzzle::handleInput(NancyInput &input) {
+	if (_state != kRun || _outcome != -1 || _exitRequested) {
+		return;
+	}
+
+	const bool click = (input.input & NancyInput::kLeftMouseButtonUp) != 0;
+	const bool playerToMove = _actors[_turn].isPlayer && !_moveIssued && !_slidingPhase && !actorsAnimating();
+
+	if (playerToMove) {
+		Common::Point cell;
+		if (findHoveredCell(input.mousePos, cell)) {
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_hoverCursorType, true, true);
+			if (click) {
+				_clickedCell = cell;
+			}
+			input.eatMouseInput();
+			return;
+		}
+	} else {
+		g_nancy->_cursor->setCursorType((CursorManager::CursorType)_busyCursorType, true, false);
+	}
+
+	if (!_exitHotspot.isEmpty() &&
+			NancySceneState.getViewport().convertViewportToScreen(_exitHotspot).contains(input.mousePos)) {
+		if (playerToMove) {
+			g_nancy->_cursor->setCursorType((CursorManager::CursorType)_exitCursorType, true, false);
+		}
+		if (click) {
+			_exitRequested = true;
+		}
+	}
+}
+
+} // End of namespace Action
+} // End of namespace Nancy
diff --git a/engines/nancy/action/puzzle/escapegridpuzzle.h b/engines/nancy/action/puzzle/escapegridpuzzle.h
new file mode 100644
index 00000000000..d55f97ab883
--- /dev/null
+++ b/engines/nancy/action/puzzle/escapegridpuzzle.h
@@ -0,0 +1,291 @@
+/* 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_ESCAPEGRIDPUZZLE_H
+#define NANCY_ACTION_ESCAPEGRIDPUZZLE_H
+
+#include "engines/nancy/commontypes.h"
+#include "engines/nancy/action/actionrecord.h"
+
+namespace Nancy {
+
+class MoviePlayer;
+
+namespace Action {
+
+// Hex-grid race, new in Nancy15 (AR 184). The player and computer opponents take turns
+// jumping to a neighboring solid hex, each racing to reach one of its goal cells first.
+// Landing on a hex slides the whole line of hexes through it one step in the direction
+// of the jump, carrying anyone standing on it, with the front hex wrapping around to the
+// back; which hexes are solid and which are missing shifts along with it.
+class EscapeGridPuzzle : public RenderActionRecord {
+public:
+	EscapeGridPuzzle() : RenderActionRecord(7) {}
+	virtual ~EscapeGridPuzzle();
+
+	void init() override;
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+	void handleInput(NancyInput &input) override;
+
+	bool isViewportRelative() const override { return true; }
+
+	// Bits of a tile type id.
+	enum TileFlags : int32 {
+		kTileWalkable	= 0x001,
+		kTileMovable	= 0x010,	// slides along when a line is pushed
+		kTileMissing	= 0x100		// overrides kTileWalkable
+	};
+
+	// The six neighbors of a hex, in the order they are searched.
+	enum Direction {
+		kNorth		= 0,
+		kNorthEast	= 1,
+		kSouthEast	= 2,
+		kSouth		= 3,
+		kSouthWest	= 4,
+		kNorthWest	= 5,
+		kNumDirections
+	};
+
+	// Jump animations are indexed clockwise from north; a hex leaves east and west empty.
+	static const uint kNumCompassDirections = 8;
+
+	// A character's animations while it rides one kind of tile.
+	enum CharacterTileAnim {
+		kCharRise		= 0,
+		kCharSinkRider	= 1,	// carried along a sliding line
+		kCharSink		= 2,	// on the hex it just landed on
+		kNumCharTileAnims
+	};
+
+	struct Animation {
+		Common::Path name;
+		Common::Array<Common::Rect> srcs;
+	};
+
+	struct TileType {
+		int32 id = 0;
+		Animation sink;
+		RandomSoundBlock sinkSound;
+		Animation rise;
+		RandomSoundBlock riseSound;
+		Common::Path imageName;
+		Common::Rect src;
+	};
+
+	struct CharacterTileAnims {
+		int32 tileTypeID = 0;
+		Animation anims[kNumCharTileAnims];
+	};
+
+	struct Character {
+		Common::Path imageName;
+		Common::Rect src;
+		int32 offsetX = 0;
+		int32 offsetY = 0;
+		Common::Array<CharacterTileAnims> tileAnims;
+		Common::Path jumpNames[kNumCompassDirections];
+		int32 jumpOffsetX[kNumCompassDirections] = {};
+		int32 jumpOffsetY[kNumCompassDirections] = {};
+	};
+
+	struct Actor {
+		int32 characterID = 0;
+		Common::Point start;
+		Common::Array<Common::Point> goals;
+		bool isPlayer = false;
+	};
+
+protected:
+	Common::String getRecordTypeName() const override { return "EscapeGridPuzzle"; }
+
+	// Animations are timed in ticks of kTickLength ms.
+	enum TileAnimKind {
+		kTileStay,		// keeps showing the given tile
+		kTileBlank,		// shows nothing
+		kTileSink,
+		kTileRise
+	};
+
+	struct TileAnim {
+		TileAnimKind kind = kTileStay;
+		int32 tileID = 0;
+		int32 total = 0;
+		int32 remaining = 0;
+	};
+
+	enum ActorAnimKind {
+		kActorStay,
+		kActorJump,
+		kActorSink,
+		kActorSinkRider,
+		kActorRise
+	};
+
+	struct ActorAnim {
+		ActorAnimKind kind = kActorStay;
+		Common::Point from;
+		Common::Point to;
+		int32 tileID = 0;
+		uint compassDir = 0;
+		int32 total = 0;
+		int32 remaining = 0;
+	};
+
+	struct TileImages {
+		Graphics::ManagedSurface staticImage;
+		Graphics::ManagedSurface sink;
+		Graphics::ManagedSurface rise;
+	};
+
+	struct CharacterImages {
+		Graphics::ManagedSurface root;
+		Common::Array<Graphics::ManagedSurface> tileAnims;	// kNumCharTileAnims per entry of Character::tileAnims
+	};
+
+	static void readAnimation(Common::SeekableReadStream &stream, Animation &dst);
+
+	static Common::Point step(const Common::Point &cell, Direction dir);
+	static bool directionTo(const Common::Point &from, const Common::Point &to, Direction &dir);
+	static int32 framesToTicks(uint numFrames);
+	static uint frameAt(uint numFrames, int32 elapsed);
+
+	Common::Rect cellRect(const Common::Point &cell) const;
+	bool validCell(const Common::Point &cell) const;
+	int32 &tileAt(const Common::Point &cell) { return _grid[cell.y * _numCols + cell.x]; }
+	int32 tileAt(const Common::Point &cell) const { return _grid[cell.y * _numCols + cell.x]; }
+	int tileTypeIndex(int32 id) const;
+	const TileType *tileType(int32 id) const;
+	const CharacterTileAnims *characterTileAnims(uint actor, int32 tileID, int &index) const;
+	int actorAt(const Common::Point &cell) const;
+
+	bool canStepOnto(const Common::Array<int32> &grid, const Common::Array<Common::Point> &cells,
+		const Common::Point &cell) const;
+	void collectLine(const Common::Array<int32> &grid, const Common::Point &from, const Common::Point &to,
+		Common::Array<Common::Point> &line) const;
+	void rotateLine(Common::Array<int32> &grid, Common::Array<Common::Point> &cells,
+		const Common::Array<Common::Point> &line) const;
+	void slideBoard(const Common::Point &from, const Common::Point &to);
+
+	int32 tileSinkTime(int32 tileID) const;
+	int32 tileRiseTime(int32 tileID) const;
+	int32 characterSinkTime(uint actor, int32 tileID) const;
+	int32 characterRiseTime(uint actor, int32 tileID) const;
+
+	void pushTileAnim(const Common::Point &cell, TileAnimKind kind, int32 tileID, int32 time);
+	void pushActorAnim(uint actor, ActorAnimKind kind, const Common::Point &cell, int32 tileID, int32 time);
+	int32 cellQueueTime(const Common::Point &cell) const;
+	int32 actorQueueTime(uint actor) const;
+	bool actorsAnimating() const;
+	void updateActorAnims(int32 ticks);
+	bool updateTileAnims(int32 ticks);
+
+	bool hasAnyMove(uint actor) const;
+	Common::Point chooseOpponentMove(uint actor);
+	void searchPath(uint actor, int depth, Common::Array<int32> &grid, Common::Array<Common::Point> &cells,
+		Common::Array<Common::Point> &path, int32 &bestDist, Common::Array<Common::Point> &bestPath) const;
+
+	void startMove(const Common::Point &target);
+	void updateTurn(int32 ticks);
+	// The outcome the race has reached, or -1 while it is still on.
+	int checkOutcome() const;
+	void endPuzzle(int outcome);
+
+	bool tileRefColor(const Common::Point &cell, const Common::Point &pos, byte &r, byte &g, byte &b) const;
+	bool isCellPixel(const Common::Point &cell, const Common::Point &pos) const;
+	bool findHoveredCell(const Common::Point &mousePos, Common::Point &outCell) const;
+
+	SoundDescription playSoundBlock(const RandomSoundBlock &block);
+	void drawAnimFrame(const Graphics::ManagedSurface &image, const Animation &anim, int32 elapsed,
+		const Common::Point &pos);
+	void drawJump(uint actor, const ActorAnim &anim);
+	void drawActor(uint actor);
+	void drawCell(const Common::Point &cell);
+	void redraw();
+
+	// -- File data --
+	uint16 _busyCursorType = 0;		// while it isn't the player's move
+	uint16 _hoverCursorType = 0;	// over a hex the player can jump onto
+	Common::Path _tileRefName;		// hit mask for a single hex, see findHoveredCell()
+	int32 _defaultSinkTime = 0;		// for animations without frames
+	int32 _defaultRiseTime = 0;
+
+	Common::Array<TileType> _tileTypes;
+
+	// The top-left hex, the hex one row down and the hex one column across; together they
+	// give the row pitch and the staggered column pitch of the honeycomb.
+	Common::Rect _originCell;
+	Common::Rect _rowStepCell;
+	Common::Rect _colStepCell;
+
+	byte _unknownGridFlag = 0;
+	int32 _numCols = 0;
+	int32 _numRows = 0;
+	int32 _hexDelay = 0;			// between one hex of a sliding line and the next
+	Common::Array<int32> _grid;		// row-major tile type ids; 0 means no tile
+
+	Common::Array<Character> _characters;
+	Common::Array<Actor> _actors;
+
+	SceneChangeDescription _solveScene;
+	FlagDescription _solveFlag;
+	RandomSoundBlock _solveSound;
+	SceneChangeDescription _failScene;	// an opponent got there first
+	FlagDescription _failFlag;
+	RandomSoundBlock _failSound;
+
+	Common::Rect _exitHotspot;
+	uint16 _exitCursorType = 0;
+	SceneChangeDescription _exitScene;
+	FlagDescription _exitFlag;
+
+	// -- Runtime state --
+	Common::Array<Common::Point> _actorCells;
+	// An opponent does not head back to the hex it jumped to the turn before last.
+	Common::Array<Common::Point> _lastTargets;
+	Common::Array<Common::Point> _previousTargets;
+	uint _turn = 0;
+	bool _moveIssued = false;		// the actor whose turn it is has jumped
+	bool _slidingPhase = false;		// the jump has landed and the hexes are moving
+	Common::Point _clickedCell;
+	Common::Array<ActorAnim> _landedJumps;
+	Common::Array<Common::Array<TileAnim>> _cellAnims;
+	Common::Array<Common::Array<ActorAnim>> _actorAnims;
+	uint32 _lastTickTime = 0;
+	int _outcome = -1;		// 0 solved, 1 failed
+	bool _exitRequested = false;
+	SoundDescription _endSound;
+
+	Graphics::ManagedSurface _tileRef;
+	Common::Array<TileImages> _tileImages;
+	Common::Array<CharacterImages> _characterImages;
+	Common::Array<MoviePlayer *> _jumpMovies;	// kNumCompassDirections per character
+	Graphics::ManagedSurface _jumpFrame;
+	const MoviePlayer *_jumpFrameMovie = nullptr;
+	int _jumpFrameIndex = -1;
+};
+
+} // End of namespace Action
+} // End of namespace Nancy
+
+#endif // NANCY_ACTION_ESCAPEGRIDPUZZLE_H
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index e0712f5b4b8..e511f6857af 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -37,6 +37,7 @@ MODULE_OBJS = \
   action/puzzle/dotconnectpuzzle.o \
   action/puzzle/drivingpuzzle.o \
   action/puzzle/dropsortpuzzle.o \
+  action/puzzle/escapegridpuzzle.o \
   action/puzzle/gridmappuzzle.o \
   action/puzzle/hamradiopuzzle.o \
   action/puzzle/hangmanpuzzle.o \


Commit: 588da93d0c489bdb88a462f3627427f142346575
    https://github.com/scummvm/scummvm/commit/588da93d0c489bdb88a462f3627427f142346575
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-09-25T21:37:46+03:00

Commit Message:
NANCY: NANCY14+: Implement the new credits renderer

Now, the credits in Nancy14 and Nancy15 are showing up correctly

Changed paths:
    engines/nancy/enginedata.cpp
    engines/nancy/enginedata.h
    engines/nancy/misc/hypertext.cpp
    engines/nancy/misc/hypertext.h
    engines/nancy/state/credits.cpp


diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 9373a0f73b8..7093ff58ba0 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -474,14 +474,21 @@ CRED::CRED(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
 	bool isVampire = g_nancy->getGameType() == kGameTypeVampire;
 	readFilename(*chunkStream, imageName);
 
-	textNames.resize(isVampire ? 7 : 1);
-	for (Common::Path &str : textNames) {
-		readFilename(*chunkStream, str);
-	}
+	if (g_nancy->getGameType() >= kGameTypeNancy14) {
+		// The credits text is a key into the AUTOTEXT chunk instead of an image
+		readFilename(*chunkStream, textKey);
+		chunkStream->skip(0x10);
+		readRect(*chunkStream, textScreenPosition);
+	} else {
+		textNames.resize(isVampire ? 7 : 1);
+		for (Common::Path &str : textNames) {
+			readFilename(*chunkStream, str);
+		}
 
-	chunkStream->skip(0x20);
-	readRect(*chunkStream, textScreenPosition);
-	chunkStream->skip(0x10);
+		chunkStream->skip(0x20);
+		readRect(*chunkStream, textScreenPosition);
+		chunkStream->skip(0x10);
+	}
 
 	updateTime = chunkStream->readUint16LE();
 	pixelsToScroll = chunkStream->readUint16LE();
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index e7f43bded43..15b96c23457 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -260,6 +260,7 @@ struct CRED : public EngineData {
 
 	Common::Path imageName;
 	Common::Array<Common::Path> textNames;
+	Common::String textKey;
 	Common::Rect textScreenPosition;
 	uint16 updateTime;
 	uint16 pixelsToScroll;
diff --git a/engines/nancy/misc/hypertext.cpp b/engines/nancy/misc/hypertext.cpp
index f3cd1c78488..075cd4902b4 100644
--- a/engines/nancy/misc/hypertext.cpp
+++ b/engines/nancy/misc/hypertext.cpp
@@ -30,7 +30,7 @@ namespace Nancy {
 namespace Misc {
 
 struct MetaInfo {
-	enum Type { kColor, kFont, kMark, kHotspot, kUnderline };
+	enum Type { kColor, kFont, kMark, kHotspot, kUnderline, kJustify, kImage };
 
 	Type type;
 	uint numChars;
@@ -95,6 +95,7 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 		int curFontID = fontID;
 		uint numNonSpaceChars = 0;
 		bool hasMark = false;
+		Common::Array<InlineImage> inlineImages;
 
 		// Token braces plus invalid characters that are known to appear in strings
 		Common::StringTokenizer tokenizer(_textLines[lineID], "<>\"");
@@ -107,6 +108,26 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 			if (tokenizer.delimitersAtTokenBegin().lastChar() == '<' && tokenizer.delimitersAtTokenEnd().firstChar() == '>') {
 				switch (curToken.firstChar()) {
 				case 'i' :
+					if (curToken.size() > 1) {
+						// Inline image: <iNAME,left,top,right,bottom>; a left of -1 means the whole image
+						Common::StringTokenizer imageTokenizer(curToken.substr(1), ",");
+						InlineImage inlineImage;
+						inlineImage.name = Common::Path(imageTokenizer.nextToken());
+
+						int coords[4];
+						for (uint i = 0; i < 4; ++i) {
+							coords[i] = atoi(imageTokenizer.nextToken().c_str());
+						}
+
+						if (coords[0] != -1) {
+							inlineImage.src = Common::Rect(coords[0], coords[1], coords[2] + 1, coords[3] + 1);
+						}
+
+						metaInfo.push({MetaInfo::kImage, numNonSpaceChars, (byte)inlineImages.size()});
+						inlineImages.push_back(inlineImage);
+						continue;
+					}
+
 					// CC begin
 					// fall through
 				case 'o' :
@@ -197,8 +218,29 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 						break;
 					}
 
+					// A font set before any text also applies to word wrapping
+					if (numNonSpaceChars == 0) {
+						curFontID = curToken[1] - '0';
+					}
+
 					metaInfo.push({MetaInfo::kFont, numNonSpaceChars, (byte)(curToken[1] - '0')});
 					continue;
+				case 'j' : {
+					// Justification: <jl>, <jr> or <jc>
+					if (curToken.size() != 2) {
+						break;
+					}
+
+					byte justification = kJustifyLeft;
+					if (curToken[1] == 'r') {
+						justification = kJustifyRight;
+					} else if (curToken[1] == 'c') {
+						justification = kJustifyCenter;
+					}
+
+					metaInfo.push({MetaInfo::kJustify, numNonSpaceChars, justification});
+					continue;
+				}
 				case '1':
 				case '2':
 				case '3':
@@ -270,6 +312,7 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 		// respect color tokens
 		uint totalCharsDrawn = 0;
 		byte colorID = _defaultTextColor;
+		uint justification = kJustifyLeft;
 		bool underline = false;
 		uint numNewlineTokens = 0;
 		uint horizontalOffset = 0;
@@ -324,6 +367,17 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 				line.deleteChar(0);
 			}
 
+			// Justification changes and inline images may sit on otherwise empty lines
+			while (metaInfo.size() && totalCharsDrawn >= metaInfo.front().numChars &&
+					(metaInfo.front().type == MetaInfo::kJustify || metaInfo.front().type == MetaInfo::kImage)) {
+				MetaInfo change = metaInfo.pop();
+				if (change.type == MetaInfo::kJustify) {
+					justification = change.index;
+				} else {
+					drawInlineImage(inlineImages[change.index], textBounds, horizontalOffset, justification, font);
+				}
+			}
+
 			bool newWrappedLine = true; // Used to ensure color/font changes don't mess up hotspots
 			while (!line.empty()) {
 				Common::String subLine;
@@ -342,6 +396,12 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 					case MetaInfo::kUnderline:
 						underline = !underline;
 						break;
+					case MetaInfo::kJustify:
+						justification = change.index;
+						break;
+					case MetaInfo::kImage:
+						drawInlineImage(inlineImages[change.index], textBounds, horizontalOffset, justification, font);
+						break;
 					case MetaInfo::kMark: {
 						auto *mark = GetEngineData(MARK);
 						assert(mark);
@@ -403,6 +463,13 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 					}
 				}
 
+				if (newWrappedLine && justification != kJustifyLeft) {
+					int freeSpace = textBounds.width() - (int)horizontalOffset - font->getStringWidth(line);
+					if (freeSpace > 0) {
+						horizontalOffset += justification == kJustifyCenter ? freeSpace / 2 : freeSpace;
+					}
+				}
+
 				uint lineSizeNoSpace = 0;
 				for (uint i = 0; i < line.size(); ++i) {
 					if (!isSpace(line[i])) {
@@ -534,6 +601,27 @@ void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffse
 	_needsTextRedraw = false;
 }
 
+void HypertextParser::drawInlineImage(const InlineImage &inlineImage, const Common::Rect &textBounds, uint horizontalOffset, uint justification, const Font *font) {
+	Graphics::ManagedSurface image;
+	if (!g_nancy->_resource->loadImage(inlineImage.name, image)) {
+		return;
+	}
+
+	Common::Rect src = inlineImage.src.isEmpty() ? Common::Rect(image.w, image.h) : inlineImage.src;
+	src.clip(Common::Rect(image.w, image.h));
+
+	int x = textBounds.left + horizontalOffset;
+	if (justification == kJustifyCenter) {
+		x = textBounds.left + (textBounds.width() - src.width()) / 2;
+	} else if (justification == kJustifyRight) {
+		x = textBounds.right - src.width();
+	}
+
+	int y = textBounds.top + _numDrawnLines * lineStep(font) + _imageVerticalOffset;
+	_fullSurface.blitFrom(image, src, Common::Point(x, y));
+	_imageVerticalOffset += src.height() + font->getFontHeight();
+}
+
 void HypertextParser::clear() {
 	if (_textLines.size()) {
 		_fullSurface.clear(_backgroundColor);
diff --git a/engines/nancy/misc/hypertext.h b/engines/nancy/misc/hypertext.h
index c7565dd6c45..3aaa9d32b34 100644
--- a/engines/nancy/misc/hypertext.h
+++ b/engines/nancy/misc/hypertext.h
@@ -25,6 +25,9 @@
 #include "engines/nancy/renderobject.h"
 
 namespace Nancy {
+
+class Font;
+
 namespace Misc {
 
 // Base class for handling the engine's custom hypertext format
@@ -44,6 +47,14 @@ public:
 	bool hasBeenDrawn() const { return !_needsTextRedraw; }
 
 protected:
+	enum Justification { kJustifyLeft, kJustifyRight, kJustifyCenter };
+
+	// Image embedded in the text via the <iNAME,left,top,right,bottom> tag
+	struct InlineImage {
+		Common::Path name;
+		Common::Rect src; // empty means the whole image
+	};
+
 	void initSurfaces(uint width, uint height, const struct Graphics::PixelFormat &format, uint32 backgroundColor, uint32 highlightBackgroundColor);
 
 	void addTextLine(const Common::String &text);
@@ -53,6 +64,8 @@ protected:
 	void drawAllText(const Common::Rect &textBounds, uint leftOffsetNonNewline, uint fontID, uint highlightFontID);
 	virtual void clear();
 
+	void drawInlineImage(const InlineImage &inlineImage, const Common::Rect &textBounds, uint horizontalOffset, uint justification, const Font *font);
+
 	Graphics::ManagedSurface _fullSurface; 			// Contains all rendered text (may be cropped)
 	Graphics::ManagedSurface _textHighlightSurface; // Same as above, but drawn with the highlight font
 
diff --git a/engines/nancy/state/credits.cpp b/engines/nancy/state/credits.cpp
index e4352372230..48680d70d53 100644
--- a/engines/nancy/state/credits.cpp
+++ b/engines/nancy/state/credits.cpp
@@ -27,6 +27,8 @@
 #include "engines/nancy/cursor.h"
 #include "engines/nancy/util.h"
 
+#include "engines/nancy/misc/hypertext.h"
+
 #include "engines/nancy/state/credits.h"
 
 #include "common/events.h"
@@ -39,6 +41,35 @@ DECLARE_SINGLETON(Nancy::State::Credits);
 namespace Nancy {
 namespace State {
 
+// Renders the Nancy 14+ credits, which are stored as hypertext in the AUTOTEXT chunk
+class CreditsTextRenderer : public Misc::HypertextParser {
+public:
+	void render(const Common::String &text, uint width, Graphics::ManagedSurface &image);
+};
+
+void CreditsTextRenderer::render(const Common::String &text, uint width, Graphics::ManagedSurface &image) {
+	// Default font and maximum surface height of the credits text
+	const uint fontID = 2;
+	const uint maxHeight = 5020;
+
+	_fullSurface.create(width, maxHeight, g_nancy->_graphics->getInputPixelFormat());
+	_fullSurface.clear(g_nancy->_graphics->getTransColor());
+
+	const Font *font = g_nancy->_graphics->getFont(fontID);
+	assert(font);
+	uint margin = (font->getFontHeight() + 1) / 2 + 1;
+
+	Common::Rect textBounds = _fullSurface.getBounds();
+	textBounds.grow(-(int)margin);
+
+	addTextLine(text);
+	drawAllText(textBounds, 0, fontID, fontID);
+
+	uint height = MIN<uint>(_drawnTextHeight + textBounds.top, maxHeight);
+	image.create(width, height, _fullSurface.format);
+	image.blitFrom(_fullSurface, Common::Rect(width, height), Common::Point());
+}
+
 void Credits::process() {
 	switch (_state) {
 	case kInit:
@@ -137,7 +168,16 @@ void Credits::run() {
 void Credits::drawTextSurface(uint id) {
 	Graphics::ManagedSurface image;
 	uint surfaceHeight = _textSurface.getBounds().height();
-	g_nancy->_resource->loadImage(_creditsData->textNames[id], image);
+	if (!_creditsData->textKey.empty()) {
+		const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+		assert(autotext);
+
+		CreditsTextRenderer renderer;
+		renderer.render(autotext->texts[_creditsData->textKey], _textSurface.getBounds().width(), image);
+	} else {
+		g_nancy->_resource->loadImage(_creditsData->textNames[id], image);
+	}
+
 	_fullTextSurface.create(image.w, image.h + (surfaceHeight * 2), g_nancy->_graphics->getInputPixelFormat());
 	_fullTextSurface.setTransparentColor(g_nancy->_graphics->getTransColor());
 	_fullTextSurface.clear(_fullTextSurface.getTransparentColor());




More information about the Scummvm-git-logs mailing list