[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