[Scummvm-git-logs] scummvm master -> bf6e75451b01c7af55ea7dd0714b8d8bb60858e3

bluegr noreply at scummvm.org
Thu May 21 23:50:44 UTC 2026


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

Summary:
bf6e75451b NANCY: Initial work on the conversation popup for Nancy 10+


Commit: bf6e75451b01c7af55ea7dd0714b8d8bb60858e3
    https://github.com/scummvm/scummvm/commit/bf6e75451b01c7af55ea7dd0714b8d8bb60858e3
Author: Dukiverse (thedukemc1 at gmail.com)
Date: 2026-05-22T02:50:41+03:00

Commit Message:
NANCY: Initial work on the conversation popup for Nancy 10+

Changed paths:
  A engines/nancy/ui/conversationpopup.cpp
  A engines/nancy/ui/conversationpopup.h
    engines/nancy/action/conversation.cpp
    engines/nancy/module.mk
    engines/nancy/state/scene.cpp
    engines/nancy/state/scene.h


diff --git a/engines/nancy/action/conversation.cpp b/engines/nancy/action/conversation.cpp
index d9f5cf1b2ae..7f9c4e4efd5 100644
--- a/engines/nancy/action/conversation.cpp
+++ b/engines/nancy/action/conversation.cpp
@@ -256,13 +256,21 @@ void ConversationSound::execute() {
 	case kRun:
 		if (!_hasDrawnTextbox) {
 			_hasDrawnTextbox = true;
-			auto *textboxData = GetEngineData(TBOX);
-			assert(textboxData);
-			NancySceneState.getTextbox().clear();
-			NancySceneState.getTextbox().setOverrideFont(textboxData->conversationFontID);
+			if (g_nancy->getGameType() >= kGameTypeNancy10) {
+				NancySceneState.getConversationPopup().open();
 
-			if (ConfMan.getBool("subtitles")) {
-				NancySceneState.getTextbox().addTextLine(_text);
+				if (ConfMan.getBool("subtitles")) {
+					NancySceneState.getConversationPopup().addTextLine(_text);
+				}
+			} else {
+				auto *textboxData = GetEngineData(TBOX);
+				assert(textboxData);
+				NancySceneState.getTextbox().clear();
+				NancySceneState.getTextbox().setOverrideFont(textboxData->conversationFontID);
+
+				if (ConfMan.getBool("subtitles")) {
+					NancySceneState.getTextbox().addTextLine(_text);
+				}
 			}
 
 			Common::Array<uint> responsesToAdd;
@@ -317,8 +325,16 @@ void ConversationSound::execute() {
 				responsesToAdd.push_back(i);
 			}
 
+			if (g_nancy->getGameType() >= kGameTypeNancy10) {
+				NancySceneState.getConversationPopup().setResponseStart();
+			}
+
 			for (uint i : responsesToAdd) {
-				NancySceneState.getTextbox().addTextLine(_responses[i].text);
+				if (g_nancy->getGameType() >= kGameTypeNancy10) {
+					NancySceneState.getConversationPopup().addTextLine(_responses[i].text);
+				} else {
+					NancySceneState.getTextbox().addTextLine(_responses[i].text);
+				}
 				_responses[i].isOnScreen = true;
 			}
 		}
@@ -406,6 +422,10 @@ void ConversationSound::execute() {
 				}
 			}
 
+			if (g_nancy->getGameType() >= kGameTypeNancy10) {
+				NancySceneState.getConversationPopup().close();
+			}
+			
 			finishExecution();
 		}
 
diff --git a/engines/nancy/module.mk b/engines/nancy/module.mk
index 35d20548ffe..445597b25bb 100644
--- a/engines/nancy/module.mk
+++ b/engines/nancy/module.mk
@@ -58,6 +58,7 @@ MODULE_OBJS = \
   ui/button.o \
   ui/clock.o \
   ui/cellphonepopup.o \
+  ui/conversationpopup.o \
   ui/inventorybox.o \
   ui/inventorypopup.o \
   ui/notebookpopup.o \
diff --git a/engines/nancy/state/scene.cpp b/engines/nancy/state/scene.cpp
index 042c8a764b4..977431f5888 100644
--- a/engines/nancy/state/scene.cpp
+++ b/engines/nancy/state/scene.cpp
@@ -625,6 +625,7 @@ void Scene::registerGraphics() {
 		_inventoryPopup.registerGraphics();
 		_notebookPopup.registerGraphics();
 		_cellPhonePopup.registerGraphics();
+		_conversationPopup.registerGraphics();
 	}
 
 	_hotspotDebug.registerGraphics();
