[Scummvm-git-logs] scummvm master -> 09e072597305e6dd5006c85381033d4dcc1dcd8e
bluegr
noreply at scummvm.org
Thu Jul 23 00:55:11 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:
73c586513b NANCY: NANCY13: More work on the Nancy13 cellphone
09e0725973 NANCY: NANCY13: More work on PachinkoPuzzle
Commit: 73c586513b5c348fa79ec85d6d1a2d19535ed2c8
https://github.com/scummvm/scummvm/commit/73c586513b5c348fa79ec85d6d1a2d19535ed2c8
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-23T03:55:01+03:00
Commit Message:
NANCY: NANCY13: More work on the Nancy13 cellphone
- Implement the photo camera functionality and screens
- Draw the Nancy13 background LCD graphics
- Fix the reshuffled buttons
- Name all online sub-button indices through enums
- Extract a drawScrollArrow() helper
- Remove stale TODOs and correct outdated comments
Changed paths:
engines/nancy/enginedata.cpp
engines/nancy/enginedata.h
engines/nancy/puzzledata.cpp
engines/nancy/puzzledata.h
engines/nancy/ui/cellphonepopup.cpp
engines/nancy/ui/cellphonepopup.h
diff --git a/engines/nancy/enginedata.cpp b/engines/nancy/enginedata.cpp
index 8f0434683cb..b41ff97a5f8 100644
--- a/engines/nancy/enginedata.cpp
+++ b/engines/nancy/enginedata.cpp
@@ -978,7 +978,9 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
readFilename(*chunkStream, cameraClickSound);
readRect(*chunkStream, pictureDisplayRect);
- // Camera picture-slot / thumbnail data (int16 rects). Not yet mapped.
+ // TODO: Camera picture-slot / thumbnail data (int16 rects, ~9 records) â
+ // skipped for now. Map these if the picture-view thumbnail grid is ever
+ // implemented (the original stores per-slot source rects here).
chunkStream->skip(114);
readRect(*chunkStream, noPictureScreenRect);
@@ -989,17 +991,25 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
statusTextX = cameraTextX;
statusTextY = cameraTextY;
- // Eight dial/web/dir/camera label SrcDestRectPairs across three on-screen
- // columns (dest x = 437 / 492 / 551), each with alternate glyph variants;
- // take one pair per column for the dial / web / dir labels.
- readRect(*chunkStream, dialLabel.srcRect); // column 1 (x=437)
+ // Eight ribbon-label SrcDestRectPairs across three on-screen columns
+ // (dest x = 437 / 492 / 551). Atlas order is CAM, DIAL, MENU, DIR, DEL,
+ // SEND, YES, NO; each screen shows one label per column.
+ readRect(*chunkStream, dialLabel.srcRect); // CAM (col 437)
readRect(*chunkStream, dialLabel.destRect);
- chunkStream->skip(32); // second x=437 variant
- readRect(*chunkStream, webLabel.srcRect); // column 2 (x=492)
+ readRect(*chunkStream, dialingLabel.srcRect); // DIAL (col 437)
+ readRect(*chunkStream, dialingLabel.destRect);
+ readRect(*chunkStream, webLabel.srcRect); // MENU (col 492)
readRect(*chunkStream, webLabel.destRect);
- readRect(*chunkStream, dirLabel.srcRect); // column 3 (x=551)
+ readRect(*chunkStream, dirLabel.srcRect); // DIR (col 551)
readRect(*chunkStream, dirLabel.destRect);
- chunkStream->skip(4 * 32); // remaining variants
+ readRect(*chunkStream, delLabel.srcRect); // DEL (col 492)
+ readRect(*chunkStream, delLabel.destRect);
+ readRect(*chunkStream, sendLabel.srcRect); // SEND (col 551)
+ readRect(*chunkStream, sendLabel.destRect);
+ readRect(*chunkStream, yesLabel.srcRect); // YES (col 492)
+ readRect(*chunkStream, yesLabel.destRect);
+ readRect(*chunkStream, noLabel.srcRect); // NO (col 551)
+ readRect(*chunkStream, noLabel.destRect);
} else {
readRect(*chunkStream, dialHilite.srcRect);
readRect(*chunkStream, dialHilite.destRect);
@@ -1083,17 +1093,23 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
readRect(*chunkStream, dirCursorSrc);
if (isNancy13) {
- // Online / directory headings â N13 changed these values, not mapped yet.
- chunkStream->skip(48);
+ // Nancy 13 has 11 online sub-buttons (Ghidra widgets 0x10..0x1a). The
+ // first (0x10, at what older games use for the directory heading) is the
+ // Back button, so the whole array is read contiguously here.
+ for (uint i = 0; i < kNumSubButtonsNancy13; ++i) {
+ readRect(*chunkStream, subButtons[i].srcRectIdle);
+ readRect(*chunkStream, subButtons[i].srcRectPressed);
+ readRect(*chunkStream, subButtons[i].destRect);
+ }
} else {
readRect(*chunkStream, dirHeading.srcRect);
readRect(*chunkStream, dirHeading.destRect);
- }
- for (uint i = 0; i < kNumSubButtons; ++i) {
- readRect(*chunkStream, subButtons[i].srcRectIdle);
- readRect(*chunkStream, subButtons[i].srcRectPressed);
- readRect(*chunkStream, subButtons[i].destRect);
+ for (uint i = 0; i < kNumSubButtons; ++i) {
+ readRect(*chunkStream, subButtons[i].srcRectIdle);
+ readRect(*chunkStream, subButtons[i].srcRectPressed);
+ readRect(*chunkStream, subButtons[i].destRect);
+ }
}
readRect(*chunkStream, searchHeading.srcRect);
@@ -1155,7 +1171,10 @@ UICL::UICL(Common::SeekableReadStream *chunkStream) : EngineData(chunkStream) {
}
if (isNancy13) {
- // Trailing captured-picture slot table, added in Nancy 13.
+ // Trailing captured-picture slot table, added in Nancy 13. These are the
+ // game's built-in photo slots; runtime captures are persisted separately
+ // (CellPhonePictureData), so this table is parsed but currently unused.
+ // TODO: identify PictureRecord::unknown (6 bytes, identical across slots).
const uint16 pictureCount = chunkStream->readUint16LE();
pictures.resize(pictureCount);
for (uint i = 0; i < pictureCount; ++i) {
diff --git a/engines/nancy/enginedata.h b/engines/nancy/enginedata.h
index 94c8d725c04..c7e9fd94237 100644
--- a/engines/nancy/enginedata.h
+++ b/engines/nancy/enginedata.h
@@ -614,7 +614,10 @@ struct UICL : public EngineData {
};
static const uint kNumDialPadSlots = 15;
+ // Nancy 10-12 have 10 online sub-buttons; Nancy 13 added an 11th (the Back
+ // button, Ghidra widget 0x10) at the front of the array.
static const uint kNumSubButtons = 10;
+ static const uint kNumSubButtonsNancy13 = 11;
static const uint kNumStatusLabels = 3; // No Signal / No Access / Old Email Only
UICL(Common::SeekableReadStream *chunkStream);
@@ -636,9 +639,17 @@ struct UICL : public EngineData {
int32 statusTextY = 0; // text Y-baseline
SrcDestRectPair welcomeScreen;
Common::String statusLabels[kNumStatusLabels]; // "No Signal", "No Access", "Old Email Only"
- SrcDestRectPair dialLabel;
- SrcDestRectPair webLabel;
- SrcDestRectPair dirLabel;
+ // Ribbon labels above the top-row buttons. In Nancy 13 the top row is
+ // Cam/Menu/Dir, and the review / delete screens reuse the same three columns
+ // with the Del/Send and Yes/No variants (all read from the 8-label block).
+ SrcDestRectPair dialLabel; // Nancy 13: "CAM"
+ SrcDestRectPair webLabel; // Nancy 13: "MENU"
+ SrcDestRectPair dirLabel; // Nancy 13: "DIR"
+ SrcDestRectPair dialingLabel; // Nancy 13: "DIAL"
+ SrcDestRectPair delLabel; // Nancy 13: "DEL"
+ SrcDestRectPair sendLabel; // Nancy 13: "SEND"
+ SrcDestRectPair yesLabel; // Nancy 13: "YES"
+ SrcDestRectPair noLabel; // Nancy 13: "NO"
// Help "?" button (original button index 15). The visible Talk/Call key
// is dial-pad slot 12, not a separate widget.
@@ -664,7 +675,7 @@ struct UICL : public EngineData {
Common::Rect dirCursorSrc;
SrcDestRectPair dirHeading;
- ThreeRectWidget subButtons[kNumSubButtons];
+ ThreeRectWidget subButtons[kNumSubButtonsNancy13];
// Heading/icon SRC+DEST pairs
SrcDestRectPair searchHeading;
diff --git a/engines/nancy/puzzledata.cpp b/engines/nancy/puzzledata.cpp
index e4f0036d52d..797b2cea943 100644
--- a/engines/nancy/puzzledata.cpp
+++ b/engines/nancy/puzzledata.cpp
@@ -352,6 +352,29 @@ void CellPhoneData::syncLinkArray(Common::Serializer &ser, Common::Array<SearchL
}
}
+void CellPhonePictureData::synchronize(Common::Serializer &ser) {
+ uint16 numPictures = (uint16)pictures.size();
+ ser.syncAsUint16LE(numPictures);
+ if (ser.isLoading()) {
+ pictures.resize(numPictures);
+ }
+
+ for (uint16 i = 0; i < numPictures; ++i) {
+ CapturedPicture &p = pictures[i];
+ ser.syncAsUint16LE(p.width);
+ ser.syncAsUint16LE(p.height);
+ ser.syncAsByte(p.sent);
+
+ uint32 numBytes = (uint32)p.width * p.height * 4;
+ if (ser.isLoading()) {
+ p.pixels.resize(numBytes);
+ }
+ if (numBytes) {
+ ser.syncBytes(p.pixels.data(), numBytes);
+ }
+ }
+}
+
void TimerData::synchronize(Common::Serializer &ser) {
for (uint i = 0; i < kNumTimers; ++i) {
Timer &t = timers[i];
@@ -445,6 +468,8 @@ PuzzleData *makePuzzleData(const uint32 tag) {
return new TableData();
case CellPhoneData::getTag():
return new CellPhoneData();
+ case CellPhonePictureData::getTag():
+ return new CellPhonePictureData();
case TimerData::getTag():
return new TimerData();
case UIResourceData::getTag():
diff --git a/engines/nancy/puzzledata.h b/engines/nancy/puzzledata.h
index 1d8965de105..bc4534db4c3 100644
--- a/engines/nancy/puzzledata.h
+++ b/engines/nancy/puzzledata.h
@@ -245,6 +245,27 @@ private:
void syncLinkArray(Common::Serializer &ser, Common::Array<SearchLink> &arr);
};
+// A cell-phone camera snapshot (Nancy 13). Stored as raw BGRA32 pixels so it
+// survives save/load independently of the scene it was taken from.
+struct CapturedPicture {
+ uint16 width = 0;
+ uint16 height = 0;
+ Common::Array<byte> pixels; // width * height * 4, BGRA32
+ bool sent = false; // true once the player has "sent" it
+};
+
+// Nancy 13 camera snapshots. Kept in its own lazily-created PuzzleData chunk so
+// existing saves and the CELL chunk are untouched (no save-version bump).
+struct CellPhonePictureData : public PuzzleData {
+ CellPhonePictureData() {}
+ virtual ~CellPhonePictureData() {}
+
+ static constexpr uint32 getTag() { return MKTAG('C', 'P', 'I', 'C'); }
+ virtual void synchronize(Common::Serializer &ser);
+
+ Common::Array<CapturedPicture> pictures;
+};
+
// Nancy 11+ AR 69 (TimerControl). 10 software timers, each counting up from
// zero. A "configured" timer (state 5/6) fires a set of event flags, plays an
// optional sound and shows an optional caption once its target duration
diff --git a/engines/nancy/ui/cellphonepopup.cpp b/engines/nancy/ui/cellphonepopup.cpp
index 9094a7c0dc9..870905294ce 100644
--- a/engines/nancy/ui/cellphonepopup.cpp
+++ b/engines/nancy/ui/cellphonepopup.cpp
@@ -36,12 +36,64 @@
#include "engines/nancy/state/scene.h"
#include "engines/nancy/ui/taskbar.h"
+#include "engines/nancy/ui/viewport.h"
+
+#include "graphics/surface.h"
#include "engines/nancy/ui/cellphonepopup.h"
namespace Nancy {
namespace UI {
+// Nancy 13 LCD tiles in the UI_Cell_Xtra atlas. The original composes the LCD
+// background into a pre-rendered per-state surface; these are the fixed source
+// rects for the plain keyboard background, the camera framing box, and the five
+// message screens. The grid is anchored to the two rects stored in the chunk
+// (the "[<?>]" help and "[<->]" framing tiles): 171x164 tiles, 173px column
+// pitch, 166px row pitch.
+static const Common::Rect kN13PlainBg(174, 187, 345, 351);
+static const Common::Rect kN13MsgWelcome(1, 353, 172, 517);
+static const Common::Rect kN13MsgPictureSent(174, 353, 345, 517);
+static const Common::Rect kN13MsgNoPictures(347, 353, 518, 517);
+static const Common::Rect kN13MsgCameraFull(1, 519, 172, 683);
+static const Common::Rect kN13MsgDeleteConfirm(174, 519, 345, 683);
+static const Common::Rect kN13MsgPictureDeleted(347, 519, 518, 683);
+// The two-phones "connecting" LCD graphic, drawn over the plain background while
+// a call is being placed. (This is the rect the chunk stores as the idle sprite
+// source; the "Welcome / River Heights" tile is kN13MsgWelcome.)
+static const Common::Rect kN13CallGraphic(347, 187, 518, 351);
+
+// Nancy 13 online sub-button roles (index = Ghidra widget id â 0x10). Nancy
+// 10-12 use a different 10-button layout, so these apply only when the game is
+// Nancy 13 or newer. Widget 0x10 (the Back button) is the extra one Nancy 13
+// prepended to the array.
+enum {
+ kN13SubBack = 0, // small-LCD Back (directory / menu / picture view)
+ kN13SubDirUp = 1,
+ kN13SubDirDown = 2,
+ kN13SubViewPics = 3, // Menu "View Pictures" option (widget 0x13 -> state 0xb)
+ kN13SubEmail = 4, // Menu "E-mail / Messaging" option (widget 0x14)
+ kN13SubBrowser = 5, // Menu "Internet Browser" option (widget 0x15, removed)
+ kN13SubListUp = 6, // zoomed list / picture-view paging
+ kN13SubListDown = 7,
+ kN13SubBackFull = 8 // Back at the bottom of a zoomed (full-screen) list
+};
+
+// Nancy 10-12 online sub-button roles (the 10-button layout, before Nancy 13
+// prepended a dedicated Back button and reshuffled the rest).
+enum {
+ kSubBack = 0, // small-LCD Back (directory / online hub)
+ kSubDirUp = 1, // directory / help scroll up
+ kSubDirDown = 2, // directory / help scroll down
+ kSubEmail = 3, // hub "E-mail / Messaging" option
+ kSubWeb = 4, // hub "Internet Browser" option
+ kSubListUp = 5, // zoomed list scroll up
+ kSubListDown = 6, // zoomed list scroll down
+ kSubEmailBack = 7, // email list / zoomed content Back (to the hub)
+ kSubSearch = 8, // browser-page SEARCH button
+ kSubWebHome = 9 // web search-list HOME (to the browser homepage)
+};
+
// Contacts are shown alphabetically by name (case-insensitive), matching
// the original's CCellPhoneSortContacts.
static bool contactNameLess(const UICL::Contact &a, const UICL::Contact &b) {
@@ -366,7 +418,10 @@ void CellPhonePopup::updateGraphics() {
return;
}
- // TODO: web/email/search/help/browser modes not implemented.
+ // Per-frame state advancement. Only the call-progress states below drive
+ // themselves (via ring/pickup sounds and timers); the interactive screens
+ // (welcome, directory, online hub, lists, content, camera, ...) stay put
+ // until the user acts, so they do nothing here.
switch (_screenState) {
case kWelcome:
case kDialing:
@@ -375,6 +430,10 @@ void CellPhonePopup::updateGraphics() {
case kWebList:
case kEmailList:
case kContentView:
+ case kCamera:
+ case kPictureView:
+ case kDeleteConfirm:
+ case kMessageScreen:
break;
case kPlaceCall:
@@ -491,8 +550,39 @@ void CellPhonePopup::drawChrome() {
}
void CellPhonePopup::drawScreenContent() {
+ if (_inCameraFraming) {
+ // Camera framing: the popup covers the viewport and shows only the
+ // movable framing box; the scene shows through the transparent surface.
+ drawCameraFraming();
+ return;
+ }
+
drawChrome();
+ // Nancy 13 fills the LCD with a background graphic on every keyboard
+ // screen (earlier games left it blank). The expanded/zoomed screens keep
+ // their white background, so only draw it on the non-zoomed states.
+ const bool n13Keyboard = g_nancy->getGameType() >= kGameTypeNancy13 && !isZoomedChromeState();
+ if (n13Keyboard) {
+ drawLcdTile(kN13PlainBg);
+ // While a call is being placed, the LCD shows the two-phones "connecting"
+ // picture over the plain background. (The idle Welcome picture is drawn in
+ // the kWelcome case below.)
+ switch (_screenState) {
+ case kPlaceCall:
+ case kWaitOutgoingRing:
+ case kLookupContact:
+ case kWaitPickup:
+ case kConnected:
+ case kInvalidNumber:
+ case kWaitInvalid:
+ drawLcdTile(kN13CallGraphic);
+ break;
+ default:
+ break;
+ }
+ }
+
// Signal + battery indicators show on the welcome screen; the connected
// in-call screen keeps the battery but not the signal. Every other state
// (dialing, ringing, lists, browser, ...) hides both.
@@ -505,9 +595,20 @@ void CellPhonePopup::drawScreenContent() {
switch (_screenState) {
case kWelcome:
drawWebDirLabels();
+ if (n13Keyboard) {
+ // Nancy 13's top row is Cam / Menu / Dir; drawWebDirLabels() paints
+ // Menu + Dir, so add the Cam label. The "Welcome / River Heights
+ // Wireless" picture sits over the plain keyboard background.
+ drawRibbonLabel(_uiclData->dialLabel);
+ if (!_noSignal) {
+ drawLcdTile(kN13MsgWelcome);
+ }
+ }
if (_noSignal) {
drawStatusLabels();
- } else {
+ } else if (!n13Keyboard) {
+ // Earlier games draw the welcome graphic here; Nancy 13 already
+ // painted the keyboard background above.
drawWelcomeScreen();
}
break;
@@ -532,7 +633,7 @@ void CellPhonePopup::drawScreenContent() {
}
// Back button on the connecting strip cancels the ringing call.
if (isCallBackButtonActive()) {
- drawBackButton(0);
+ drawBackButton(kSubBack);
}
break;
@@ -540,7 +641,7 @@ void CellPhonePopup::drawScreenContent() {
// Still ringing â only the connecting sprite, plus the Back button.
drawConnectingSprite();
if (isCallBackButtonActive()) {
- drawBackButton(0);
+ drawBackButton(kSubBack);
}
break;
@@ -561,16 +662,29 @@ void CellPhonePopup::drawScreenContent() {
drawDirectoryList();
drawDirectoryArrows();
drawHeading(_uiclData->dialHilite);
- drawBackButton(0);
+ drawBackButton(kSubBack);
+ if (n13Keyboard) {
+ // Relabel the first bottom button (Cam): "Send" while choosing a photo
+ // recipient, "Dial" while browsing contacts to place a call.
+ drawRibbonLabelAt(_sendingPicture ? _uiclData->sendLabel.srcRect
+ : _uiclData->dialingLabel.srcRect,
+ _uiclData->dialLabel.destRect);
+ }
break;
case kOnlineHub: {
drawHeading(_uiclData->onlineHeading);
- drawBackButton(0);
- // Email / Web option buttons (subButtons 3 and 4) sit inside the LCD.
- // Each highlights (its pressed sprite) when the cursor is over it.
- drawHubButton(3);
- drawHubButton(4);
+ drawBackButton(kSubBack);
+ // The two LCD option buttons highlight (pressed sprite) on hover. Nancy
+ // 13's Menu is View Pictures (subButtons[3]) + E-mail (subButtons[4]);
+ // the removed web browser was subButtons[5].
+ if (g_nancy->getGameType() >= kGameTypeNancy13) {
+ drawHubButton(kN13SubEmail);
+ drawHubButton(kN13SubViewPics);
+ } else {
+ drawHubButton(kSubEmail);
+ drawHubButton(kSubWeb);
+ }
break;
}
@@ -580,14 +694,15 @@ void CellPhonePopup::drawScreenContent() {
drawHeading(_uiclData->searchHeading);
drawLinkList();
drawDirectoryArrows();
- drawBackButton(9);
+ drawBackButton(kSubWebHome);
break;
case kEmailList:
drawHeading(_uiclData->emailHeading);
drawLinkList();
drawDirectoryArrows();
- drawBackButton(7);
+ // Zoomed-list Back: subButtons[7] before Nancy 13, [8] in Nancy 13.
+ drawBackButton(g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubBackFull : kSubEmailBack);
break;
case kContentView:
@@ -601,10 +716,49 @@ void CellPhonePopup::drawScreenContent() {
drawDirectoryArrows();
drawBackButton(contentViewBottomButton());
if (_contentHeading == &_uiclData->browserHeading && !isBrowserArticle()) {
- drawHubButton(8);
+ drawHubButton(kSubSearch);
+ }
+ break;
+
+ case kCamera:
+ // The framing overlay is drawn by the _inCameraFraming short-circuit at
+ // the top of this function.
+ break;
+
+ case kDeleteConfirm:
+ // The "DELETE? YES OR NO" prompt tile plus the Yes / No ribbon labels.
+ drawLcdTile(kN13MsgDeleteConfirm);
+ drawRibbonLabel(_uiclData->yesLabel);
+ drawRibbonLabel(_uiclData->noLabel);
+ break;
+
+ case kMessageScreen:
+ // A transient message tile (Picture Sent / Deleted / Camera Full).
+ if (_messageTileSrc) {
+ drawLcdTile(*_messageTileSrc);
+ }
+ drawBackButton(kSubBack);
+ break;
+
+ case kPictureView: {
+ // Nancy 13 "view pictures": the captured photo (or the "no pictures"
+ // background) fills the LCD, with the Cam / Del / Send ribbon labels and
+ // a Back button.
+ drawPictureView();
+ drawRibbonLabel(_uiclData->dialLabel); // CAM (retake)
+ drawRibbonLabel(_uiclData->delLabel);
+ drawRibbonLabel(_uiclData->sendLabel);
+ drawBackButton(kSubBack);
+ // Paging arrows appear only when there is more than one photo to leaf
+ // through (they page _pictureIndex; see handleInput).
+ const CellPhonePictureData *pd = pictureData();
+ if (pd && pd->pictures.size() > 1) {
+ drawScrollArrow(scrollUpButton(), _scrollUpHovered);
+ drawScrollArrow(scrollDownButton(), _scrollDownHovered);
}
break;
}
+ }
// Keypad depress feedback sits on top of everything else.
drawPressedDialKey();
@@ -653,15 +807,30 @@ void CellPhonePopup::drawWebDirLabels() {
}
void CellPhonePopup::drawDialLabel() {
- const UICL::SrcDestRectPair &dl = _uiclData->dialLabel;
- if (dl.srcRect.isEmpty() || dl.destRect.isEmpty()) {
+ drawRibbonLabel(_uiclData->dialLabel);
+}
+
+void CellPhonePopup::drawRibbonLabel(const UICL::SrcDestRectPair &label) {
+ if (label.srcRect.isEmpty() || label.destRect.isEmpty()) {
return;
}
+ const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
+ _drawSurface.blitFrom(_spritesImage, label.srcRect,
+ Common::Point(label.destRect.left - chunkOrigin.x,
+ label.destRect.top - chunkOrigin.y));
+}
+void CellPhonePopup::drawRibbonLabelAt(const Common::Rect &src, const Common::Rect &dest) {
+ // Draw one ribbon-label sprite at another label's slot â used to relabel the
+ // first bottom button (Cam) as Dial in the directory and Send in the
+ // picture-recipient chooser.
+ if (src.isEmpty() || dest.isEmpty()) {
+ return;
+ }
const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
- _drawSurface.blitFrom(_spritesImage, dl.srcRect,
- Common::Point(dl.destRect.left - chunkOrigin.x,
- dl.destRect.top - chunkOrigin.y));
+ _drawSurface.blitFrom(_spritesImage, src,
+ Common::Point(dest.left - chunkOrigin.x,
+ dest.top - chunkOrigin.y));
}
void CellPhonePopup::drawTypeMessage() {
@@ -897,6 +1066,13 @@ void CellPhonePopup::openContentView(const Common::String &key, const UICL::SrcD
}
void CellPhonePopup::openBrowserHome() {
+ // The web browser was removed in Nancy 13 (its atlas CUR_URLimages_OVL no
+ // longer ships); the Menu's second option there is "view pictures", wired
+ // separately. Never open the (missing) browser for Nancy 13.
+ if (g_nancy->getGameType() >= kGameTypeNancy13) {
+ return;
+ }
+
// The Web button opens the browser home page (UIBW page 0 â the "River
// Heights Wireless" homepage). Its in-page hyperlinks then navigate to
// further pages / the search list.
@@ -1138,9 +1314,171 @@ void CellPhonePopup::drawWelcomeScreen() {
ws.destRect.top - chunkOrigin.y));
}
+void CellPhonePopup::drawLcdTile(const Common::Rect &src) {
+ const Common::Rect &lcd = _uiclData->welcomeScreen.destRect;
+ if (src.isEmpty() || lcd.isEmpty()) {
+ return;
+ }
+ const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
+ _drawSurface.blitFrom(_spritesImage, src,
+ Common::Point(lcd.left - chunkOrigin.x,
+ lcd.top - chunkOrigin.y));
+}
+
+void CellPhonePopup::showMessageScreen(const Common::Rect &tileSrc, ScreenState returnState) {
+ _messageTileSrc = &tileSrc;
+ _messageReturnState = returnState;
+ enterScreenState(kMessageScreen);
+}
+
+CellPhonePictureData *CellPhonePopup::pictureData() const {
+ // Lazily created (Nancy 13 only) â persists in the 'CPIC' save chunk.
+ return (CellPhonePictureData *)NancySceneState.getPuzzleData(CellPhonePictureData::getTag());
+}
+
+void CellPhonePopup::drawPictureView() {
+ CellPhonePictureData *pd = pictureData();
+ if (!pd || pd->pictures.empty()) {
+ drawLcdTile(kN13MsgNoPictures);
+ return;
+ }
+
+ _pictureIndex = CLIP<int>(_pictureIndex, 0, (int)pd->pictures.size() - 1);
+ const CapturedPicture &pic = pd->pictures[_pictureIndex];
+ if (pic.width == 0 || pic.height == 0 ||
+ pic.pixels.size() < (uint)pic.width * (uint)pic.height * 4) {
+ return;
+ }
+
+ const Common::Rect &lcd = _uiclData->welcomeScreen.destRect;
+ if (lcd.isEmpty()) {
+ return;
+ }
+ const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
+
+ // Wrap the stored BGRA32 pixels and scale them into the LCD area.
+ Graphics::Surface src;
+ src.init(pic.width, pic.height, pic.width * 4,
+ const_cast<byte *>(pic.pixels.data()),
+ g_nancy->_graphics->getScreenPixelFormat());
+ Graphics::Surface *scaled = src.scale((int16)lcd.width(), (int16)lcd.height(), false);
+ if (scaled) {
+ _drawSurface.blitFrom(*scaled,
+ Common::Point(lcd.left - chunkOrigin.x, lcd.top - chunkOrigin.y));
+ scaled->free();
+ delete scaled;
+ }
+}
+
+void CellPhonePopup::captureViewport(const Common::Rect &screenRegion) {
+ CellPhonePictureData *pd = pictureData();
+ if (!pd) {
+ return;
+ }
+
+ const Viewport &viewport = NancySceneState.getViewport();
+ const Graphics::ManagedSurface &vp = viewport.getBackground();
+ if (vp.w == 0 || vp.h == 0) {
+ return;
+ }
+
+ // Translate the framed screen region into the (scrolled) viewport background.
+ const Common::Rect vpScreen = viewport.getScreenPosition();
+ const int scrollY = (int)viewport.getCurVerticalScroll();
+ Common::Rect grab;
+ if (screenRegion.isEmpty()) {
+ grab = Common::Rect(0, scrollY, vpScreen.width(), scrollY + vpScreen.height());
+ } else {
+ grab = screenRegion;
+ grab.translate(-vpScreen.left, -vpScreen.top + scrollY);
+ }
+ grab.clip(Common::Rect(vp.w, vp.h));
+ if (grab.isEmpty()) {
+ return;
+ }
+
+ // Copy the sub-area, converted to BGRA32 so it can be re-displayed and saved.
+ Graphics::Surface sub = vp.rawSurface().getSubArea(grab);
+ Graphics::Surface *conv = sub.convertTo(g_nancy->_graphics->getScreenPixelFormat());
+ if (!conv) {
+ return;
+ }
+
+ CapturedPicture pic;
+ pic.width = (uint16)conv->w;
+ pic.height = (uint16)conv->h;
+ pic.pixels.resize((uint)pic.width * (uint)pic.height * 4);
+ for (int y = 0; y < conv->h; ++y) {
+ memcpy(pic.pixels.data() + (uint)y * (uint)pic.width * 4,
+ conv->getBasePtr(0, y), (uint)pic.width * 4);
+ }
+ conv->free();
+ delete conv;
+
+ pd->pictures.push_back(pic);
+ _pictureIndex = (int)pd->pictures.size() - 1;
+}
+
+Common::Rect CellPhonePopup::framingScreenRect() const {
+ const Common::Rect vp = NancySceneState.getViewport().getScreenPosition();
+ const int w = MIN<int>(kFramingWidth, vp.width());
+ const int h = MIN<int>(kFramingHeight, vp.height());
+ const int cx = CLIP<int>(_framingMouse.x, vp.left + w / 2, vp.right - w / 2);
+ const int cy = CLIP<int>(_framingMouse.y, vp.top + h / 2, vp.bottom - h / 2);
+ return Common::Rect(cx - w / 2, cy - h / 2, cx - w / 2 + w, cy - h / 2 + h);
+}
+
+void CellPhonePopup::enterCameraFraming() {
+ if (_inCameraFraming) {
+ return;
+ }
+ _inCameraFraming = true;
+ _savedPhoneRect = _screenPosition;
+
+ // Grow the popup to cover the viewport so the framing box can be drawn
+ // anywhere over the live scene.
+ const Common::Rect vp = NancySceneState.getViewport().getScreenPosition();
+ moveTo(vp);
+ _drawSurface.create(vp.width(), vp.height(), g_nancy->_graphics->getScreenPixelFormat());
+ setTransparent(true);
+ _framingMouse = Common::Point(vp.left + vp.width() / 2, vp.top + vp.height() / 2);
+}
+
+void CellPhonePopup::exitCameraFraming() {
+ if (!_inCameraFraming) {
+ return;
+ }
+ _inCameraFraming = false;
+ moveTo(_savedPhoneRect);
+ Common::Rect bounds = _screenPosition;
+ bounds.moveTo(0, 0);
+ _drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getScreenPixelFormat());
+ setTransparent(false);
+}
+
+void CellPhonePopup::drawCameraFraming() {
+ const uint32 trans = g_nancy->_graphics->getTransColor();
+ _drawSurface.clear(trans);
+
+ Common::Rect box = framingScreenRect();
+ box.translate(-_screenPosition.left, -_screenPosition.top);
+
+ // TODO: The original swaps the mouse cursor for a framing-rectangle sprite
+ // loaded from the game resources (via the scene-prep capture path, not the
+ // UICL chunk); locate that cursor and blit it here instead of the drawn
+ // outline. The 220x176 framing size is likewise a placeholder to confirm.
+ const uint32 col = _drawSurface.format.RGBToColor(255, 255, 255);
+ _drawSurface.frameRect(box, col);
+ if (box.width() > 4 && box.height() > 4) {
+ box.grow(-1);
+ _drawSurface.frameRect(box, col);
+ }
+ _needsRedraw = true;
+}
+
void CellPhonePopup::drawBackButton(uint subButtonIndex) {
- // subButtons[0] is the Back button in the lower ribbon (help / sub-screens);
- // subButtons[7] is the Back button at the bottom of the zoomed content view.
+ // kSubBack is the Back button in the lower ribbon (help / sub-screens);
+ // kSubEmailBack is the Back button at the bottom of the zoomed content view.
const UICL::ThreeRectWidget &back = _uiclData->subButtons[subButtonIndex];
if (back.destRect.isEmpty()) {
return;
@@ -1216,11 +1554,13 @@ int CellPhonePopup::currentBackButtonIndex() const {
switch (_screenState) {
case kDirectory:
case kOnlineHub:
- return 0;
+ case kPictureView:
+ case kMessageScreen:
+ return kSubBack;
case kWebList:
- return 9;
+ return kSubWebHome;
case kEmailList:
- return 7;
+ return kSubEmailBack;
case kContentView:
return (int)contentViewBottomButton();
default:
@@ -1231,9 +1571,9 @@ int CellPhonePopup::currentBackButtonIndex() const {
uint CellPhonePopup::contentViewBottomButton() const {
// Help Back (0); browser-article HOME (9); main page / email article Back (7).
if (isHelpContentView()) {
- return 0;
+ return kSubBack;
}
- return isBrowserArticle() ? 9 : 7;
+ return isBrowserArticle() ? kSubWebHome : kSubEmailBack;
}
bool CellPhonePopup::isBrowserArticle() const {
@@ -1245,47 +1585,47 @@ bool CellPhonePopup::isBrowserArticle() const {
}
const UICL::ThreeRectWidget &CellPhonePopup::scrollUpButton() const {
- // Directory and help both scroll with the small-LCD arrow pair
- // (subButtons[1]/[2]); the zoomed email / browser articles use [5]/[6].
- return (_screenState == kDirectory || isHelpContentView())
- ? _uiclData->subButtons[1]
- : _uiclData->subButtons[5];
+ // Directory and help scroll with the small-LCD arrow pair (subButtons[1]/[2]);
+ // the zoomed email / browser lists use a different pair. Nancy 13 keeps the
+ // directory on [1]/[2] and uses the list arrows [6]/[7] elsewhere; earlier
+ // games use [5]/[6] for the zoomed lists.
+ const bool smallLcd = _screenState == kDirectory || isHelpContentView();
+ if (g_nancy->getGameType() >= kGameTypeNancy13) {
+ return smallLcd ? _uiclData->subButtons[kN13SubDirUp]
+ : _uiclData->subButtons[kN13SubListUp];
+ }
+ return smallLcd ? _uiclData->subButtons[kSubDirUp] : _uiclData->subButtons[kSubListUp];
}
const UICL::ThreeRectWidget &CellPhonePopup::scrollDownButton() const {
- return (_screenState == kDirectory || isHelpContentView())
- ? _uiclData->subButtons[2]
- : _uiclData->subButtons[6];
+ const bool smallLcd = _screenState == kDirectory || isHelpContentView();
+ if (g_nancy->getGameType() >= kGameTypeNancy13) {
+ return smallLcd ? _uiclData->subButtons[kN13SubDirDown]
+ : _uiclData->subButtons[kN13SubListDown];
+ }
+ return smallLcd ? _uiclData->subButtons[kSubDirDown] : _uiclData->subButtons[kSubListDown];
}
-void CellPhonePopup::drawDirectoryArrows() {
- // Up/down scroll arrows are not in the chrome image; blit on every redraw.
+void CellPhonePopup::drawScrollArrow(const UICL::ThreeRectWidget &arrow, bool hovered) {
+ // Scroll/paging arrows are not in the chrome image; blit on every redraw.
// The pressed (lit) sprite is used while the cursor is over the arrow.
- const UICL::ThreeRectWidget &up = scrollUpButton();
- const UICL::ThreeRectWidget &down = scrollDownButton();
-
- const Common::Point chunkOrigin(_screenPosition.left, _screenPosition.top);
-
- if (!up.destRect.isEmpty()) {
- const Common::Rect &upSrc = (_scrollUpHovered && !up.srcRectPressed.isEmpty())
- ? up.srcRectPressed
- : up.srcRectIdle;
- if (!upSrc.isEmpty()) {
- _drawSurface.blitFrom(_spritesImage, upSrc,
- Common::Point(up.destRect.left - chunkOrigin.x,
- up.destRect.top - chunkOrigin.y));
- }
+ if (arrow.destRect.isEmpty()) {
+ return;
}
- if (!down.destRect.isEmpty()) {
- const Common::Rect &downSrc = (_scrollDownHovered && !down.srcRectPressed.isEmpty())
- ? down.srcRectPressed
- : down.srcRectIdle;
- if (!downSrc.isEmpty()) {
- _drawSurface.blitFrom(_spritesImage, downSrc,
- Common::Point(down.destRect.left - chunkOrigin.x,
- down.destRect.top - chunkOrigin.y));
- }
+ const Common::Rect &src = (hovered && !arrow.srcRectPressed.isEmpty())
+ ? arrow.srcRectPressed
+ : arrow.srcRectIdle;
+ if (src.isEmpty()) {
+ return;
}
+ _drawSurface.blitFrom(_spritesImage, src,
+ Common::Point(arrow.destRect.left - _screenPosition.left,
+ arrow.destRect.top - _screenPosition.top));
+}
+
+void CellPhonePopup::drawDirectoryArrows() {
+ drawScrollArrow(scrollUpButton(), _scrollUpHovered);
+ drawScrollArrow(scrollDownButton(), _scrollDownHovered);
// Selection indicator (dirArrowSrc sprite) â only the contacts directory
// shows it. The search-topic and email lists are plain lists in the
@@ -1607,15 +1947,18 @@ bool CellPhonePopup::isContactVisible(const UICL::Contact &c) const {
}
Common::Rect CellPhonePopup::hubEmailRect() const {
- // subButtons[3] is the upper LCD option button (Email).
- Common::Rect r = _uiclData->subButtons[3].destRect;
+ // The Email option button: subButtons[3] before Nancy 13, [4] in Nancy 13.
+ const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubEmail : kSubEmail;
+ Common::Rect r = _uiclData->subButtons[slot].destRect;
r.translate(-_screenPosition.left, -_screenPosition.top);
return r;
}
Common::Rect CellPhonePopup::hubWebRect() const {
- // subButtons[4] is the lower LCD option button (Web).
- Common::Rect r = _uiclData->subButtons[4].destRect;
+ // The lower LCD option button: Web (subButtons[4]) before Nancy 13, View
+ // Pictures (subButtons[3]) in Nancy 13.
+ const uint slot = g_nancy->getGameType() >= kGameTypeNancy13 ? kN13SubViewPics : kSubWeb;
+ Common::Rect r = _uiclData->subButtons[slot].destRect;
r.translate(-_screenPosition.left, -_screenPosition.top);
return r;
}
@@ -1796,6 +2139,33 @@ void CellPhonePopup::handleInput(NancyInput &input) {
return;
}
+ // Nancy 13 camera framing: the movable box tracks the mouse; a left click
+ // takes the shot, a right click cancels back to the welcome screen.
+ if (_inCameraFraming) {
+ if (input.mousePos != _framingMouse) {
+ _framingMouse = input.mousePos;
+ drawScreenContent();
+ }
+ if (input.input & NancyInput::kLeftMouseButtonUp) {
+ const Common::Rect shot = framingScreenRect();
+ exitCameraFraming();
+ CellPhonePictureData *pd = pictureData();
+ if (pd && pd->pictures.size() >= kMaxPictures) {
+ // The camera roll is full â the original refuses the shot and
+ // asks the player to delete one or more pictures.
+ showMessageScreen(kN13MsgCameraFull, kWelcome);
+ } else {
+ captureViewport(shot);
+ enterScreenState(kPictureView);
+ }
+ } else if (input.input & NancyInput::kRightMouseButtonUp) {
+ exitCameraFraming();
+ enterScreenState(kWelcome);
+ }
+ input.eatMouseInput();
+ return;
+ }
+
// Mid-call states accept only the close X.
const bool transientCallState =
_screenState == kPlaceCall || _screenState == kWaitOutgoingRing ||
@@ -1836,7 +2206,7 @@ void CellPhonePopup::handleInput(NancyInput &input) {
if (transientCallState) {
// While ringing, only the Back button is live (cancels the call).
if (isCallBackButtonActive()) {
- const Common::Rect backHit = backButtonHitRect(0);
+ const Common::Rect backHit = backButtonHitRect(kSubBack);
const Common::Point popupMouse(input.mousePos.x - _screenPosition.left,
input.mousePos.y - _screenPosition.top);
const bool overBack = !backHit.isEmpty() && backHit.contains(popupMouse);
@@ -1861,10 +2231,11 @@ void CellPhonePopup::handleInput(NancyInput &input) {
const Common::Point chunkMouse = mouseToChunkCoords(input.mousePos);
// Light the up/down arrows on hover in any state that uses them (directory,
- // link lists, and the content view â help included, which scrolls via the
- // small-LCD arrow pair).
+ // link lists, the content view â help included, which scrolls via the
+ // small-LCD arrow pair â and the Nancy 13 picture-view paging arrows).
const bool arrowsActive = _screenState == kDirectory || isLinkListMode() ||
- _screenState == kContentView;
+ _screenState == kContentView ||
+ _screenState == kPictureView;
const bool overUp = arrowsActive &&
scrollUpButton().destRect.contains(chunkMouse);
const bool overDown = arrowsActive && !overUp &&
@@ -1928,7 +2299,19 @@ void CellPhonePopup::handleInput(NancyInput &input) {
_uiclData->helpButton.destRect.contains(chunkMouse)) {
g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
if (input.input & NancyInput::kLeftMouseButtonUp) {
- openContentView(_uiclData->helpTextKey, _uiclData->helpHeading);
+ // Nancy 13 stores two help keys: the phone-help text lives under
+ // helpTextKey2 ("PHNUSE") in AUTOTEXT, not helpTextKey ("NTR025"), so
+ // use whichever actually resolves.
+ Common::String helpKey = _uiclData->helpTextKey;
+ if (g_nancy->getGameType() >= kGameTypeNancy13 && !_uiclData->helpTextKey2.empty()) {
+ const CVTX *autotext = (const CVTX *)g_nancy->getEngineData("AUTOTEXT");
+ const Common::String help2 = _uiclData->helpTextKey2.toString();
+ if (autotext && !autotext->texts.contains(helpKey) &&
+ autotext->texts.contains(help2)) {
+ helpKey = help2;
+ }
+ }
+ openContentView(helpKey, _uiclData->helpHeading);
input.eatMouseInput();
return;
}
@@ -1957,7 +2340,7 @@ void CellPhonePopup::handleInput(NancyInput &input) {
// Visible Back button at the bottom of the display. Gated so it can't
// intercept up/down clicks.
- const Common::Rect backHit = backButtonHitRect(0);
+ const Common::Rect backHit = backButtonHitRect(kSubBack);
const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
chunkMouse.y - _screenPosition.top);
const bool overUpDown =
@@ -1974,7 +2357,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
}
}
- // Row click selects (the call button does the actual call).
+ // Row click only selects; the first bottom button places the call (or,
+ // while choosing a photo recipient, sends the picture).
const uint row = directoryRowAt(chunkMouse);
if (row != (uint)-1) {
const int contactIdx = contactIndexForVisibleRow(row);
@@ -1998,11 +2382,14 @@ void CellPhonePopup::handleInput(NancyInput &input) {
chunkMouse.y - _screenPosition.top);
const Common::Rect emailR = hubEmailRect();
const Common::Rect webR = hubWebRect();
- const Common::Rect backHit = backButtonHitRect(0);
+ const Common::Rect backHit = backButtonHitRect(kSubBack);
// Highlight whichever option button the cursor is over.
- const int newHubHover = emailR.contains(popupMouse) ? 3
- : webR.contains(popupMouse) ? 4 : -1;
+ const bool n13Hub = g_nancy->getGameType() >= kGameTypeNancy13;
+ const int emailSlot = n13Hub ? kN13SubEmail : kSubEmail;
+ const int webSlot = n13Hub ? kN13SubViewPics : kSubWeb;
+ const int newHubHover = emailR.contains(popupMouse) ? emailSlot
+ : webR.contains(popupMouse) ? webSlot : -1;
if (newHubHover != _hoveredHubButton) {
_hoveredHubButton = newHubHover;
drawScreenContent();
@@ -2022,7 +2409,14 @@ void CellPhonePopup::handleInput(NancyInput &input) {
if (input.input & NancyInput::kLeftMouseButtonUp) {
_directoryScroll = 0;
_directorySelection = 0;
- openBrowserHome();
+ if (g_nancy->getGameType() >= kGameTypeNancy13) {
+ // Nancy 13's Menu second option is "view pictures" (the web
+ // browser was removed).
+ _pictureIndex = 0;
+ enterScreenState(kPictureView);
+ } else {
+ openBrowserHome();
+ }
input.eatMouseInput();
return;
}
@@ -2036,6 +2430,122 @@ void CellPhonePopup::handleInput(NancyInput &input) {
}
}
+ // Nancy 13 "view pictures" review screen. The top-row buttons are relabelled
+ // Cam (retake) / Del / Send; the directory scroll arrows page between photos.
+ if (_screenState == kPictureView) {
+ const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
+ chunkMouse.y - _screenPosition.top);
+ CellPhonePictureData *pd = pictureData();
+ const int numPics = pd ? (int)pd->pictures.size() : 0;
+
+ Common::Rect camR = _uiclData->dialPadSlots[12].destRect;
+ Common::Rect delR = _uiclData->dialPadSlots[13].destRect;
+ Common::Rect sendR = _uiclData->dialPadSlots[14].destRect;
+ Common::Rect upR = _uiclData->subButtons[kN13SubListUp].destRect;
+ Common::Rect downR = _uiclData->subButtons[kN13SubListDown].destRect;
+ const Common::Point origin(_screenPosition.left, _screenPosition.top);
+ camR.translate(-origin.x, -origin.y);
+ delR.translate(-origin.x, -origin.y);
+ sendR.translate(-origin.x, -origin.y);
+ upR.translate(-origin.x, -origin.y);
+ downR.translate(-origin.x, -origin.y);
+ const Common::Rect backHit = backButtonHitRect(kSubBack);
+
+ const bool overButton = camR.contains(popupMouse) ||
+ (numPics > 0 && (delR.contains(popupMouse) || sendR.contains(popupMouse))) ||
+ (numPics > 1 && (upR.contains(popupMouse) || downR.contains(popupMouse))) ||
+ (!backHit.isEmpty() && backHit.contains(popupMouse));
+ if (overButton) {
+ g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+ }
+ if (overButton != _backButtonHovered) {
+ _backButtonHovered = overButton;
+ drawScreenContent();
+ }
+
+ if (input.input & NancyInput::kLeftMouseButtonUp) {
+ if (camR.contains(popupMouse)) {
+ // Retake â back into the framing overlay.
+ _screenState = kCamera;
+ enterCameraFraming();
+ drawScreenContent();
+ } else if (numPics > 0 && delR.contains(popupMouse)) {
+ enterScreenState(kDeleteConfirm);
+ } else if (numPics > 0 && sendR.contains(popupMouse)) {
+ // Pick a recipient in the directory, then confirm "sent".
+ _sendingPicture = true;
+ _directoryScroll = 0;
+ _directorySelection = 0;
+ enterScreenState(kDirectory);
+ } else if (numPics > 1 && upR.contains(popupMouse)) {
+ _pictureIndex = (_pictureIndex + numPics - 1) % numPics;
+ drawScreenContent();
+ } else if (numPics > 1 && downR.contains(popupMouse)) {
+ _pictureIndex = (_pictureIndex + 1) % numPics;
+ drawScreenContent();
+ } else if (!backHit.isEmpty() && backHit.contains(popupMouse)) {
+ enterScreenState(kOnlineHub);
+ }
+ input.eatMouseInput();
+ return;
+ }
+ input.eatMouseInput();
+ return;
+ }
+
+ // Nancy 13 delete-confirm ("DELETE? YES OR NO"): Yes and No are the Menu
+ // (slot 13) and Dir (slot 14) dial-pad keys relabelled (like slot 12's
+ // Cam/Dial/Send), so those slots are the hitboxes. Yes deletes the current
+ // photo; No returns to the picture view.
+ if (_screenState == kDeleteConfirm) {
+ const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
+ chunkMouse.y - _screenPosition.top);
+ Common::Rect yesR = _uiclData->dialPadSlots[13].destRect;
+ Common::Rect noR = _uiclData->dialPadSlots[14].destRect;
+ yesR.translate(-_screenPosition.left, -_screenPosition.top);
+ noR.translate(-_screenPosition.left, -_screenPosition.top);
+ if (yesR.contains(popupMouse) || noR.contains(popupMouse)) {
+ g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+ }
+ if (input.input & NancyInput::kLeftMouseButtonUp) {
+ CellPhonePictureData *pd = pictureData();
+ if (yesR.contains(popupMouse) && pd && _pictureIndex >= 0 &&
+ _pictureIndex < (int)pd->pictures.size()) {
+ pd->pictures.remove_at(_pictureIndex);
+ if (_pictureIndex >= (int)pd->pictures.size()) {
+ _pictureIndex = MAX(0, (int)pd->pictures.size() - 1);
+ }
+ showMessageScreen(kN13MsgPictureDeleted, kPictureView);
+ input.eatMouseInput();
+ return;
+ }
+ if (noR.contains(popupMouse)) {
+ enterScreenState(kPictureView);
+ input.eatMouseInput();
+ return;
+ }
+ }
+ input.eatMouseInput();
+ return;
+ }
+
+ // Nancy 13 transient message tile (Sent / Deleted / Camera Full): any click
+ // (or the Back button) dismisses it back to the state it was raised from.
+ if (_screenState == kMessageScreen) {
+ g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+ if (input.input & NancyInput::kLeftMouseButtonUp) {
+ ScreenState ret = _messageReturnState;
+ // If the roll is now empty (last photo deleted), fall back to Menu.
+ CellPhonePictureData *pd = pictureData();
+ if (ret == kPictureView && (!pd || pd->pictures.empty())) {
+ ret = kOnlineHub;
+ }
+ enterScreenState(ret);
+ }
+ input.eatMouseInput();
+ return;
+ }
+
// Link-list modes (web search results / email messages). Up/down +
// Back behave like in directory mode; row clicks navigate to the
// link's scene and set its event flag.
@@ -2061,8 +2571,12 @@ void CellPhonePopup::handleInput(NancyInput &input) {
// The list views show a bottom button at the same spot: the email list
// a BACK (subButtons[7]) to the hub, the search list a HOME
- // (subButtons[9]) to the browser homepage. Same dest rect either way.
- const Common::Rect backHit = backButtonHitRect(_screenState == kWebList ? 9 : 7);
+ // (subButtons[9]) to the browser homepage. Nancy 13 moved the zoomed-list
+ // Back to subButtons[8] (and dropped the web list). Same dest rect either.
+ const uint listBackIdx = g_nancy->getGameType() >= kGameTypeNancy13
+ ? (uint)kN13SubBackFull
+ : (uint)(_screenState == kWebList ? kSubWebHome : kSubEmailBack);
+ const Common::Rect backHit = backButtonHitRect(listBackIdx);
const Common::Point popupMouse(chunkMouse.x - _screenPosition.left,
chunkMouse.y - _screenPosition.top);
const bool overUpDown =
@@ -2155,10 +2669,10 @@ void CellPhonePopup::handleInput(NancyInput &input) {
// The main browser page carries the top-row SEARCH button (subButtons[8])
// which opens the search list; it highlights green while hovered.
if (_contentHeading == &_uiclData->browserHeading && !isBrowserArticle() &&
- !_uiclData->subButtons[8].destRect.isEmpty()) {
- Common::Rect searchBtn = _uiclData->subButtons[8].destRect;
+ !_uiclData->subButtons[kSubSearch].destRect.isEmpty()) {
+ Common::Rect searchBtn = _uiclData->subButtons[kSubSearch].destRect;
searchBtn.translate(-_screenPosition.left, -_screenPosition.top);
- const int newHover = searchBtn.contains(popupMouseLink) ? 8 : -1;
+ const int newHover = searchBtn.contains(popupMouseLink) ? kSubSearch : -1;
if (newHover != _hoveredHubButton) {
_hoveredHubButton = newHover;
drawScreenContent();
@@ -2252,6 +2766,31 @@ void CellPhonePopup::handleInput(NancyInput &input) {
if (input.input & NancyInput::kLeftMouseButtonUp) {
playDialPadSound(_uiclData->dialPadSlots[12].soundName);
+ if (g_nancy->getGameType() >= kGameTypeNancy13 &&
+ (_screenState == kWelcome || _screenState == kDialing)) {
+ // Nancy 13's slot 12 is dual-purpose (Ghidra widget 0xc): on the
+ // welcome / dialing screen it is the camera button â enter the
+ // framing overlay (the phone yields to the viewport and a movable
+ // box marks the shot). In the directory it dials instead, so fall
+ // through to the call logic below.
+ _screenState = kCamera;
+ enterCameraFraming();
+ drawScreenContent();
+ input.eatMouseInput();
+ return;
+ }
+ if (_sendingPicture && _screenState == kDirectory) {
+ // Choosing a photo recipient: the first button is "Send" â mark
+ // the picture sent and show the confirmation.
+ _sendingPicture = false;
+ CellPhonePictureData *pd = pictureData();
+ if (pd && _pictureIndex >= 0 && _pictureIndex < (int)pd->pictures.size()) {
+ pd->pictures[_pictureIndex].sent = true;
+ }
+ showMessageScreen(kN13MsgPictureSent, kPictureView);
+ input.eatMouseInput();
+ return;
+ }
if (!_noSignal) {
if (_screenState == kDirectory) {
const int contactIdx =
@@ -2276,8 +2815,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
// Dial-pad slot behaviour:
// 0..9 - digit input
// 10, 11 - *, # (no-op)
- // 12 - call/talk key (handled above)
- // 13 - web mode (TODO)
+ // 12 - call/talk key, or Nancy 13 camera/dial/send (handled above)
+ // 13 - Menu: opens the online hub (e-mail + web / view pictures)
// 14 - directory toggle
int newHovered = -1;
if (keypadVisible) {
@@ -2322,9 +2861,8 @@ void CellPhonePopup::handleInput(NancyInput &input) {
}
}
- // TODO: sub-buttons (email/search/help/browser/in-call menu) not hooked up.
-
- // Block the viewport from acting on the cursor while the phone is up.
+ // Nothing else claimed the click: block the viewport from acting on the
+ // cursor while the phone is up.
input.eatMouseInput();
}
diff --git a/engines/nancy/ui/cellphonepopup.h b/engines/nancy/ui/cellphonepopup.h
index f7f9f482cd5..c3395fc6130 100644
--- a/engines/nancy/ui/cellphonepopup.h
+++ b/engines/nancy/ui/cellphonepopup.h
@@ -32,8 +32,9 @@ struct NancyInput;
namespace UI {
-// Nancy 10+ cell phone popup, driven by the UICL chunk.
-// TODO: email, search, help, browser modes; in-call menu; redial.
+// Nancy 10+ cell phone popup, driven by the UICL chunk. Handles dialling and
+// calls, the contacts directory, the online hub (e-mail / web search / browser,
+// or the Nancy 13 camera), the help page, and the Nancy 13 photo camera.
class CellPhonePopup : public RenderObject {
public:
CellPhonePopup();
@@ -101,7 +102,14 @@ private:
kOnlineHub = 10, // Online heading + Email / Web sub-buttons
kWebList = 11, // web search-results list (AR-131 mode 1)
kEmailList = 12, // email message list (AR-131 mode 0)
- kContentView = 13 // full-text view of a single email / page
+ kContentView = 13, // full-text view of a single email / page
+
+ // Nancy 13 camera feature (the web browser was removed; Menu offers
+ // "view pictures" instead).
+ kCamera = 14, // framing the live viewport before a snapshot
+ kPictureView = 15, // reviewing a captured photo (Cam/Del/Send)
+ kDeleteConfirm = 16, // "DELETE? YES OR NO" over a photo
+ kMessageScreen = 17 // a transient message tile (SENT / DELETED / FULL)
};
void drawChrome();
@@ -109,6 +117,10 @@ private:
void drawStatusIcons(bool includeSignal = true);
void drawWebDirLabels();
void drawDialLabel();
+ // Blit one ribbon label sprite (Cam / Menu / Dir / Del / Send / Yes / No)
+ // from the sprite atlas at its chunk dest.
+ void drawRibbonLabel(const UICL::SrcDestRectPair &label);
+ void drawRibbonLabelAt(const Common::Rect &src, const Common::Rect &dest);
void drawTypeMessage();
void drawConnectedLabel();
void drawConnectingSprite();
@@ -118,7 +130,35 @@ private:
void drawStatusLabels();
void drawDirectoryList();
void drawDirectoryArrows();
+ // Blit one scroll/paging arrow (idle, or its pressed sprite when hovered).
+ void drawScrollArrow(const UICL::ThreeRectWidget &arrow, bool hovered);
void drawWelcomeScreen();
+ // Nancy 13: blit one UI_Cell_Xtra atlas tile into the LCD area (the plain
+ // keyboard background or one of the message tiles). These are fixed atlas
+ // positions the original bakes into a pre-rendered per-state LCD surface.
+ void drawLcdTile(const Common::Rect &src);
+ // Show a transient message tile (Picture Sent / Deleted / Camera Full),
+ // dismissed by any click back to returnState.
+ void showMessageScreen(const Common::Rect &tileSrc, ScreenState returnState);
+ // Nancy 13: draw the current captured photo (scaled into the LCD) for the
+ // "view pictures" screen, or the "no pictures" tile when there are none.
+ void drawPictureView();
+ // The captured pictures store, lazily created (Nancy 13 only). May be null.
+ struct CellPhonePictureData *pictureData() const;
+ // Nancy 13 camera: grab the given screen-space region of the live viewport
+ // into a new persisted CapturedPicture and select it. Empty rect = the whole
+ // viewport.
+ void captureViewport(const Common::Rect &screenRegion = Common::Rect());
+
+ // Nancy 13 camera framing: while kCamera is active the popup covers the
+ // viewport and draws a movable rectangle marking the shot. Entering saves the
+ // phone's rect and grows the draw surface; exiting restores it.
+ void enterCameraFraming();
+ void exitCameraFraming();
+ void drawCameraFraming();
+ // Screen-space rect of the framing box, centred on the mouse and clamped to
+ // the viewport.
+ Common::Rect framingScreenRect() const;
// Blit a sub-button's idle sprite at its chunk dest (used for the visible
// Back buttons: subButtons[0] on the help / directory / online screens,
// subButtons[7] in the zoomed email / browser content view).
@@ -295,6 +335,27 @@ private:
uint _directoryScroll = 0;
uint _directorySelection = 0;
+ // Nancy 13 "view pictures": index of the currently displayed captured photo.
+ int _pictureIndex = 0;
+
+ // Nancy 13 camera framing state.
+ bool _inCameraFraming = false;
+ Common::Rect _savedPhoneRect; // phone rect to restore when framing ends
+ Common::Point _framingMouse; // last mouse pos (screen coords)
+ static const int kFramingWidth = 220;
+ static const int kFramingHeight = 176;
+
+ // The original caps the persisted camera roll at 50 pictures.
+ static const uint kMaxPictures = 50;
+
+ // True while the directory is open to pick a recipient for the current photo
+ // (reached from the picture-review "Send" button).
+ bool _sendingPicture = false;
+
+ // Active tile + return target for the transient kMessageScreen state.
+ const Common::Rect *_messageTileSrc = nullptr;
+ ScreenState _messageReturnState = kWelcome;
+
// Content-view (single email / page) state.
ScreenState _contentReturnState = kOnlineHub;
const UICL::SrcDestRectPair *_contentHeading = nullptr;
Commit: 09e072597305e6dd5006c85381033d4dcc1dcd8e
https://github.com/scummvm/scummvm/commit/09e072597305e6dd5006c85381033d4dcc1dcd8e
Author: Filippos Karapetis (bluegr at gmail.com)
Date: 2026-07-23T03:55:03+03:00
Commit Message:
NANCY: NANCY13: More work on PachinkoPuzzle
- Implement panel bounds
- Fix ball bouncing on pins
- Fix drawing of climbers (yeti and gold digger)
- Fix ball entry position
- Implement the functionality of the board holes
- Implement win/lose conditions
Changed paths:
engines/nancy/action/puzzle/pachinkopuzzle.cpp
engines/nancy/action/puzzle/pachinkopuzzle.h
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.cpp b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
index 40f3392cbd0..09e0ffb1042 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.cpp
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.cpp
@@ -48,6 +48,20 @@ static const double kSpawnYJitter = 0.01;
// a small constant drag is used here as an approximation.
static const double kDrag = 12.0; // px/s per second
+// Collision geometry. Pins are stored as 1x1 points on a staggered ~30px grid; on screen
+// they are small round bumpers, so the hit distance matches the drawn pin + ball radii (a
+// ball may still thread the wider gaps, which is fine - the stagger catches it a row later).
+static const double kPinRadius = 4.0;
+static const double kBallRadius = 5.0;
+static const int kPhysicsSubsteps = 4; // per frame, to avoid tunnelling the pins
+
+static const uint32 kHoleLitMs = 400; // how long a hole stays lit after a catch
+
+// A climber reaches the pot once its accumulated steps hit this goal. moverSpeed per catch
+// is 2 (Miner) / 3 (Yeti), so the Yeti climbs faster - matching the game's "the Yeti
+// usually wins". The exact goal is not recovered from the chunk; this is a tuned value.
+static const int kClimbGoal = 30;
+
static double wrapAngle(double a) {
while (a < 0.0) {
a += kTwoPi;
@@ -63,7 +77,7 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
// 167-byte header blob.
readRect(stream, _ballSrc); // ball sprite source
- stream.skip(16); // four unidentified int32
+ readRect(stream, _ballEntry); // top-right entry chute
_velMin = stream.readSint32LE(); // launch-speed floor
_velMax = stream.readSint32LE(); // launch-speed ceiling
_spawnYMin = stream.readSint32LE(); // launch-heading (deg) range
@@ -71,7 +85,7 @@ void PachinkoPuzzle::readData(Common::SeekableReadStream &stream) {
stream.skip(4);
_launchVecLen = stream.readSint32LE();
stream.skip(4);
- stream.skip(16); // four unidentified int32
+ readRect(stream, _panelBounds); // pin-panel bounds (walls + floor)
_eventFlag = stream.readSint16LE(); // "in progress" flag
readFilename(stream, _ballImageName); // ball sprite sheet
readRect(stream, _launcherBallSrc);
@@ -143,15 +157,13 @@ void PachinkoPuzzle::readMachine(Common::SeekableReadStream &stream, Machine &m)
m.winchSound.readData(stream); // snd1 - the winch-up cue
- // The 55-byte blob: its leading filename is the result movie (MUS_PachinkoWinANIM for
- // the Miner); an empty filename means no movie.
- byte blob[55];
- stream.read(blob, sizeof(blob));
- int len = 0;
- while (len < 33 && blob[len] != 0) {
- ++len;
- }
- m.movieName = Common::String((const char *)blob, len);
+ stream.skip(1); // per-machine flag byte
+
+ // The 55-byte blob: a filename[33] (the result movie - MUS_PachinkoWinANIM /
+ // MUS_PachinkoLoseANIM), then its 16-byte destination rect, then int32 + int16.
+ readFilename(stream, m.movieName);
+ readRect(stream, m.movieDest);
+ stream.skip(6);
m.resultSound.readData(stream); // snd2 - the win/lose voice cue
@@ -168,6 +180,74 @@ void PachinkoPuzzle::loadMachineImage(Machine &m) {
}
}
+// The four holes are the bumper zones (type 0x16) that carry a bell sound. The sound name
+// maps each hole to its climber: Miner/Explosion feed the Gold Digger (win), Yeti/Ouch feed
+// the Yeti (lose).
+void PachinkoPuzzle::buildHoles() {
+ _holes.clear();
+ for (const ActionZone &z : _zones) {
+ if (z.type != 0x16 || z._sound.names.empty()) {
+ continue;
+ }
+
+ Machine *climber = nullptr;
+ for (const Common::String &n : z._sound.names) {
+ if (n.contains("Miner") || n.contains("Explosion")) {
+ climber = &_winMachine;
+ break;
+ }
+ if (n.contains("Yeti") || n.contains("Ouch")) {
+ climber = &_loseMachine;
+ break;
+ }
+ }
+ if (!climber) {
+ continue;
+ }
+
+ Hole hole;
+ hole.rect = z.rect;
+ hole.climber = climber;
+ hole.sound = z._sound;
+ _holes.push_back(hole);
+ }
+
+ // Attach each hole's "lit" sprite from the matching overlay (0x0d) zone, which shares
+ // the hole's rect and names the lit board overlay.
+ for (const ActionZone &z : _zones) {
+ if (z.type != 0x0d || z.overlaySrcRects.empty()) {
+ continue;
+ }
+ for (Hole &hole : _holes) {
+ if (hole.rect == z.rect) {
+ hole.litSrc = z.overlaySrcRects[0];
+ hole.litDest = z.overlayDestRect;
+ if (_litImageName.empty()) {
+ _litImageName = Common::Path(z.overlayName.c_str());
+ }
+ break;
+ }
+ }
+ }
+
+ if (!_litImageName.empty()) {
+ g_nancy->_resource->loadImage(_litImageName, _litImage);
+ _litImage.setTransparentColor(_drawSurface.getTransparentColor());
+ }
+}
+
+// The climber's current on-screen anchor: the point on its climb path from the bottom
+// (moverStart) to the pot (moverEnd), advanced by how many balls it has caught.
+Common::Point PachinkoPuzzle::climberAnchor(const Machine &m) const {
+ double t = (double)m.climbSteps / (double)kClimbGoal;
+ if (t > 1.0) {
+ t = 1.0;
+ }
+ Common::Point start((m.moverStart.left + m.moverStart.right) / 2, (m.moverStart.top + m.moverStart.bottom) / 2);
+ Common::Point end((m.moverEnd.left + m.moverEnd.right) / 2, (m.moverEnd.top + m.moverEnd.bottom) / 2);
+ return Common::Point((int16)(start.x + (end.x - start.x) * t), (int16)(start.y + (end.y - start.y) * t));
+}
+
void PachinkoPuzzle::init() {
Common::Rect vpBounds = NancySceneState.getViewport().getBounds();
_drawSurface.create(vpBounds.width(), vpBounds.height(), g_nancy->_graphics->getInputPixelFormat());
@@ -186,6 +266,8 @@ void PachinkoPuzzle::init() {
loadMachineImage(_winMachine);
loadMachineImage(_loseMachine);
+ buildHoles();
+
NancySceneState.setNoHeldItem();
// In-progress marker: set on entry, cleared once a ball is caught.
@@ -249,63 +331,57 @@ void PachinkoPuzzle::spawnBall() {
Ball ball;
ball.speed = (double)(_velMax - _velMin) * launchParam + (double)_velMin;
ball.angle = wrapAngle(headingDeg * kDeg2Rad);
- ball.x = (_launcherHotspot.left + _launcherHotspot.right) / 2.0;
- ball.y = (_launcherBallDest.top + _launcherBallDest.bottom) / 2.0;
+ // Balls appear at the top-right entry chute and fall left/down through the pins.
+ ball.x = (_ballEntry.left + _ballEntry.right) / 2.0;
+ ball.y = (_ballEntry.top + _ballEntry.bottom) / 2.0;
ball.active = true;
_balls.push_back(ball);
playSoundBlock(_plinkSounds);
}
-bool PachinkoPuzzle::collideBall(Ball &ball, double nx, double ny) const {
- // Reflect off any pin or bumper rect the ball would enter. The axis of the smaller
- // penetration decides which velocity component flips (an AABB response).
- Common::Point p((int16)nx, (int16)ny);
-
- const Common::Rect *hit = nullptr;
- for (const Common::Rect &r : _pins) {
- if (r.contains(p)) {
- hit = &r;
- break;
- }
- }
- if (!hit) {
- for (const ActionZone &z : _zones) {
- // Walls (type 1/0x14) and bumpers (0x16) are solid; overlays are not.
- if ((z.type == 0x01 || z.type == 0x14 || z.type == 0x16) && z.rect.contains(p)) {
- hit = &z.rect;
- if (!z._sound.names.empty()) {
- const_cast<PachinkoPuzzle *>(this)->playSoundBlock(z._sound);
- }
- break;
- }
+// Reflect the ball off any pin it now overlaps (pins collide as circles). Returns true if a
+// bounce happened.
+bool PachinkoPuzzle::collidePins(Ball &ball) const {
+ const double hitDist = kPinRadius + kBallRadius;
+ for (const Common::Rect &pin : _pins) {
+ double px = (pin.left + pin.right) / 2.0;
+ double py = (pin.top + pin.bottom) / 2.0;
+ double dx = ball.x - px;
+ double dy = ball.y - py;
+ double d2 = dx * dx + dy * dy;
+ if (d2 >= hitDist * hitDist || d2 < 1e-6) {
+ continue;
}
- }
- if (!hit) {
- return false;
+ // Reflect the velocity about the surface normal (pin centre -> ball) and push the
+ // ball back out to the contact distance.
+ double d = sqrt(d2);
+ double nxn = dx / d;
+ double nyn = dy / d;
+ double vx = cos(ball.angle) * ball.speed;
+ double vy = -sin(ball.angle) * ball.speed;
+ double dot = vx * nxn + vy * nyn;
+ vx -= 2.0 * dot * nxn;
+ vy -= 2.0 * dot * nyn;
+
+ ball.x = px + nxn * hitDist;
+ ball.y = py + nyn * hitDist;
+ ball.speed = sqrt(vx * vx + vy * vy) * kRestitution;
+ ball.angle = wrapAngle(atan2(-vy, vx));
+ return true;
}
+ return false;
+}
- double vx = cos(ball.angle) * ball.speed;
- double vy = -sin(ball.angle) * ball.speed;
-
- // Penetration depth on each axis; flip the axis with the shallower overlap.
- double penLeft = nx - hit->left;
- double penRight = hit->right - nx;
- double penTop = ny - hit->top;
- double penBottom = hit->bottom - ny;
- double penX = MIN(penLeft, penRight);
- double penY = MIN(penTop, penBottom);
-
- if (penX < penY) {
- vx = -vx;
- } else {
- vy = -vy;
+int PachinkoPuzzle::catchInHole(const Ball &ball) const {
+ Common::Point p((int16)ball.x, (int16)ball.y);
+ for (uint i = 0; i < _holes.size(); ++i) {
+ if (_holes[i].rect.contains(p)) {
+ return (int)i;
+ }
}
-
- ball.speed = sqrt(vx * vx + vy * vy) * kRestitution;
- ball.angle = wrapAngle(atan2(-vy, vx));
- return true;
+ return -1;
}
void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
@@ -322,23 +398,36 @@ void PachinkoPuzzle::stepBall(Ball &ball, double dt) {
ball.speed = 0.0;
}
- // Move, reflecting on a collision (single sub-step is sufficient at this frame rate).
- double dist = ball.speed * dt;
- double nx = ball.x + cos(ball.angle) * dist;
- double ny = ball.y - sin(ball.angle) * dist;
- if (!collideBall(ball, nx, ny)) {
- ball.x = nx;
- ball.y = ny;
- }
-}
+ // Advance in small sub-steps so the ball cannot tunnel between the pins, bouncing off a
+ // pin or a side wall on the way.
+ double subDt = dt / kPhysicsSubsteps;
+ for (int s = 0; s < kPhysicsSubsteps; ++s) {
+ ball.x += cos(ball.angle) * ball.speed * subDt;
+ ball.y -= sin(ball.angle) * ball.speed * subDt;
-bool PachinkoPuzzle::ballSettled(const Ball &ball, const Machine &m) const {
- Common::Point c = m.catchPoint();
- double dx = ball.x - c.x;
- double dy = ball.y - c.y;
- // A generous catch radius (the machine's mover end rect half-size).
- int r = MAX(m.moverEnd.width(), m.moverEnd.height()) / 2 + 8;
- return (dx * dx + dy * dy) <= (double)(r * r);
+ if (collidePins(ball)) {
+ continue;
+ }
+
+ // Side walls of the panel keep the ball in play; the ball enters from the right of
+ // the right wall, so that wall only reflects once the ball is inside.
+ double vx2 = cos(ball.angle) * ball.speed;
+ double vy2 = -sin(ball.angle) * ball.speed;
+ bool reflected = false;
+ if (ball.x < _panelBounds.left + kBallRadius && vx2 < 0.0) {
+ ball.x = _panelBounds.left + kBallRadius;
+ vx2 = -vx2;
+ reflected = true;
+ } else if (ball.x < _panelBounds.right && ball.x > _panelBounds.right - kBallRadius && vx2 > 0.0) {
+ ball.x = _panelBounds.right - kBallRadius;
+ vx2 = -vx2;
+ reflected = true;
+ }
+ if (reflected) {
+ ball.speed = sqrt(vx2 * vx2 + vy2 * vy2) * kRestitution;
+ ball.angle = wrapAngle(atan2(-vy2, vx2));
+ }
+ }
}
void PachinkoPuzzle::advanceMachine(Machine &m, uint32 now) {
@@ -355,7 +444,7 @@ void PachinkoPuzzle::advanceMachine(Machine &m, uint32 now) {
void PachinkoPuzzle::redraw() {
_drawSurface.clear(g_nancy->_graphics->getTransColor());
- // The two machines (their current animation frame), drawn at their mover start.
+ // The two climbers, drawn bottom-centred on their current position along the climb path.
const Machine *machines[2] = { &_winMachine, &_loseMachine };
for (int i = 0; i < 2; ++i) {
const Machine &m = *machines[i];
@@ -363,7 +452,8 @@ void PachinkoPuzzle::redraw() {
continue;
}
const Common::Rect &src = m.frames[m.frame % m.frames.size()];
- Common::Point pos(m.moverStart.left, m.moverStart.top);
+ Common::Point anchor = climberAnchor(m);
+ Common::Point pos(anchor.x - src.width() / 2, anchor.y - src.height());
_drawSurface.blitFrom(m.image, src, pos);
}
@@ -380,9 +470,19 @@ void PachinkoPuzzle::redraw() {
}
}
- // The result animation, when playing.
- if (_pzState == kWaitResult && _resultMovie.isVideoLoaded()) {
- _resultMovie.drawFrame(_drawSurface, Common::Point(0, 0));
+ // Holes lit by a recent catch.
+ if (!_litImage.empty()) {
+ uint32 now = g_nancy->getTotalPlayTime();
+ for (const Hole &hole : _holes) {
+ if (hole.litUntil > now && !hole.litSrc.isEmpty()) {
+ _drawSurface.blitFrom(_litImage, hole.litSrc, Common::Point(hole.litDest.left, hole.litDest.top));
+ }
+ }
+ }
+
+ // The result animation, when playing, drawn at the active machine's movie dest.
+ if (_pzState == kWaitResult && _resultMovie.isVideoLoaded() && _activeMachine) {
+ _resultMovie.drawFrame(_drawSurface, Common::Point(_activeMachine->movieDest.left, _activeMachine->movieDest.top));
}
_needsRedraw = true;
@@ -423,28 +523,35 @@ void PachinkoPuzzle::execute() {
}
stepBall(ball, dt);
- // A caught ball decides the outcome and starts that machine's result.
- if (ballSettled(ball, _winMachine)) {
- ball.active = false;
- _solved = true;
- _activeMachine = &_winMachine;
- _pzState = kPlayResult;
- break;
- }
- if (ballSettled(ball, _loseMachine)) {
+ // A ball that drops in a hole is removed and climbs its machine a step.
+ int hole = catchInHole(ball);
+ if (hole >= 0) {
ball.active = false;
- _solved = false;
- _activeMachine = &_loseMachine;
- _pzState = kPlayResult;
- break;
+ playSoundBlock(_holes[hole].sound);
+ _holes[hole].litUntil = now + kHoleLitMs;
+ Machine *climber = _holes[hole].climber;
+ climber->climbSteps += climber->moverSpeed;
+ continue;
}
- // A ball that leaves the board is discarded.
- if (ball.y > _drawSurface.h + 64 || ball.x < -64 || ball.x > _drawSurface.w + 64) {
+ // A ball that reaches the floor or leaves the panel is discarded (a miss).
+ if (ball.y > _panelBounds.bottom || ball.x < _panelBounds.left - 32 ||
+ ball.x > _panelBounds.right + 64 || ball.y > _drawSurface.h + 32) {
ball.active = false;
}
}
+ // The first climber to reach the pot ends the game (Gold Digger = win, Yeti = lose).
+ if (_winMachine.climbSteps >= kClimbGoal) {
+ _solved = true;
+ _activeMachine = &_winMachine;
+ _pzState = kPlayResult;
+ } else if (_loseMachine.climbSteps >= kClimbGoal) {
+ _solved = false;
+ _activeMachine = &_loseMachine;
+ _pzState = kPlayResult;
+ }
+
redraw();
break;
}
diff --git a/engines/nancy/action/puzzle/pachinkopuzzle.h b/engines/nancy/action/puzzle/pachinkopuzzle.h
index 77f6afc876c..6b19c89e6e9 100644
--- a/engines/nancy/action/puzzle/pachinkopuzzle.h
+++ b/engines/nancy/action/puzzle/pachinkopuzzle.h
@@ -64,28 +64,26 @@ public:
protected:
Common::String getRecordTypeName() const override { return "PachinkoPuzzle"; }
- // One of the two "machine" sub-objects (Miner = win, Yeti = lose). Each is an
- // animated sprite whose frames slide from a start point to an end (catch) point.
+ // One of the two mountain climbers (Miner/Gold Digger = win, Yeti = lose). Each is an
+ // animated sprite that climbs from its start anchor (bottom of the mountain) up to its
+ // end anchor (the pot at the top) as balls fall into its holes.
struct Machine {
Common::Path imageName; // the ANIM_OVL sprite strip
int32 animRate = 0; // frames per second
Common::Array<Common::Rect> frames; // sprite-strip source rects
- Common::Rect moverStart; // slide path start rect (its centre is the anchor)
- Common::Rect moverEnd; // slide path end / catch rect
- int32 moverSpeed = 0;
+ Common::Rect moverStart; // climb-path bottom anchor (2x2 point rect)
+ Common::Rect moverEnd; // climb-path top anchor (the pot)
+ int32 moverSpeed = 0; // climb steps gained per ball caught
RandomSoundBlock winchSound; // [snd1] the winch-up cue
RandomSoundBlock resultSound; // [snd2] the win/lose voice cue (MinerWin*/PachinkoLose*)
RandomSoundBlock fastSound; // [snd3] the fast-winch cue
Common::Path movieName; // result animation (blob[0], "" == none)
+ Common::Rect movieDest; // where the result animation is drawn
Graphics::ManagedSurface image;
uint frame = 0; // current animation frame
uint32 nextFrameTime = 0;
-
- Common::Point catchPoint() const {
- return Common::Point((moverEnd.left + moverEnd.right) / 2,
- (moverEnd.top + moverEnd.bottom) / 2);
- }
+ int climbSteps = 0; // accumulated climb (moverSpeed per catch)
};
// A single launched ball. Physics run in viewport space; the heading is stored as a
@@ -99,14 +97,27 @@ protected:
bool active = false;
};
+ // One of the four holes on the panel. A ball that drops in advances its climber and
+ // briefly lights the hole (a sprite from the "lit" overlay).
+ struct Hole {
+ Common::Rect rect;
+ Machine *climber = nullptr;
+ RandomSoundBlock sound; // the bell cue (PinballBell_*)
+ Common::Rect litSrc; // lit-hole sprite source (in _litImage)
+ Common::Rect litDest; // where it is drawn
+ uint32 litUntil = 0; // keep it lit until this time
+ };
+
void readMachine(Common::SeekableReadStream &stream, Machine &m);
void loadMachineImage(Machine &m);
+ void buildHoles();
+ Common::Point climberAnchor(const Machine &m) const;
void redraw();
void spawnBall();
void stepBall(Ball &ball, double dt);
- bool collideBall(Ball &ball, double nx, double ny) const;
- bool ballSettled(const Ball &ball, const Machine &m) const;
+ bool collidePins(Ball &ball) const;
+ int catchInHole(const Ball &ball) const; // hole index the ball fell into, or -1
void advanceMachine(Machine &m, uint32 now);
SoundDescription playSoundBlock(const RandomSoundBlock &block);
void setDataCursor(uint16 cursorType) const;
@@ -115,11 +126,13 @@ protected:
Common::Path _imageName; // 0x00 - board overlay (MUS_PachinkoPUZ02_OVL)
Common::Rect _ballSrc; // ball sprite source in the overlay
+ Common::Rect _ballEntry; // top-right entry chute (where balls appear)
int32 _velMin = 0; // launch-speed floor
int32 _velMax = 0; // launch-speed ceiling
int32 _spawnYMin = 0; // launch-heading (deg) range
int32 _spawnYMax = 0;
int32 _launchVecLen = 0; // decorative launch nub length
+ Common::Rect _panelBounds; // pin-panel bounds (side walls + floor)
int16 _eventFlag = 0; // "in progress" flag id
Common::Path _ballImageName; // the ball sprite sheet
Common::Rect _launcherBallSrc; // ball-in-launcher sprite src
@@ -135,6 +148,7 @@ protected:
Common::Array<Common::Rect> _pins; // static pin collision rects
Common::Array<ActionZone> _zones; // bumpers / walls / overlays (Nancy13 layout)
+ Common::Array<Hole> _holes; // the four catch holes (built from _zones)
// The give-up / exit hotspot (the base trailer's 23-byte record).
Common::Rect _exitHotspot;
@@ -161,8 +175,10 @@ protected:
uint32 _resultTime = 0;
SoundDescription _resultSoundDesc;
+ Common::Path _litImageName; // "lit" board overlay (hole highlights)
Graphics::ManagedSurface _image; // board overlay
Graphics::ManagedSurface _ballImage; // ball sprite sheet
+ Graphics::ManagedSurface _litImage; // lit-hole sprites
MoviePlayer _resultMovie;
};
More information about the Scummvm-git-logs
mailing list