[Scummvm-git-logs] scummvm master -> 19ec9a963dc3848148065e6025a1733bfb42dbc9

bluegr noreply at scummvm.org
Sat Aug 1 06:14:36 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:
5a35e70090 NANCY: NANCY12: Implement new overlay type, TextLineOverlay
19ec9a963d NANCY: NANCY12: More work on the MinigolfPuzzle


Commit: 5a35e7009011c654e6373251e00fc33a3fa90129
    https://github.com/scummvm/scummvm/commit/5a35e7009011c654e6373251e00fc33a3fa90129
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-01T09:14:21+03:00

Commit Message:
NANCY: NANCY12: Implement new overlay type, TextLineOverlay

Draws a single line of text on top of the scene background. The text is
a value looked up from the player-data table.

Now, the golf course scorecard shows the player scores for each course

Changed paths:
    engines/nancy/action/arfactory.cpp
    engines/nancy/action/datarecords.cpp
    engines/nancy/action/overlay.cpp
    engines/nancy/action/overlay.h
    engines/nancy/puzzledata.cpp
    engines/nancy/puzzledata.h


diff --git a/engines/nancy/action/arfactory.cpp b/engines/nancy/action/arfactory.cpp
index 9255f99ab1e..452cbef1ea9 100644
--- a/engines/nancy/action/arfactory.cpp
+++ b/engines/nancy/action/arfactory.cpp
@@ -277,7 +277,10 @@ ActionRecord *ActionManager::createActionRecord(uint16 type, Common::SeekableRea
 		else
 			return new TableIndexSetValueHS();
 	case 68:
-		return new TextScroll(false);
+		if (g_nancy->getGameType() >= kGameTypeNancy12)
+			return new TextLineOverlay();
+		else
+			return new TextScroll(false);
 	case 69:	// Nancy11
 		return new TimerControl();
 	case 70:
diff --git a/engines/nancy/action/datarecords.cpp b/engines/nancy/action/datarecords.cpp
index 169f46cf02b..0a161754b2f 100644
--- a/engines/nancy/action/datarecords.cpp
+++ b/engines/nancy/action/datarecords.cpp
@@ -127,8 +127,7 @@ void SetValue::execute() {
 	TableData *playerTable = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
 	assert(playerTable);
 
-	// nancy8 has 20 single & 20 combo values, later games have 30/10
-	uint numSingleValues = g_nancy->getGameType() <= kGameTypeNancy8 ? 20 : 30;
+	uint numSingleValues = playerTable->getNumSingleValues();
 
 	if (_index < numSingleValues) {
 		// Single values
@@ -166,8 +165,7 @@ void SetValueCombo::execute() {
 	TableData *playerTable = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
 	assert(playerTable);
 
-	// nancy8 has 20 single & 20 combo values, later games have 30/10
-	uint numSingleValues = g_nancy->getGameType() <= kGameTypeNancy8 ? 20 : 30;
+	uint numSingleValues = playerTable->getNumSingleValues();
 
 	playerTable->setComboValue(_valueIndex - numSingleValues, 0);
 
@@ -231,8 +229,7 @@ void ValueTest::execute() {
 	TableData *playerTable = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
 	assert(playerTable);
 
-	// nancy8 has 20 single & 20 combo values, later games have 30/10
-	uint numSingleValues = g_nancy->getGameType() <= kGameTypeNancy8 ? 20 : 30;
+	uint numSingleValues = playerTable->getNumSingleValues();
 
 	float testedValue;
 	if (_valueIndex < numSingleValues) {
diff --git a/engines/nancy/action/overlay.cpp b/engines/nancy/action/overlay.cpp
index 76787952e36..41828e3db1e 100644
--- a/engines/nancy/action/overlay.cpp
+++ b/engines/nancy/action/overlay.cpp
@@ -33,6 +33,8 @@
 
 #include "common/serializer.h"
 
+#include "graphics/font.h"
+
 namespace Nancy {
 namespace Action {
 
@@ -471,5 +473,64 @@ void TableIndexOverlay::execute() {
 	}
 }
 
+void TextLineOverlay::readData(Common::SeekableReadStream &stream) {
+	_fontID = stream.readUint16LE();
+	_textColor = stream.readUint16LE();
+	_position.x = stream.readSint16LE();
+	stream.skip(2);
+	_position.y = stream.readSint16LE();
+	stream.skip(2);
+	readFilename(stream, _textKey);
+	_tableIndex = stream.readSint16LE();
+}
+
+void TextLineOverlay::execute() {
+	if (_isDone) {
+		return;
+	}
+
+	const Graphics::Font *font = g_nancy->_graphics->getFont(_fontID);
+	if (!font) {
+		return;
+	}
+
+	Common::String text;
+	if (!_textKey.empty()) {
+		text = _textKey;
+	} else {
+		TableData *playerTable = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+		assert(playerTable);
+
+		int16 value = playerTable->getValue(_tableIndex);
+
+		// An unset value is displayed as zero
+		if (value == kNoTableValue) {
+			value = 0;
+		}
+
+		text = Common::String::format("%d", value);
+	}
+
+	uint width = font->getStringWidth(text);
+	uint height = font->getFontHeight();
+	if (!width || !height) {
+		_isDone = true;
+		return;
+	}
+
+	_drawSurface.create(width, height, g_nancy->_graphics->getInputPixelFormat());
+	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	font->drawString(&_drawSurface, text, 0, 0, width, _textColor);
+
+	// The stored y is the baseline (bottom) of the text, so anchor the surface's
+	// bottom edge there rather than its top
+	moveTo(Common::Rect(_position.x, _position.y - (int16)height, _position.x + (int16)width, _position.y));
+	setTransparent(true);
+	setVisible(true);
+	registerGraphics();
+
+	_isDone = true;
+}
+
 } // End of namespace Action
 } // End of namespace Nancy
diff --git a/engines/nancy/action/overlay.h b/engines/nancy/action/overlay.h
index 626952ee1b1..7ae0724c569 100644
--- a/engines/nancy/action/overlay.h
+++ b/engines/nancy/action/overlay.h
@@ -133,6 +133,29 @@ protected:
 	int16 _lastIndexVal = -1;
 };
 
+// Draws a single line of text on top of the scene background. The text is a
+// value looked up from the player-data table (used by the nancy12 minigolf
+// scorecard, where each hole's score is a separate record).
+class TextLineOverlay : public RenderActionRecord {
+public:
+	TextLineOverlay() : RenderActionRecord(8) {}
+	virtual ~TextLineOverlay() {}
+
+	void readData(Common::SeekableReadStream &stream) override;
+	void execute() override;
+
+	bool isViewportRelative() const override { return true; }
+
+protected:
+	Common::String getRecordTypeName() const override { return "TextLineOverlay"; }
+
+	uint16 _fontID = 0;
+	uint16 _textColor = 0;
+	Common::Point _position;
+	Common::String _textKey;
+	int16 _tableIndex = 0;
+};
+
 } // End of namespace Action
 } // End of namespace Nancy
 
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index 2d51a7d6679..0f0613fe9ac 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -302,6 +302,21 @@ float TableData::getComboValue(uint16 index) const {
 	return index < comboValues.size() ? comboValues[index] : kNoTableValue;
 }
 
+uint TableData::getNumSingleValues() const {
+	// nancy8 has 20 single & 20 combo values, later games have 30/10
+	return g_nancy->getGameType() <= kGameTypeNancy8 ? 20 : 30;
+}
+
+int16 TableData::getValue(uint16 index) const {
+	uint numSingleValues = getNumSingleValues();
+	if (index < numSingleValues) {
+		return getSingleValue(index);
+	}
+
+	float value = getComboValue(index - numSingleValues);
+	return (int16)(value + (value < 0 ? -0.5f : 0.5f));
+}
+
 void CellPhoneData::synchronize(Common::Serializer &ser) {
 	ser.syncAsByte(noSignal);
 	ser.syncAsByte(batteryLow);
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index cbd13954866..91f34c81ca0 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -214,6 +214,14 @@ struct TableData : public PuzzleData {
 	void setComboValue(uint16 index, float value);
 	float getComboValue(uint16 index) const;
 
+	// The number of single (non-combo) values, i.e. the boundary between the
+	// single-value and combo-value index ranges: 20 up to nancy8, 30 afterwards.
+	uint getNumSingleValues() const;
+
+	// Reads a value by its combined index (single values come first, then combos).
+	// Combo (float) values are rounded to the nearest integer.
+	int16 getValue(uint16 index) const;
+
 	Common::Array<int16> singleValues;
 	Common::Array<float> comboValues;
 };


Commit: 19ec9a963dc3848148065e6025a1733bfb42dbc9
    https://github.com/scummvm/scummvm/commit/19ec9a963dc3848148065e6025a1733bfb42dbc9
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-08-01T09:14:27+03:00

Commit Message:
NANCY: NANCY12: More work on the MinigolfPuzzle

Still needs some tweaks to ball speed, but it's playable now:

- Draw ghost balls correctly
- Fix aim velocity
- Implement pipe/teleport exits
- Handle ball strike counts
- Implement terrain sand, slopes and sink zones
- Implement Nancy's reaction sounds
- Draw cosmetic overlay sprites

Changed paths:
    engines/nancy/action/actionzone.cpp
    engines/nancy/action/actionzone.h
    engines/nancy/action/puzzle/minigolfpuzzle.cpp
    engines/nancy/action/puzzle/minigolfpuzzle.h


diff --git a/engines/nancy/action/actionzone.cpp b/engines/nancy/action/actionzone.cpp
index 6a2d59f0c78..1002b7f9aad 100644
--- a/engines/nancy/action/actionzone.cpp
+++ b/engines/nancy/action/actionzone.cpp
@@ -79,17 +79,21 @@ void ActionZone::readSubtype(Common::SeekableReadStream &stream, bool isNancy13)
 	case 0x0f:
 		stream.skip(4);		// int16 + int16
 		break;
-	case 3:
-		stream.skip(8);		// double
+	case 3:					// terrain (sand): extra deceleration while the ball is inside
+		terrainDecel = stream.readDoubleLE();
 		break;
 	case 0x17:				// Flat Tire (min/max) - Nancy12 only
 		stream.skip(8);		// int32 + int32
 		break;
-	case 4:
-		stream.skip(10);	// double + int16
+	case 4:					// slope: a velocity kick of slopeForce along slopeAngle
+		slopeForce = stream.readDoubleLE();
+		slopeAngle = stream.readSint16LE();
 		break;
-	case 2:
-		stream.skip(24);	// Rect + int32 + int16 + int16
+	case 2:					// teleport / pipe: exit rect + hold time + exit angle/speed
+		readRect(stream, exitRect);
+		teleportDelay = stream.readSint32LE();
+		exitAngle = stream.readSint16LE();
+		exitSpeed = stream.readSint16LE();
 		break;
 	case 0x0c:				// trigger zone: special effect + target scene id + flag
 		readSpecialEffect(stream);
diff --git a/engines/nancy/action/actionzone.h b/engines/nancy/action/actionzone.h
index 2397ca3b12a..ae3edc77cf4 100644
--- a/engines/nancy/action/actionzone.h
+++ b/engines/nancy/action/actionzone.h
@@ -71,6 +71,23 @@ struct ActionZone {
 	int16 tailId = 0;
 	byte tailFlag = 0;
 
+	// Teleport / pipe subtype (0x02): the ball entering this zone is held for
+	// teleportDelay ms, then re-emerges at exitRect travelling at exitSpeed along
+	// exitAngle (degrees).
+	Common::Rect exitRect;
+	int32 teleportDelay = 0;
+	int16 exitAngle = 0;
+	int16 exitSpeed = 0;
+
+	// Terrain subtype (0x03, e.g. a sand trap): extra deceleration added to the
+	// ball's friction while it is inside this zone.
+	double terrainDecel = 0.0;
+
+	// Slope subtype (0x04, e.g. a sloped green): a velocity kick of slopeForce along
+	// slopeAngle (degrees) applied on entering the zone and removed on leaving.
+	double slopeForce = 0.0;
+	int16 slopeAngle = 0;
+
 	// OverlayZone subtypes (0x0d / 0x16)
 	Common::String overlayName;
 	Common::Array<Common::Rect> overlaySrcRects;
diff --git a/engines/nancy/action/puzzle/minigolfpuzzle.cpp b/engines/nancy/action/puzzle/minigolfpuzzle.cpp
index 7ba3d148078..b514b832a56 100644
--- a/engines/nancy/action/puzzle/minigolfpuzzle.cpp
+++ b/engines/nancy/action/puzzle/minigolfpuzzle.cpp
@@ -28,6 +28,7 @@
 #include "engines/nancy/input.h"
 #include "engines/nancy/cursor.h"
 #include "engines/nancy/util.h"
+#include "engines/nancy/puzzledata.h"
 
 #include "engines/nancy/state/scene.h"
 #include "engines/nancy/action/puzzle/minigolfpuzzle.h"
@@ -36,19 +37,36 @@ namespace Nancy {
 namespace Action {
 
 // TODO - open items:
-//  - Physics constants (power scaling, restitution) are approximations.
-//  - No abandon/exit path (quitting an unsolved hole) is identified.
-
-static const double kMaxDrag = 250.0;		// aim distance (mask px) for a full-power shot
+//  - Wall restitution and the sink-speed threshold (kSinkSpeed) are approximations.
+//  - Leaving an unsolved hole is a scene-level hotspot (not part of this record);
+//    input is gated to the viewport so that hotspot still works, but it isn't
+//    driven from here.
+
+// Struck speed = clamp(dragLen, maxSpeed) * kPowerScale * maxSpeed, in mask px per
+// fixed step; the ball then decelerates linearly by _decel each step, so travel
+// distance scales with drag^2. The physics advance in fixed 30Hz steps to stay
+// frame-rate independent. At this kPowerScale a full-course shot is a small,
+// precise ~24px drag (the three preview balls guide the aim).
+static const double kPowerScale = 0.005;
+static const double kFixedStep = 1.0 / 30.0;
+static const double kRestSpeed = 0.5;		// stop the ball below this per-step speed
+static const double kSinkSpeed = 50.0;		// ball sinks only if it reaches the cup at or below this speed; faster rolls over
 static const double kRestitution = 0.8;		// wall-bounce energy retained
+static const double kDefaultAimDrag = 24.0;	// default aim-cursor distance from the ball (mask px)
+static const byte kZoneTeleport = 2;		// ActionZone subtype for a pipe (enter one hole, exit another)
+static const byte kZoneTerrain = 3;			// ActionZone subtype for terrain (a sand trap that adds friction)
+static const byte kZoneSlope = 4;			// ActionZone subtype for a slope (a velocity kick toward its angle)
+static const byte kZoneOverlay = 0xd;		// ActionZone subtype for a cosmetic overlay sprite
 
 // Isometric projection (mask space -> screen): rotate 45 degrees, foreshorten Y by
 // half. cos45 == sin45; the Y component is additionally scaled by kIsoYScale.
 static const double kIsoCos = 0.70710678118654752;	// cos(pi/4) == sin(pi/4)
 static const double kIsoYScale = 0.5;
 
+static const int kGhostAlpha = 110;			// opacity (0-255) of the preview "virtual balls"
+
 void MinigolfPuzzle::readData(Common::SeekableReadStream &stream) {
-	// 106-byte PuzzleBase header (bulk-copied by the original into the puzzle object).
+	// 106-byte PuzzleBase header.
 	readFilename(stream, _ballImageName);		// 0x00
 	readFilename(stream, _holeBoundaryName);	// 0x21
 	_maxSpeed = stream.readSint32LE();			// 0x42
@@ -57,7 +75,7 @@ void MinigolfPuzzle::readData(Common::SeekableReadStream &stream) {
 	readRect(stream, _teeRect);					// 0x4f - the tee / ball-start square
 	_initialPower = stream.readSint16LE();		// 0x5f
 	_initialAngle = stream.readSint16LE();		// 0x61
-	_winEventFlag = stream.readSint16LE();		// 0x63
+	_strokeCountIndex = stream.readSint16LE();	// 0x63
 	stream.skip(4);								// 0x65
 	_mirrorFlag = stream.readByte();			// 0x69
 
@@ -101,29 +119,38 @@ void MinigolfPuzzle::init() {
 		_maskCenterY = _boundaryMask.h / 2.0;
 	}
 
-	// The hole is the zone that plays the sink sounds (GOL_Sink*). (_teeRect comes
-	// from the header.) All coordinates are in mask/course space. That zone's
-	// "special effect" is really the win transition: its leading id is the scene to
-	// change to when the ball is potted (with the effect being the fade).
-	for (const ActionZone &z : _zones) {
-		bool isHole = false;
+	// The cups are the zones that play the sink sounds (GOL_Sink*), in mask/course
+	// space. A hole can have several (each with its own target scene/flag in its
+	// special effect + tail). A separate zone over each cup carries Nancy's reaction
+	// voice line. The scene/flag/fade are resolved from the cup the ball drops in.
+	_inSlope.resize(_zones.size(), false);
+	for (uint i = 0; i < _zones.size(); ++i) {
+		const ActionZone &z = _zones[i];
+		bool isSink = false;
 		for (const Common::String &n : z._sound.names) {
 			if (n.contains("Sink") || n.contains("sink")) {
-				isHole = true;
+				isSink = true;
 				break;
 			}
 		}
-		if (isHole) {
-			_holeRect = z.rect;
-			_sinkSound = z._sound;
-			_winScene.sceneID = z.specialEffectId;
-			if (z.hasSpecialEffect) {
-				_winHasFade = true;
-				_winFadeType = z.seType;
-				_winFadeTotalTime = z.seTotalTime;
-				_winFadeToBlackTime = z.seFadeToBlackTime;
-				_winFadeRect = z.seRect;
+		if (isSink) {
+			_sinkZones.push_back(i);
+			if (_sinkSound.names.empty()) {
+				_sinkSound = z._sound;
 			}
+		} else if (_reactionSound.names.empty() && !z._sound.names.empty()) {
+			_reactionSound = z._sound;
+		}
+	}
+
+	// A cosmetic overlay sprite (e.g. hole 6a's broken wall), drawn at its dest rect.
+	for (const ActionZone &z : _zones) {
+		if (z.type == kZoneOverlay && !z.overlayName.empty() &&
+				!z.overlaySrcRects.empty() && !z.overlayDestRect.isEmpty()) {
+			g_nancy->_resource->loadImage(Common::Path(z.overlayName), _overlayImage);
+			_overlayImage.setTransparentColor(_drawSurface.getTransparentColor());
+			_overlaySrc = z.overlaySrcRects[0];
+			_overlayDest = z.overlayDestRect;
 			break;
 		}
 	}
@@ -146,14 +173,24 @@ void MinigolfPuzzle::init() {
 		_openColor = _boundaryMask.getPixel((int)_ballX, (int)_ballY);
 	}
 
+	// Seed the slope enter/leave state from the ball's start, so a tee that sits in
+	// a slope doesn't fire a spurious kick on the first step.
+	for (uint i = 0; i < _zones.size(); ++i) {
+		_inSlope[i] = _zones[i].type == kZoneSlope &&
+			_zones[i].rect.contains(Common::Point((int16)_ballX, (int16)_ballY));
+	}
+
 	// A default aim in the level's preset direction, so the preview shows at once.
 	// The cursor sits behind the ball (opposite the launch direction).
 	double a = (double)_initialAngle * (M_PI / 180.0);
-	_aimCursor = Common::Point((int16)(_ballX - cos(a) * kMaxDrag), (int16)(_ballY + sin(a) * kMaxDrag));
+	_aimCursor = Common::Point((int16)(_ballX - cos(a) * kDefaultAimDrag), (int16)(_ballY + sin(a) * kDefaultAimDrag));
 
-	// Every hole starts with the player placing the ball on the tee.
+	// Every hole starts with the player placing the ball on the tee, and its stroke
+	// count reset to zero.
 	_mgState = kPlacing;
 	_lastUpdate = g_nancy->getTotalPlayTime();
+	_strokes = 0;
+	writeStrokeCount();
 
 	redraw();
 }
@@ -199,40 +236,91 @@ void MinigolfPuzzle::drawBall() {
 
 void MinigolfPuzzle::drawAimPreview() {
 	// Velocity the current aim would produce (mirrors launchBall: away from cursor).
-	double aimX = _ballX - _aimCursor.x;
-	double aimY = _ballY - _aimCursor.y;
-	double len = sqrt(aimX * aimX + aimY * aimY);
-	if (len < 3.0) {
+	double vx, vy;
+	aimToVelocity(_ballX - _aimCursor.x, _ballY - _aimCursor.y, vx, vy);
+	if (vx == 0.0 && vy == 0.0) {
 		return;
 	}
-	double power = MIN(len / kMaxDrag, 1.0) * (double)_maxSpeed;
-	double x = _ballX, y = _ballY;
-	double vx = (aimX / len) * power;
-	double vy = (aimY / len) * power;
-
-	// March the shot forward and drop a ghost ball (the ball sprite itself) at
-	// intervals along the predicted path, stopping at the hole or when it stalls.
 	const Common::Rect ghostSrc = _ballFrames.empty() ? Common::Rect() : _ballFrames[0];
+	if (ghostSrc.isEmpty()) {
+		return;
+	}
+
+	// March the shot forward and collect the predicted path.
+	Common::Array<Common::Point> path;
+	double x = _ballX, y = _ballY;
 	const int kSteps = 240;
 	for (int i = 0; i < kSteps; ++i) {
-		bool reachedHole = stepBall(x, y, vx, vy, 1.0 / 30.0, false);
-		if (i % 12 == 0 && !ghostSrc.isEmpty()) {
-			Common::Point gc = projectToScreen(x, y);
-			Common::Point gp(gc.x - ghostSrc.width() / 2, gc.y - ghostSrc.height() / 2);
-			_drawSurface.blitFrom(_image, ghostSrc, gp);
-		}
-		if (reachedHole || sqrt(vx * vx + vy * vy) < 5.0) {
+		bool reachedHole = stepBall(x, y, vx, vy, false);
+		path.push_back(Common::Point((int16)(x + 0.5), (int16)(y + 0.5)));
+		if (reachedHole || sqrt(vx * vx + vy * vy) < kRestSpeed) {
 			break;
 		}
 	}
+
+	// Show exactly 3 "virtual balls" spaced evenly along the path: bunched near the
+	// ball for a soft hit, spread out for a hard one.
+	for (int k = 1; k <= 3; ++k) {
+		uint idx = (path.size() * k) / 3;
+		if (idx > 0) {
+			--idx;
+		}
+		Common::Point gc = projectToScreen(path[idx].x, path[idx].y);
+		Common::Point gp(gc.x - ghostSrc.width() / 2, gc.y - ghostSrc.height() / 2);
+		drawGhostBall(ghostSrc, gp);
+	}
+}
+
+void MinigolfPuzzle::drawGhostBall(const Common::Rect &src, const Common::Point &dest) {
+	// The preview balls are translucent: blend the ball sprite with the course
+	// background so they read as fainter than the real ball.
+	const Graphics::ManagedSurface &bg = NancySceneState.getViewport().getBackground();
+	const Graphics::PixelFormat &fmt = _drawSurface.format;
+	uint32 transColor = _image.getTransparentColor();
+
+	for (int sy = 0; sy < src.height(); ++sy) {
+		int dy = dest.y + sy;
+		if (dy < 0 || dy >= _drawSurface.h) {
+			continue;
+		}
+		for (int sx = 0; sx < src.width(); ++sx) {
+			int dx = dest.x + sx;
+			if (dx < 0 || dx >= _drawSurface.w) {
+				continue;
+			}
+
+			uint32 px = _image.getPixel(src.left + sx, src.top + sy);
+			if (px == transColor) {
+				continue;	// transparent part of the sprite
+			}
+
+			byte ballR, ballG, ballB;
+			_image.format.colorToRGB(px, ballR, ballG, ballB);
+
+			byte bgR = 0, bgG = 0, bgB = 0;
+			if (dx < (int)bg.w && dy < (int)bg.h) {
+				bg.format.colorToRGB(bg.getPixel(dx, dy), bgR, bgG, bgB);
+			}
+
+			byte r = (byte)((ballR * kGhostAlpha + bgR * (255 - kGhostAlpha)) / 255);
+			byte g = (byte)((ballG * kGhostAlpha + bgG * (255 - kGhostAlpha)) / 255);
+			byte b = (byte)((ballB * kGhostAlpha + bgB * (255 - kGhostAlpha)) / 255);
+			_drawSurface.setPixel(dx, dy, fmt.RGBToColor(r, g, b));
+		}
+	}
 }
 
 void MinigolfPuzzle::redraw() {
 	_drawSurface.clear(g_nancy->_graphics->getTransColor());
+	if (!_overlayImage.empty() && !_overlayDest.isEmpty()) {
+		_drawSurface.blitFrom(_overlayImage, _overlaySrc, Common::Point(_overlayDest.left, _overlayDest.top));
+	}
 	if (_mgState == kAiming) {
 		drawAimPreview();
 	}
-	drawBall();
+	if (!_ballHidden) {
+		drawBall();
+	}
 	_needsRedraw = true;
 }
 
@@ -257,32 +345,76 @@ void MinigolfPuzzle::playSoundBlock(const RandomSoundBlock &block) {
 	g_nancy->_sound->playSound(desc);
 }
 
+void MinigolfPuzzle::aimToVelocity(double aimX, double aimY, double &vx, double &vy) const {
+	// Struck speed is proportional to the drag length (clamped to maxSpeed), aimed
+	// along the drag vector: speed = drag * kPowerScale * maxSpeed.
+	double len = sqrt(aimX * aimX + aimY * aimY);
+	if (len < 1.0) {
+		vx = vy = 0.0;
+		return;
+	}
+	double drag = MIN(len, (double)_maxSpeed);
+	double speed = drag * kPowerScale * (double)_maxSpeed;
+	vx = (aimX / len) * speed;
+	vy = (aimY / len) * speed;
+}
+
+void MinigolfPuzzle::pipeExitVelocity(const ActionZone &zone, double inVx, double inVy, double &outVx, double &outVy) const {
+	// A negative exitSpeed/exitAngle means "keep the ball's incoming value".
+	double inSpeed = sqrt(inVx * inVx + inVy * inVy);
+	double speed = zone.exitSpeed < 0 ? inSpeed : (double)zone.exitSpeed;
+	double angle = zone.exitAngle < 0 ? atan2(-inVy, inVx) : (double)zone.exitAngle * (M_PI / 180.0);
+	outVx = speed * cos(angle);
+	outVy = -speed * sin(angle);
+}
+
 void MinigolfPuzzle::launchBall(const Common::Point &maskCursor) {
 	// The club is pulled back behind the ball: the shot travels away from the
 	// cursor (ball - cursor), golf-backswing style, not toward it.
-	double aimX = _ballX - maskCursor.x;
-	double aimY = _ballY - maskCursor.y;
-	double len = sqrt(aimX * aimX + aimY * aimY);
-	if (len < 3.0) {
+	aimToVelocity(_ballX - maskCursor.x, _ballY - maskCursor.y, _velX, _velY);
+	if (_velX == 0.0 && _velY == 0.0) {
 		return;
 	}
 
-	double power = MIN(len / kMaxDrag, 1.0) * (double)_maxSpeed;
-	_velX = (aimX / len) * power;
-	_velY = (aimY / len) * power;
+	// Count the stroke - the scorecard scores the hole by this value.
+	++_strokes;
+	writeStrokeCount();
 
 	playSoundBlock(_puttSound);
 	_mgState = kMoving;
 	_lastUpdate = g_nancy->getTotalPlayTime();
+	_stepAccum = 0.0;
 }
 
-bool MinigolfPuzzle::stepBall(double &x, double &y, double &vx, double &vy, double dt, bool playSounds) {
-	double dispX = vx * dt;
-	double dispY = vy * dt;
+void MinigolfPuzzle::writeStrokeCount() {
+	if (_strokeCountIndex < 0) {
+		return;
+	}
 
-	// Walk the displacement in ~1px sub-steps so a fast ball can't tunnel through
-	// a thin wall (the original halves the step recursively for the same reason),
-	// reflecting off each axis independently so it can slide along an angled wall.
+	TableData *table = (TableData *)NancySceneState.getPuzzleData(TableData::getTag());
+	if (!table) {
+		return;
+	}
+
+	// The stroke-count slot is a combined table index (single values first, then
+	// combos); the scorecard's text overlays read it back the same way.
+	uint boundary = table->getNumSingleValues();
+	uint index = (uint)_strokeCountIndex;
+	if (index < boundary) {
+		table->setSingleValue(index, _strokes);
+	} else {
+		table->setComboValue(index - boundary, (float)_strokes);
+	}
+}
+
+bool MinigolfPuzzle::stepBall(double &x, double &y, double &vx, double &vy, bool playSounds) {
+	// One fixed physics step: the velocity is already in mask px per step.
+	double dispX = vx;
+	double dispY = vy;
+
+	// Walk the displacement in ~1px sub-steps so a fast ball can't tunnel through a
+	// thin wall, reflecting off each axis independently so it can slide along an
+	// angled wall.
 	int steps = (int)ceil(MAX(ABS(dispX), ABS(dispY)));
 	if (steps < 1) {
 		steps = 1;
@@ -295,13 +427,66 @@ bool MinigolfPuzzle::stepBall(double &x, double &y, double &vx, double &vy, doub
 		double nx = x + sx;
 		double ny = y + sy;
 
-		// Potting takes priority over wall reflection: the cup reads as non-fairway
-		// in the mask, so a sub-step that reaches the hole zone sinks the ball rather
-		// than bouncing off it. Checked per sub-step so a fast ball can't skip it.
-		if (!_holeRect.isEmpty() && _holeRect.contains(Common::Point((int16)(nx + 0.5), (int16)(ny + 0.5)))) {
+		// The cups: a ball slow enough drops into whichever cup it's over (potting),
+		// a faster one rolls over it and Nancy reacts. A cup reads as non-fairway in
+		// the mask, so an overshooting ball must skip the wall reflection here (via
+		// continue) or it would bounce off the hole. Checked per sub-step so a fast
+		// ball can't skip it.
+		Common::Point ballPt((int16)(nx + 0.5), (int16)(ny + 0.5));
+		bool overCup = false;
+		for (uint si = 0; si < _sinkZones.size(); ++si) {
+			if (!_zones[_sinkZones[si]].rect.contains(ballPt)) {
+				continue;
+			}
+			if (sqrt(vx * vx + vy * vy) <= kSinkSpeed) {
+				_sunkZone = (int)_sinkZones[si];
+				x = nx;
+				y = ny;
+				return true;
+			}
+			overCup = true;
+			break;
+		}
+		if (overCup) {
+			if (playSounds) {
+				if (!_wasOverHole) {
+					playSoundBlock(_reactionSound);
+				}
+				_wasOverHole = true;
+			}
 			x = nx;
 			y = ny;
-			return true;
+			continue;
+		}
+		if (playSounds) {
+			_wasOverHole = false;
+		}
+
+		// Teleport / pipe zones: entering one whisks the ball to its exit instead of
+		// bouncing off the wall-hole.
+		for (uint zi = 0; zi < _zones.size(); ++zi) {
+			const ActionZone &z = _zones[zi];
+			if (z.type != kZoneTeleport || z.exitRect.isEmpty() ||
+					!z.rect.contains(Common::Point((int16)(nx + 0.5), (int16)(ny + 0.5)))) {
+				continue;
+			}
+
+			if (playSounds) {
+				// Real ball: enter the pipe. updateBall plays the warp sound, holds
+				// the ball out of sight, then emerges it at the exit.
+				_pipeZone = (int)zi;
+				_pipeInVx = vx;
+				_pipeInVy = vy;
+				x = nx;
+				y = ny;
+				vx = vy = 0.0;
+			} else {
+				// Preview: emerge at once so the ghost path continues through the pipe.
+				x = (z.exitRect.left + z.exitRect.right) / 2.0;
+				y = (z.exitRect.top + z.exitRect.bottom) / 2.0;
+				pipeExitVelocity(z, vx, vy, vx, vy);
+			}
+			return false;
 		}
 
 		if (isWall((int)(nx + 0.5), (int)(y + 0.5))) {
@@ -330,12 +515,27 @@ bool MinigolfPuzzle::stepBall(double &x, double &y, double &vx, double &vy, doub
 		y = ny;
 	}
 
-	double f = 1.0 - _decel * dt;
-	if (f < 0.0) {
-		f = 0.0;
+	// Linear friction: shave a fixed amount off the speed each step, keeping the
+	// direction, so the ball decelerates to a stop. A sand trap (terrain zone) the
+	// ball is currently inside adds extra deceleration.
+	double decel = _decel;
+	for (uint zi = 0; zi < _zones.size(); ++zi) {
+		const ActionZone &z = _zones[zi];
+		if (z.type == kZoneTerrain && z.terrainDecel != 0.0 &&
+				z.rect.contains(Common::Point((int16)(x + 0.5), (int16)(y + 0.5)))) {
+			decel += z.terrainDecel;
+		}
+	}
+
+	double speed = sqrt(vx * vx + vy * vy);
+	if (speed > 0.0) {
+		double newSpeed = speed - decel;
+		if (newSpeed < 0.0) {
+			newSpeed = 0.0;
+		}
+		vx = vx / speed * newSpeed;
+		vy = vy / speed * newSpeed;
 	}
-	vx *= f;
-	vy *= f;
 
 	if (playSounds && bounced) {
 		playSoundBlock(_wallSound);
@@ -345,40 +545,107 @@ bool MinigolfPuzzle::stepBall(double &x, double &y, double &vx, double &vy, doub
 
 void MinigolfPuzzle::updateBall() {
 	uint32 now = g_nancy->getTotalPlayTime();
-	double dt = (now - _lastUpdate) / 1000.0;
+	double elapsed = (now - _lastUpdate) / 1000.0;
 	_lastUpdate = now;
-	if (dt <= 0.0) {
+	if (elapsed <= 0.0) {
 		return;
 	}
-	if (dt > 0.1) {
-		dt = 0.1;
+	if (elapsed > 0.25) {
+		elapsed = 0.25;	// don't try to catch up huge gaps (e.g. after a pause)
 	}
 
-	bool reachedHole = stepBall(_ballX, _ballY, _velX, _velY, dt, true);
+	// Advance the physics in fixed 30Hz steps so travel distance is frame-rate
+	// independent.
+	_stepAccum += elapsed;
+	while (_stepAccum >= kFixedStep) {
+		_stepAccum -= kFixedStep;
+
+		// Held inside a pipe: play the warp sound, keep the ball out of sight, then
+		// emerge it at the exit once the hold time (or the sound) is done.
+		if (_pipeZone != -1) {
+			const ActionZone &z = _zones[_pipeZone];
+			if (!_ballHidden) {
+				playSoundBlock(z._sound);
+				_ballHidden = true;
+				_needsRedraw = true;
+				_pipeReleaseTime = z.teleportDelay > 0 ? now + (uint32)z.teleportDelay : 0;
+			}
 
-	if (!_ballFrames.empty()) {
-		_ballFrame = (_ballFrame + 1) % _ballFrames.size();
-	}
+			bool release = z.teleportDelay > 0 ? now >= _pipeReleaseTime
+				: !g_nancy->_sound->isSoundPlaying((uint16)z._sound.channel);
+			if (release) {
+				_ballX = (z.exitRect.left + z.exitRect.right) / 2.0;
+				_ballY = (z.exitRect.top + z.exitRect.bottom) / 2.0;
+				pipeExitVelocity(z, _pipeInVx, _pipeInVy, _velX, _velY);
+				_pipeZone = -1;
+				_ballHidden = false;
+				_needsRedraw = true;
+			}
+			continue;
+		}
 
-	// Potting the ball wins.
-	if (reachedHole) {
-		_velX = _velY = 0.0;
-		_ballX = (_holeRect.left + _holeRect.right) / 2.0;
-		_ballY = (_holeRect.top + _holeRect.bottom) / 2.0;
-		_mgState = kSunk;
-		_solved = true;
-		_sunkTime = now;
-		playSoundBlock(_sinkSound);
-		if (_winEventFlag != -1) {
-			NancySceneState.setEventFlag(_winEventFlag, g_nancy->_true);
+		bool reachedHole = stepBall(_ballX, _ballY, _velX, _velY, true);
+
+		if (_pipeZone != -1) {
+			continue;	// ball just entered a pipe - handle the hold next step
+		}
+
+		// Slopes: kick the ball's velocity toward the slope's angle on entering the
+		// zone, and undo the kick on leaving, so it drifts downhill while inside.
+		for (uint zi = 0; zi < _zones.size(); ++zi) {
+			const ActionZone &z = _zones[zi];
+			if (z.type != kZoneSlope) {
+				continue;
+			}
+			bool inside = z.rect.contains(Common::Point((int16)(_ballX + 0.5), (int16)(_ballY + 0.5)));
+			if (inside == _inSlope[zi]) {
+				continue;
+			}
+			double a = (double)z.slopeAngle * (M_PI / 180.0);
+			double fx = z.slopeForce * cos(a);
+			double fy = -z.slopeForce * sin(a);
+			_velX += inside ? fx : -fx;
+			_velY += inside ? fy : -fy;
+			_inSlope[zi] = inside;
+		}
+
+		if (!_ballFrames.empty()) {
+			_ballFrame = (_ballFrame + 1) % _ballFrames.size();
+		}
+
+		// Potting the ball wins. Resolve the target scene / flag / fade from the cup
+		// the ball actually dropped into (a hole can have several with different
+		// outcomes, e.g. hole 4a's middle cup plays a cutscene).
+		if (reachedHole && _sunkZone >= 0 && _sunkZone < (int)_zones.size()) {
+			const ActionZone &cup = _zones[_sunkZone];
+			_velX = _velY = 0.0;
+			_ballX = (cup.rect.left + cup.rect.right) / 2.0;
+			_ballY = (cup.rect.top + cup.rect.bottom) / 2.0;
+			_mgState = kSunk;
+			_solved = true;
+			_sunkTime = now;
+			playSoundBlock(_sinkSound);
+
+			_winScene.sceneID = cup.specialEffectId;
+			if (cup.type == 0x0c && cup.tailId != -1) {
+				NancySceneState.setEventFlag(cup.tailId, cup.tailFlag ? g_nancy->_true : g_nancy->_false);
+			}
+			if (cup.hasSpecialEffect) {
+				_winHasFade = true;
+				_winFadeType = cup.seType;
+				_winFadeTotalTime = cup.seTotalTime;
+				_winFadeToBlackTime = cup.seFadeToBlackTime;
+				_winFadeRect = cup.seRect;
+			}
+			return;
 		}
-		return;
-	}
 
-	// Coming to rest without sinking readies the next stroke from where it stopped.
-	if (sqrt(_velX * _velX + _velY * _velY) < 5.0) {
-		_velX = _velY = 0.0;
-		_mgState = kAiming;
+		// Coming to rest without sinking readies the next stroke from where it stopped.
+		if (sqrt(_velX * _velX + _velY * _velY) < kRestSpeed) {
+			_velX = _velY = 0.0;
+			_mgState = kAiming;
+			return;
+		}
 	}
 }
 
@@ -427,6 +694,13 @@ void MinigolfPuzzle::handleInput(NancyInput &input) {
 		return;
 	}
 
+	// Only take input over the play area. Clicks outside the viewport (e.g. a
+	// leave / give-up hotspot in the game frame) are left for other records, so we
+	// don't strike the ball when the player is trying to quit the hole.
+	if (!NancySceneState.getViewport().getScreenPosition().contains(input.mousePos)) {
+		return;
+	}
+
 	// Cursor position, converted screen -> viewport -> mask (course) space.
 	Common::Rect screenPt(input.mousePos.x, input.mousePos.y, input.mousePos.x + 1, input.mousePos.y + 1);
 	Common::Rect vpPt = NancySceneState.getViewport().convertScreenToViewport(screenPt);
diff --git a/engines/nancy/action/puzzle/minigolfpuzzle.h b/engines/nancy/action/puzzle/minigolfpuzzle.h
index 3268eeeab82..8a5da664f51 100644
--- a/engines/nancy/action/puzzle/minigolfpuzzle.h
+++ b/engines/nancy/action/puzzle/minigolfpuzzle.h
@@ -52,10 +52,14 @@ protected:
 
 	void redraw();
 	void drawBall();
+	void drawGhostBall(const Common::Rect &src, const Common::Point &dest);
 	void drawAimPreview();
 	void playSoundBlock(const RandomSoundBlock &block);
 	void launchBall(const Common::Point &maskCursor);
-	bool stepBall(double &x, double &y, double &vx, double &vy, double dt, bool playSounds);
+	void writeStrokeCount();			// mirror _strokes into the scorecard's TableData slot
+	void aimToVelocity(double aimX, double aimY, double &vx, double &vy) const;
+	void pipeExitVelocity(const ActionZone &zone, double inVx, double inVy, double &outVx, double &outVy) const;
+	bool stepBall(double &x, double &y, double &vx, double &vy, bool playSounds);
 	void updateBall();
 	bool isWall(int px, int py) const;
 
@@ -71,18 +75,18 @@ protected:
 	Common::Path _ballImageName;		// GOL_Ball_OVL - the ball sprite sheet
 	Common::Path _holeBoundaryName;		// GOL_Hole05B_BNDRY - course collision boundary OVL
 
-	int32 _maxSpeed = 0;				// base+0x42, ball speed cap
-	double _decel = 0.0;				// base+0x46, per-second deceleration
+	int32 _maxSpeed = 0;				// base+0x42, drag/speed scale
+	double _decel = 0.0;				// base+0x46, per-frame linear deceleration (speed units/step)
 	byte _launchMode = 0;				// base+0x4e, ==2 the ball starts pre-placed on the tee
 	int16 _initialPower = 0;			// base+0x5f
 	int16 _initialAngle = 0;			// base+0x61, degrees - the default aim direction
-	int16 _winEventFlag = 0;			// base+0x63, set on sinking the ball
+	int16 _strokeCountIndex = 0;			// base+0x63, TableData slot holding the stroke count for the scorecard
 	byte _mirrorFlag = 0;				// base+0x69
 
-	// Derived: scene shown when the ball is potted, taken from the sink zone's
-	// "special effect" (its leading id is the target scene, the effect is the fade).
+	// A hole can have several cups, each with its own target scene/flag (e.g. hole
+	// 4a's middle cup plays a cutscene). Set from the cup the ball actually drops in.
 	SceneChangeDescription _winScene;
-	bool _winHasFade = false;			// the sink zone's fade, played over the win scene change
+	bool _winHasFade = false;			// the cup's fade, played over the scene change
 	byte _winFadeType = 0;
 	uint16 _winFadeTotalTime = 0;
 	uint16 _winFadeToBlackTime = 0;
@@ -94,7 +98,11 @@ protected:
 	RandomSoundBlock _wallSound;		// played on a wall bounce
 
 	Common::Array<ActionZone> _zones;	// hole/sink/overlay zones
+	Common::Array<bool> _inSlope;		// per-zone: ball currently inside a slope zone (for enter/leave kicks)
+	Common::Array<uint> _sinkZones;		// derived: indices of the cup (sink) zones
+	int _sunkZone = -1;					// zone index of the cup the ball dropped into
 	RandomSoundBlock _sinkSound;		// derived: the hole zone's sound
+	RandomSoundBlock _reactionSound;	// derived: Nancy's voice line when the ball rolls over the cup
 
 	// Runtime state
 	enum State { kPlacing, kAiming, kMoving, kSunk };
@@ -107,21 +115,35 @@ protected:
 	double _vpCenterY = 0.0;			// viewport height / 2
 
 	Common::Rect _teeRect;				// derived: the tee square (course space)
-	Common::Rect _holeRect;				// derived: the sink zone's rect (course space)
 	uint32 _openColor = 0;				// boundary-mask colour that counts as open fairway
 
 	double _ballX = 0.0;
 	double _ballY = 0.0;
 	double _velX = 0.0;
 	double _velY = 0.0;
+	byte _strokes = 0;					// strokes taken this hole; mirrored into _strokeCountIndex
 	uint _ballFrame = 0;
 	Common::Point _aimCursor;			// current cursor position while aiming
 	uint32 _lastUpdate = 0;
+	double _stepAccum = 0.0;			// leftover real time, integrated in fixed 30Hz steps
 	uint32 _sunkTime = 0;
 	bool _solved = false;
+	bool _wasOverHole = false;			// ball was over the cup last step (to fire the reaction once per pass)
+
+	// Pipe / teleport transit
+	int _pipeZone = -1;					// zone holding the ball mid-transit, or -1
+	uint32 _pipeReleaseTime = 0;		// when to emerge (for a timed hold)
+	double _pipeInVx = 0.0;				// velocity on entry (for the "keep incoming" exit sentinels)
+	double _pipeInVy = 0.0;
+	bool _ballHidden = false;			// ball is inside a pipe (not drawn)
 
 	Graphics::ManagedSurface _image;
 	Graphics::ManagedSurface _boundaryMask;
+
+	// Cosmetic overlay sprite (ActionZone type 0xd), e.g. hole 6a's broken wall.
+	Graphics::ManagedSurface _overlayImage;
+	Common::Rect _overlaySrc;
+	Common::Rect _overlayDest;
 };
 
 } // End of namespace Action




More information about the Scummvm-git-logs mailing list