@@ -1158,6 +1159,7 @@ void Scene::handleInput() {
 	// the popup that overlapped the textbox area could accidentally pick
 	// a conversation response.
 	if (g_nancy->getGameType() >= kGameTypeNancy10) {
+		_conversationPopup.handleInput(input);
 		_inventoryPopup.handleInput(input);
 		_notebookPopup.handleInput(input);
 		_cellPhonePopup.handleInput(input);
@@ -1295,6 +1297,7 @@ void Scene::initStaticData() {
 		_inventoryPopup.init();
 		_notebookPopup.init();
 		_cellPhonePopup.init();
+		_conversationPopup.init();
 	}
 
 	// Init buttons
diff --git a/engines/nancy/state/scene.h b/engines/nancy/state/scene.h
index 1474f3ca118..e9118c381ea 100644
--- a/engines/nancy/state/scene.h
+++ b/engines/nancy/state/scene.h
@@ -39,6 +39,7 @@
 #include "engines/nancy/ui/inventorypopup.h"
 #include "engines/nancy/ui/notebookpopup.h"
 #include "engines/nancy/ui/cellphonepopup.h"
+#include "engines/nancy/ui/conversationpopup.h"
 
 namespace Common {
 class SeekableReadStream;
@@ -177,6 +178,7 @@ public:
 	UI::InventoryPopup &getInventoryPopup() { return _inventoryPopup; }
 	UI::NotebookPopup &getNotebookPopup() { return _notebookPopup; }
 	UI::CellPhonePopup &getCellPhonePopup() { return _cellPhonePopup; }
+	UI::ConversationPopup &getConversationPopup() { return _conversationPopup; }
 	UI::Clock *getClock();
 	UI::Taskbar *getTaskbar() { return _taskbar; }
 
@@ -279,6 +281,7 @@ private:
 	UI::InventoryPopup _inventoryPopup;
 	UI::NotebookPopup _notebookPopup;
 	UI::CellPhonePopup _cellPhonePopup;
+	UI::ConversationPopup _conversationPopup;
 
 	UI::Button *_menuButton;
 	UI::Button *_helpButton;
diff --git a/engines/nancy/ui/conversationpopup.cpp b/engines/nancy/ui/conversationpopup.cpp
new file mode 100644
index 00000000000..dbc44d6abc3
--- /dev/null
+++ b/engines/nancy/ui/conversationpopup.cpp
@@ -0,0 +1,317 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "engines/nancy/cursor.h"
+#include "engines/nancy/graphics.h"
+#include "engines/nancy/input.h"
+#include "engines/nancy/nancy.h"
+#include "engines/nancy/resource.h"
+
+#include "engines/nancy/state/scene.h"
+
+#include "engines/nancy/ui/conversationpopup.h"
+
+namespace Nancy {
+namespace UI {
+
+static const uint16 kHypertextSurfaceHeight = 4096;
+
+ConversationPopup::ConversationPopup() :
+		RenderObject(12), // z=12: above the viewport (6) and the taskbar (7).
+		_uicoData(nullptr),
+		_tboxData(nullptr),
+		_highlightRObj(13) {}
+
+void ConversationPopup::init() {
+	_uicoData = GetEngineData(UICO);
+	assert(_uicoData);
+	_tboxData = GetEngineData(TBOX);
+	assert(_tboxData);
+
+	g_nancy->_resource->loadImage(_uicoData->header.imageName, _overlayImage);
+
+	Common::Rect popupRect = _uicoData->header.normalDestRect;
+	if (_uicoData->header.overlayInGameFrame) {
+		const VIEW *view = GetEngineData(VIEW);
+		if (view) {
+			popupRect.translate(view->screenPosition.left, view->screenPosition.top);
+		}
+	}
+	moveTo(popupRect);
+
+	Common::Rect bounds = _screenPosition;
+	bounds.moveTo(0, 0);
+	_drawSurface.create(bounds.width(), bounds.height(), g_nancy->_graphics->getInputPixelFormat());
+
+	initSurfaces(_uicoData->textRect.width(), kHypertextSurfaceHeight,
+				 g_nancy->_graphics->getInputPixelFormat(), 0, 0);
+
+	_highlightRObj.moveTo(popupRect);
+
+	drawBackground();
+	setTransparent(false);
+	setVisible(false);
+
+	RenderObject::init();
+}
+
+void ConversationPopup::registerGraphics() {
+	RenderObject::registerGraphics();
+	_highlightRObj.registerGraphics();
+	_highlightRObj.setVisible(false);
+}
+
+void ConversationPopup::open() {
+	// Always clear, even if already visible — the conversation action
+	// calls open() on every new dialogue state (matching Textbox::clear()
+	// behavior for Nancy 1-9).
+	_scrollPos = 0.0f;
+	_scrollbarDragging = false;
+	_scrollbarHovered = false;
+	_responseStartIdx = 0;
+
+	HypertextParser::clear();
+	_drawnTextHeight = 0;
+	_fullSurface.fillRect(Common::Rect(0, 0, _fullSurface.w, _fullSurface.h), 0);
+	_textHighlightSurface.fillRect(Common::Rect(0, 0, _textHighlightSurface.w, _textHighlightSurface.h), 0);
+
+	drawBackground();
+	drawScrollbar(0);
+	setVisible(true);
+}
+
+void ConversationPopup::addTextLine(const Common::String &text) {
+	HypertextParser::addTextLine(text);
+
+	drawBackground();
+	drawContent();
+	drawScrollbar(0);
+}
+
+void ConversationPopup::close() {
+	setVisible(false);
+	_highlightRObj.setVisible(false);
+}
+
+void ConversationPopup::drawBackground() {
+	_drawSurface.blitFrom(_overlayImage, _uicoData->header.normalSrcRect,
+						  Common::Point(0, 0));
+	_needsRedraw = true;
+}
+
+void ConversationPopup::drawContent() {
+	_drawnTextHeight = 0;
+	_numDrawnLines = 0;
+	_hotspots.clear();
+	_fullSurface.fillRect(Common::Rect(0, 0, _fullSurface.w, _fullSurface.h), 0);
+	_textHighlightSurface.fillRect(Common::Rect(0, 0, _textHighlightSurface.w, _textHighlightSurface.h), 0);
+
+	// TODO: Padding doesn't match the original game. leftOffset returns an
+	// unexpected value for Nancy 10+, so upOffset is used as a fallback for
+	// both axes until the TBOX layout for Nancy 10+ is better understood.
+
+	// TODO: Line spacing is tighter than the original game.
+	
+	Common::Rect textBounds(0, 0, _fullSurface.w, _fullSurface.h);
+	textBounds.top += _tboxData->upOffset;
+	textBounds.left += _tboxData->upOffset;
+
+	drawAllText(textBounds, 0, _tboxData->conversationFontID, _tboxData->highlightConversationFontID);
+
+	Common::Rect localTextRect = _uicoData->textRect;
+	localTextRect.translate(-_uicoData->header.normalDestRect.left,
+							-_uicoData->header.normalDestRect.top);
+
+	const uint16 inner = getInnerHeight();
+	const uint16 outer = localTextRect.height();
+	int scrollY = 0;
+	if (inner > outer) {
+		scrollY = MIN<int>((int)(_scrollPos * (inner - outer)),
+						   MAX<int>(0, _fullSurface.h - outer));
+	}
+
+	_drawSurface.blitFrom(_fullSurface,
+		Common::Rect(0, scrollY, _fullSurface.w, scrollY + outer),
+		Common::Point(localTextRect.left, localTextRect.top));
+
+	_needsRedraw = true;
+}
+
+uint16 ConversationPopup::getInnerHeight() const {
+	return _drawnTextHeight + _tboxData->upOffset;
+}
+
+Common::Rect ConversationPopup::toPopupLocal(const Common::Rect &chunkRect, bool useGameFrame) const {
+	// Chunk-space rects need the viewport offset applied when their own
+	// destUsesGameFrameOffset flag is set, then the popup's screen
+	// position subtracted to get surface-local coordinates.
+	Common::Rect r = chunkRect;
+	if (useGameFrame) {
+		const VIEW *view = GetEngineData(VIEW);
+		if (view) {
+			r.translate(view->screenPosition.left, view->screenPosition.top);
+		}
+	}
+	r.translate(-_screenPosition.left, -_screenPosition.top);
+	return r;
+}
+
+Common::Point ConversationPopup::popupLocalMouse(const Common::Point &screenMouse) const {
+	return Common::Point(screenMouse.x - _screenPosition.left,
+						 screenMouse.y - _screenPosition.top);
+}
+
+Common::Rect ConversationPopup::computeThumbRect() const {
+	const UISliderRecord &sl = _uicoData->header.slider;
+	if (!_uicoData->header.sliderEnabled || sl.destRect.isEmpty() || sl.sourceRects[0].isEmpty()) {
+		return Common::Rect();
+	}
+
+	const int trackHeight = sl.destRect.height();
+	const int thumbHeight = sl.sourceRects[0].height();
+	const int travel = MAX(0, trackHeight - thumbHeight);
+	const int thumbY = sl.destRect.top + (int)(_scrollPos * travel);
+
+	Common::Rect chunkThumb(sl.destRect.left, thumbY,
+							sl.destRect.left + sl.sourceRects[0].width(),
+							thumbY + thumbHeight);
+	return toPopupLocal(chunkThumb, sl.destUsesGameFrameOffset != 0);
+}
+
+void ConversationPopup::drawScrollbar(uint state) {
+	const UISliderRecord &sl = _uicoData->header.slider;
+	if (!_uicoData->header.sliderEnabled) {
+		return;
+	}
+
+	const Common::Rect thumb = computeThumbRect();
+	if (thumb.isEmpty()) {
+		return;
+	}
+
+	_drawSurface.blitFrom(_overlayImage, sl.sourceRects[state],
+						  Common::Point(thumb.left, thumb.top));
+}
+
+void ConversationPopup::handleInput(NancyInput &input) {
+	if (!_isVisible) {
+		return;
+	}
+
+	const Common::Point localMouse = popupLocalMouse(input.mousePos);
+	const UISliderRecord &slider = _uicoData->header.slider;
+
+	if (_uicoData->header.sliderEnabled) {
+		const Common::Rect trackLocal = toPopupLocal(slider.destRect, slider.destUsesGameFrameOffset != 0);
+		const int trackHeight = trackLocal.height();
+		const int thumbHeight = slider.sourceRects[0].height();
+		const int travel = MAX(0, trackHeight - thumbHeight);
+		const int thumbY = trackLocal.top + (int)(_scrollPos * travel);
+		Common::Rect thumbLocal(trackLocal.left, thumbY,
+								trackLocal.left + slider.sourceRects[0].width(),
+								thumbY + thumbHeight);
+		const bool overThumb = thumbLocal.contains(localMouse);
+
+		if (_scrollbarDragging) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+
+			const int newThumbTop = localMouse.y - _scrollbarGrabOffset;
+			const int clamped = CLIP<int>(newThumbTop, trackLocal.top, trackLocal.top + travel);
+			_scrollPos = travel > 0 ? (float)(clamped - trackLocal.top) / (float)travel : 0.0f;
+
+			drawBackground();
+			drawContent();
+			drawScrollbar(2);
+
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				_scrollbarDragging = false;
+				drawScrollbar(overThumb ? 1 : 0);
+				_needsRedraw = true;
+			}
+			input.eatMouseInput();
+			return;
+		}
+
+		if (overThumb != _scrollbarHovered) {
+			_scrollbarHovered = overThumb;
+			drawScrollbar(overThumb ? 1 : 0);
+			_needsRedraw = true;
+		}
+
+		if (overThumb) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+			if (slider.isDraggable && (input.input & NancyInput::kLeftMouseButtonDown)) {
+				_scrollbarDragging = true;
+				_scrollbarGrabOffset = localMouse.y - thumbY;
+				drawScrollbar(2);
+				_needsRedraw = true;
+				input.eatMouseInput();
+				return;
+			}
+		}
+	}
+
+	// Response hotspot handling — mirrors Textbox::handleInput().
+	Common::Rect localTextRect = _uicoData->textRect;
+	localTextRect.translate(-_uicoData->header.normalDestRect.left,
+							-_uicoData->header.normalDestRect.top);
+
+	const uint16 inner = getInnerHeight();
+	const uint16 outer = localTextRect.height();
+	const int scrollY = inner > outer ? (int)(_scrollPos * (inner - outer)) : 0;
+
+	bool hasHighlight = false;
+	for (uint i = _responseStartIdx; i < _hotspots.size(); ++i) {
+		Common::Rect hotspot = _hotspots[i];
+		hotspot.translate(localTextRect.left, localTextRect.top - scrollY);
+		Common::Rect hotspotOnScreen = convertToScreen(hotspot).findIntersectingRect(_screenPosition);
+		if (hotspotOnScreen.contains(input.mousePos)) {
+			g_nancy->_cursor->setCursorType(CursorManager::kHotspotArrow);
+
+			_highlightRObj.setVisible(true);
+			Common::Rect hotspotInside = convertToLocal(hotspotOnScreen);
+			hotspotInside.translate(-localTextRect.left, scrollY - localTextRect.top);
+			_highlightRObj._drawSurface.create(_textHighlightSurface, hotspotInside);
+			_highlightRObj.moveTo(hotspotOnScreen);
+			hasHighlight = true;
+
+			if (input.input & NancyInput::kLeftMouseButtonUp) {
+				input.input &= ~NancyInput::kLeftMouseButtonUp;
+				NancySceneState.clearLogicConditions();
+				NancySceneState.setLogicCondition(i - _responseStartIdx, g_nancy->_true);
+			}
+
+			break;
+		}
+	}
+
+	if (!hasHighlight && _highlightRObj.isVisible()) {
+		_highlightRObj.setVisible(false);
+	}
+
+	// Swallow clicks on the popup itself so they don't fall through.
+	if (_screenPosition.contains(input.mousePos)) {
+		input.eatMouseInput();
+	}
+}
+
+} // End of namespace UI
+} // End of namespace Nancy
diff --git a/engines/nancy/ui/conversationpopup.h b/engines/nancy/ui/conversationpopup.h
new file mode 100644
index 00000000000..50cf6a0f22d
--- /dev/null
+++ b/engines/nancy/ui/conversationpopup.h
@@ -0,0 +1,99 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef NANCY_UI_CONVERSATIONPOPUP_H
+#define NANCY_UI_CONVERSATIONPOPUP_H
+
+#include "engines/nancy/misc/hypertext.h"
+#include "engines/nancy/renderobject.h"
+
+namespace Nancy {
+
+struct NancyInput;
+struct TBOX;
+struct UICO;
+
+namespace UI {
+
+// Nancy 10+ conversation popup. Replaces the legacy Textbox for dialogue
+// display. The conversation action calls open(), addTextLine(), and close()
+// to drive the popup lifecycle. Response hotspots and hover highlights
+// mirror the Textbox behavior but render inside a UICO-driven overlay window.
+class ConversationPopup : public RenderObject, public Misc::HypertextParser {
+public:
+	ConversationPopup();
+	~ConversationPopup() override = default;
+
+	void init() override;
+	void registerGraphics() override;
+	void handleInput(NancyInput &input);
+
+	// Called by ConversationSound::execute() at each new dialogue state.
+	// Always clears previous content, matching Textbox::clear() for Nancy 1-9.
+	void open();
+
+	// Called once per text line after open() — NPC speech first, then
+	// each response option. Triggers a redraw after each addition.
+	void addTextLine(const Common::String &text);
+
+	// Called at kActionTrigger just before finishExecution(). Hides the popup.
+	void close();
+
+	// Called after NPC text lines have been added, before response lines.
+	// Records the hotspot offset so response clicks map to the correct index.
+	void setResponseStart() { _responseStartIdx = (uint)_hotspots.size(); }
+
+private:
+	void drawBackground();
+	void drawContent();
+	void drawScrollbar(uint state);
+	uint16 getInnerHeight() const;
+
+	// Returns the popup-local bounding rect of the scrollbar thumb at
+	// the current scroll position.
+	Common::Rect computeThumbRect() const;
+
+	// Convert a chunk-space rect into popup-local coordinates.
+	Common::Rect toPopupLocal(const Common::Rect &chunkRect, bool useGameFrame) const;
+	Common::Point popupLocalMouse(const Common::Point &screenMouse) const;
+
+	const UICO *_uicoData;
+	const TBOX *_tboxData;
+
+	Graphics::ManagedSurface _overlayImage;
+
+	// Separate render object for drawing hovered response text,
+	// mirroring the Textbox::_highlightRObj approach.
+	RenderObject _highlightRObj;
+
+	// Scrollbar state, driven by header.slider.
+	float _scrollPos = 0.0f;
+	bool _scrollbarDragging = false;
+	bool _scrollbarHovered = false;
+	int _scrollbarGrabOffset = 0;
+
+	uint _responseStartIdx = 0;
+};
+
+} // End of namespace UI
+} // End of namespace Nancy
+
+#endif // NANCY_UI_CONVERSATIONPOPUP_H




More information about the Scummvm-git-logs mailing list