/* 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 . * */ #include "common/tokenizer.h" #include "engines/nancy/nancy.h" #include "engines/nancy/graphics.h" #include "engines/nancy/resource.h" #include "engines/nancy/misc/hypertext.h" namespace Nancy { namespace Misc { struct MetaInfo { enum Type { kColor, kFont, kMark, kHotspot }; Type type; uint numChars; byte index; }; void HypertextParser::initSurfaces(uint width, uint height, const Graphics::PixelFormat &format, uint32 backgroundColor, uint32 highlightBackgroundColor) { _backgroundColor = backgroundColor; _highlightBackgroundColor = highlightBackgroundColor; _fullSurface.create(width, height, format); _fullSurface.clear(backgroundColor); _textHighlightSurface.create(width, height, format); _textHighlightSurface.clear(highlightBackgroundColor); } void HypertextParser::addTextLine(const Common::String &text) { _textLines.push_back(text); _needsTextRedraw = true; } void HypertextParser::addImage(uint16 lineID, const Common::Rect &src) { _imageLineIDs.push_back(lineID); _imageSrcs.push_back(src); } void HypertextParser::setImageName(const Common::Path &name) { _imageName = name; } void HypertextParser::drawAllText(const Common::Rect &textBounds, uint leftOffsetNonNewline, uint fontID, uint highlightFontID) { using namespace Common; const Font *font = nullptr; const Font *highlightFont = nullptr; Graphics::ManagedSurface image; _numDrawnLines = 0; if (!_imageName.empty()) { g_nancy->_resource->loadImage(_imageName, image); } for (uint lineID = 0; lineID < _textLines.size(); ++lineID) { Common::String currentLine; bool hasHotspot = false; Rect hotspot; Common::Queue metaInfo; Common::Queue newlineTokens; newlineTokens.push(0); int curFontID = fontID; uint numNonSpaceChars = 0; // Token braces plus invalid characters that are known to appear in strings Common::StringTokenizer tokenizer(_textLines[lineID], "<>\""); Common::String curToken; bool reachedEndTag = false; while(!tokenizer.empty() && !reachedEndTag) { curToken = tokenizer.nextToken(); if (tokenizer.delimitersAtTokenBegin().lastChar() == '<' && tokenizer.delimitersAtTokenEnd().firstChar() == '>') { switch (curToken.firstChar()) { case 'i' : // CC begin // fall through case 'o' : // CC end if (curToken.size() != 1) { break; } continue; case 'e' : // End conversation. Originally used for quickly ending dialogue when debugging, but // also marks the ending of the current text line. if (curToken.size() != 1) { break; } // Ignore the rest of the text. This fixes nancy7 scene 5770 reachedEndTag = true; continue; case 'h' : // Hotspot if (curToken.size() != 1) { break; } if (hasHotspot) { // Replace duplicate hotspot token with a newline to copy the original behavior currentLine += '\n'; } hasHotspot = true; continue; case 'H' : // Hotspot inside list, begin if (curToken.size() != 1) { break; } metaInfo.push({MetaInfo::kHotspot, numNonSpaceChars, 1}); continue; case 'L' : // Hotspot inside list, end if (curToken.size() != 1) { break; } metaInfo.push({MetaInfo::kHotspot, numNonSpaceChars, 0}); continue; case 'n' : // Newline if (curToken.size() != 1) { break; } currentLine += '\n'; newlineTokens.push(numNonSpaceChars); continue; case 't' : // Tab if (curToken.size() != 1) { break; } currentLine += '\t'; continue; case 'c' : // Color tokens // We keep the positions (excluding spaces) and colors of the color tokens in a queue if (curToken.size() != 2) { break; } metaInfo.push({MetaInfo::kColor, numNonSpaceChars, (byte)(curToken[1] - '0')}); continue; case 'f' : // Font token // This selects a specific font ID for the following text if (curToken.size() != 2) { break; } metaInfo.push({MetaInfo::kFont, numNonSpaceChars, (byte)(curToken[1] - '0')}); continue; case '1': case '2': case '3': case '4': case '5': // Mark token for Nancy 8 and later games. no-op for earlier games if (g_nancy->getGameType() <= kGameTypeNancy7) { continue; } if (curToken.size() != 1) { break; } metaInfo.push({MetaInfo::kMark, numNonSpaceChars, (byte)(curToken[0] - '1')}); continue; default: break; } // Ignore non-tokens when they're between braces. This fixes nancy6 scenes 1953 & 1954, // where some sound names slipped through into the text data. debugC(Nancy::kDebugHypertext, "Unrecognized hypertext tag <%s>", curToken.c_str()); continue; } // Count the number of non-space characters. We use this to keep track // of where color changes should happen, since including whitespaces // presents a lot of edge cases when combined with word wrapping for (uint i = 0; i < curToken.size(); ++i) { if (curToken[i] != ' ') { ++numNonSpaceChars; } } currentLine += curToken; } font = g_nancy->_graphics->getFont(curFontID); highlightFont = g_nancy->_graphics->getFont(highlightFontID); assert(font && highlightFont); // Do word wrapping on the text, sans tokens. This assumes // all text uses fonts of the same width Array wrappedLines; font->wordWrap(currentLine, textBounds.width(), wrappedLines, 0); // Setup most of the hotspot; textbox if (hasHotspot) { hotspot.left = textBounds.left; hotspot.top = textBounds.top + (_numDrawnLines * font->getFontHeight()) - 1; hotspot.setHeight(0); hotspot.setWidth(0); } // Go through the wrapped lines and draw them, making sure to // respect color tokens uint totalCharsDrawn = 0; byte colorID = _defaultTextColor; uint numNewlineTokens = 0; uint horizontalOffset = 0; bool newLineStart = false; for (uint lineNumber = 0; lineNumber < wrappedLines.size(); ++lineNumber) { Common::String &line = wrappedLines[lineNumber]; horizontalOffset = 0; newLineStart = false; // Draw images if (newlineTokens.empty()) { warning("HypertextParser::drawAllText():: newlineTokens list was empty at line %u out of %u wrapped lines", lineNumber+1, wrappedLines.size()); } if (!newlineTokens.empty() && newlineTokens.front() <= totalCharsDrawn) { newlineTokens.pop(); newLineStart = true; for (uint i = 0; i < _imageLineIDs.size(); ++i) { if (numNewlineTokens == _imageLineIDs[i]) { // A lot of magic numbers that make sure we draw pixel-perfect. This is a mess for three reasons: // - The original engine draws strings with a bottom-left anchor, while ScummVM uses top-left // - The original engine uses inclusive rects, while ScummVM uses non-includive // - The original engine does some stupid stuff with spacing // This works correctly in nancy7, but might fail with different games/fonts if (lineNumber != 0) { _imageVerticalOffset += (font->getFontHeight() + 1) / 2 + 1; } _fullSurface.blitFrom(image, _imageSrcs[i], Common::Point( textBounds.left + horizontalOffset + 1, textBounds.top + _numDrawnLines * highlightFont->getFontHeight() + _imageVerticalOffset)); _imageVerticalOffset += _imageSrcs[i].height() - 1; if (lineNumber == 0) { _imageVerticalOffset += font->getFontHeight() / 2 - 1; } else { _imageVerticalOffset += (font->getFontHeight() + 1) / 2 + 3; } } } ++numNewlineTokens; } // Trim whitespaces (only) at beginning and end of wrapped lines while (line.lastChar() == ' ') { line.deleteLastChar(); } while (line.firstChar() == ' ') { line.deleteChar(0); } bool newWrappedLine = true; // Used to ensure color/font changes don't mess up hotspots while (!line.empty()) { Common::String subLine; while (metaInfo.size() && totalCharsDrawn >= metaInfo.front().numChars) { // We have a color/font change token, a hyperlink, or a mark at begginning of (what's left of) the current line MetaInfo change = metaInfo.pop(); switch (change.type) { case MetaInfo::kFont: curFontID = change.index; font = g_nancy->_graphics->getFont(curFontID); break; case MetaInfo::kColor: colorID = change.index; break; case MetaInfo::kMark: { auto *mark = GetEngineData(MARK); assert(mark); if (lineNumber == 0) { // A mark on the first line pushes up all text if (textBounds.top - _imageVerticalOffset > 3) { _imageVerticalOffset -= 3; } else { _imageVerticalOffset = -textBounds.top; } } Common::Rect markSrc = mark->_markSrcs[change.index]; Common::Rect markDest = markSrc; markDest.moveTo(textBounds.left + horizontalOffset + (newLineStart ? 0 : leftOffsetNonNewline) + 1, lineNumber == 0 ? textBounds.top - ((font->getFontHeight() + 1) / 2) + _imageVerticalOffset + 4 : textBounds.top + _numDrawnLines * font->getFontHeight() + _imageVerticalOffset - 4); // For now we do not check if we need to go to new line; neither does the original _fullSurface.blitFrom(g_nancy->_graphics->_object0, markSrc, markDest); horizontalOffset += markDest.width() + 2; break; } case MetaInfo::kHotspot: // List only hasHotspot = change.index; if (hasHotspot) { hotspot.left = textBounds.left + (newLineStart ? 0 : horizontalOffset + leftOffsetNonNewline); hotspot.top = textBounds.top + _numDrawnLines * font->getFontHeight() + _imageVerticalOffset - 1; hotspot.setHeight(0); hotspot.setWidth(0); } else { _hotspots.push_back(hotspot); hotspot = { 0, 0, 0, 0 }; } break; } } uint lineSizeNoSpace = 0; for (uint i = 0; i < line.size(); ++i) { if (!isSpace(line[i])) { ++lineSizeNoSpace; } } if (metaInfo.size() && totalCharsDrawn < metaInfo.front().numChars && metaInfo.front().numChars <= (totalCharsDrawn + lineSizeNoSpace)) { // There's a token inside the current line, so split off the part before it uint subSize = metaInfo.front().numChars - totalCharsDrawn; for (uint i = 0; i < subSize; ++i) { if (isSpace(line[i])) { ++subSize; } } subLine = line.substr(0, subSize); line = line.substr(subLine.size()); } // Choose whether to draw the subLine, or the full line Common::String &stringToDraw = subLine.size() ? subLine : line; // Draw the normal text font->drawString( &_fullSurface, stringToDraw, textBounds.left + horizontalOffset + (newLineStart ? 0 : leftOffsetNonNewline), textBounds.top + _numDrawnLines * font->getFontHeight() + _imageVerticalOffset, textBounds.width(), colorID); // Then, draw the highlight if (hasHotspot && !_textHighlightSurface.empty()) { highlightFont->drawString( &_textHighlightSurface, stringToDraw, textBounds.left + horizontalOffset + (newLineStart ? leftOffsetNonNewline : 0), textBounds.top + _numDrawnLines * highlightFont->getFontHeight() + _imageVerticalOffset, textBounds.width(), colorID); } // Count number of non-space characters drawn. Used for color. // Note that we use isSpace() specifically to exclude the tab character for (uint i = 0; i < stringToDraw.size(); ++i) { if (!isSpace(stringToDraw[i])) { ++totalCharsDrawn; } } // Add to the width/height of the hotspot if (hasHotspot) { hotspot.setWidth(MAX(hotspot.width(), font->getStringWidth(stringToDraw))); if (!stringToDraw.empty() && newWrappedLine) { hotspot.setHeight(hotspot.height() + font->getFontHeight()); } } newWrappedLine = false; if (subLine.size()) { horizontalOffset += font->getStringWidth(subLine); } else { break; } } ++_numDrawnLines; // Record the height of the text currently drawn. Used for textbox scrolling _drawnTextHeight = (_numDrawnLines - 1) * font->getFontHeight() + _imageVerticalOffset; } // Draw the footer image(s) for (uint i = 0; i < _imageLineIDs.size(); ++i) { if (numNewlineTokens <= _imageLineIDs[i]) { _imageVerticalOffset += (font->getFontHeight() + 1) / 2 + 1; _fullSurface.blitFrom(image, _imageSrcs[i], Common::Point( textBounds.left + horizontalOffset + 1, textBounds.top + _numDrawnLines * highlightFont->getFontHeight() + _imageVerticalOffset)); _imageVerticalOffset += _imageSrcs[i].height() - 1; if (i < _imageLineIDs.size() - 1) { _imageVerticalOffset += (font->getFontHeight() + 1) / 2 + 3; } _drawnTextHeight = (_numDrawnLines - 1) * font->getFontHeight() + _imageVerticalOffset; } } // Add the hotspot to the list if (hasHotspot) { _hotspots.push_back(hotspot); } // Note: disabled since it was most likely a bug, and is behavior exclusive to the textbox /* // Simulate a bug in the original engine where player text longer than // a single line gets a double newline afterwards if (wrappedLines.size() > 1 && hasHotspot) { ++_numLines; if (lineID == _textLines.size() - 1) { _lastResponseisMultiline = true; } } */ // Add a newline after every full piece of text ++_numDrawnLines; _drawnTextHeight += font->getFontHeight(); } // Add a line's height at end of text to replicate original behavior if (font) { _drawnTextHeight += font->getFontHeight(); } _needsTextRedraw = false; } void HypertextParser::clear() { if (_textLines.size()) { _fullSurface.clear(_backgroundColor); _textHighlightSurface.clear(_highlightBackgroundColor); _textLines.clear(); _hotspots.clear(); _numDrawnLines = 0; _drawnTextHeight = 0; } } } // End of namespace Misc } // End of namespace Nancy