diff --git a/.npmignore b/.npmignore index bc22a199ec..f3c7f66d45 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,4 @@ **/.* build/ plugins/spine/spine-runtimes/ +visual-tests/ diff --git a/src/gameobjects/bitmaptext/BatchChar.js b/src/gameobjects/bitmaptext/BatchChar.js index 3126b4459f..8c5448cf2d 100644 --- a/src/gameobjects/bitmaptext/BatchChar.js +++ b/src/gameobjects/bitmaptext/BatchChar.js @@ -32,7 +32,7 @@ var tempTransformData = { */ var BatchChar = function (drawingContext, submitterNode, src, char, glyph, offsetX, offsetY, calcMatrix, tintData) { - tempTextureData.frame = src.frame; + tempTextureData.frame = char.style ? char.style.frame : src.frame; tempTextureData.uvSource = glyph; var x = (char.x - src.displayOriginX) + offsetX; diff --git a/src/gameobjects/bitmaptext/GetBitmapTextSize.js b/src/gameobjects/bitmaptext/GetBitmapTextSize.js index 29ed8103aa..5b8f315f49 100644 --- a/src/gameobjects/bitmaptext/GetBitmapTextSize.js +++ b/src/gameobjects/bitmaptext/GetBitmapTextSize.js @@ -72,6 +72,7 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) var chars = src.fontData.chars; var lineHeight = src.fontData.lineHeight; + var styleByIndex = src._styleByIndex; var letterSpacing = src.letterSpacing; var lineSpacing = src.lineSpacing; @@ -93,6 +94,7 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) var lastGlyph = null; var lastCharCode = 0; + var lastFontData = null; var lineWidths = []; var shortestLine = Number.MAX_VALUE; var longestLine = 0; @@ -100,97 +102,217 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) var currentLineWidth = 0; var i; - var j; var lines; var words = []; var characters = []; var current = null; - // Measure the width of the text - var measureTextWidth = function (text, fontData) + // Resolve each character into an item carrying its glyph, advance and kerning + var items = []; + for (i = 0; i < textLength; i++) { - var width = 0; + charCode = text.charCodeAt(i); + if (charCode === 10) + { + items.push({ newline: true }); + lastGlyph = null; + lastFontData = null; + continue; + } - for (var i = 0; i < text.length; i++) + var fontData = src.fontData; + var style = null; + + if (styleByIndex) { - var charCode = text.charCodeAt(i); - var glyph = fontData.chars[charCode]; + style = styleByIndex[i]; + fontData = style.fontData; + + glyph = fontData.chars[charCode]; - if (glyph) + // The style font is missing this char: fall back to the default + if (!glyph) { - width += glyph.xAdvance; + fontData = src.fontData; + glyph = fontData.chars[charCode]; + style = null; } } + else + { + glyph = chars[charCode]; + } + + if (!glyph) continue; + + var kerningOffset = 0; + + if (lastGlyph !== null && fontData === lastFontData) + { + kerningOffset = glyph.kerning[lastCharCode]; + if (kerningOffset === undefined) + kerningOffset = 0; + } + + // Scale of this item relative to the global scale; 1 unless the + // style sets an explicit size + var rel = 1; + + if (style !== null && style.size !== undefined) + { + rel = (style.size / fontData.size) / scale; + } + + items.push({ + idx: i, + char: text[i], + code: charCode, + glyph: glyph, + fontData: fontData, + base: (fontData.base === undefined) ? fontData.lineHeight : fontData.base, + style: style, + rel: rel, + kerningOffset: kerningOffset, + advance: glyph.xAdvance + letterSpacing + kerningOffset, + charWidth: glyph.xOffset + glyph.xAdvance + kerningOffset, + isSpace: charCode === wordWrapCharCode + }); - return width * sx; - }; - - // Scan for breach of maxWidth and insert carriage-returns + lastGlyph = glyph; + lastCharCode = charCode; + lastFontData = fontData; + } + + // Apply automatic wrapping if (maxWidth > 0) { - // Split the text into lines - lines = text.split('\n'); - var wrappedLines = []; - - // Loop through each line - for (i = 0; i < lines.length; i++) + var breakIndices = []; + var wordWidth = 0; + var wordSpace = null; + var wordFirst = null; + var wrapLineWidth = 0; + var lastSpace = null; + + for (i = 0; i <= items.length; i++) { - var line = lines[i]; - var word = ''; - var wrappedLine = ''; - var lineToCheck = ''; - var lineWithWord = ''; - - // Loop through each character in a line - for (j = 0; j < line.length; j++) + var wrapItem = (i < items.length) ? items[i] : null; + var atLineEnd = (wrapItem === null || wrapItem.newline); + + if (!atLineEnd) { - charCode = line.charCodeAt(j); + if (wordWidth === 0) + { + wordFirst = wrapItem; + } - word += line[j]; + // More correct would be wordWidth += wrapItem.advance, but the old + // string-based wrap used glyph.xAdvance only, so keeping it to avoid a behaviour change + wordWidth += wrapItem.glyph.xAdvance * wrapItem.rel; - // White space or end of line? - if (charCode === wordWrapCharCode || j === line.length - 1) + if (wrapItem.code === wordWrapCharCode) + { + wordSpace = wrapItem; + } + else { - lineWithWord = lineToCheck + word; - - var textWidth = measureTextWidth(lineWithWord, src.fontData); + continue; + } + } - if (textWidth <= maxWidth) - { - lineToCheck = lineWithWord; - } - else + if (wordWidth > 0 || wordSpace !== null) + { + if ((wrapLineWidth + wordWidth) * sx <= maxWidth) + { + wrapLineWidth += wordWidth; + } + else + { + if (lastSpace !== null) { - // If the current word is too long to fit on a line, wrap it - // Remove trailing word wrap char to keep text length the same - wrappedLine = wrappedLine.slice(0, -1); - wrappedLine += (wrappedLine ? '\n' : '') + lineToCheck; - lineToCheck = word; + lastSpace.newline = true; + breakIndices.push(lastSpace.idx); + + wordFirst.advance -= wordFirst.kerningOffset; + wordFirst.charWidth -= wordFirst.kerningOffset; + wordFirst.kerningOffset = 0; } - word = ''; + wrapLineWidth = wordWidth; } + + lastSpace = wordSpace; + wordSpace = null; + wordWidth = 0; + wordFirst = null; } - wrappedLine = wrappedLine.slice(0, -1); - wrappedLine += (wrappedLine ? '\n' : '') + lineToCheck; - wrappedLines.push(wrappedLine); + if (atLineEnd) + { + wrapLineWidth = 0; + lastSpace = null; + } } - text = wrappedLines.join('\n'); + if (breakIndices.length > 0) + { + var textChars = text.split(''); + + for (i = 0; i < breakIndices.length; i++) + { + textChars[breakIndices[i]] = '\n'; + } + + text = textChars.join(''); + } out.wrappedText = text; + } + + // Calculate per-line metrics (base and height) + var lineHeights = null; + var lineBases = null; + + if (styleByIndex) + { + var defaultBase = (src.fontData.base === undefined) ? lineHeight : src.fontData.base; + var metricsLine = 0; - textLength = text.length; + lineHeights = [ lineHeight ]; + lineBases = [ defaultBase ]; + + for (i = 0; i < items.length; i++) + { + var metricsItem = items[i]; + + if (metricsItem.newline) + { + metricsLine++; + + lineHeights[metricsLine] = lineHeight; + lineBases[metricsLine] = defaultBase; + } + else + { + if (metricsItem.fontData.lineHeight * metricsItem.rel > lineHeights[metricsLine]) + { + lineHeights[metricsLine] = metricsItem.fontData.lineHeight * metricsItem.rel; + } + + if (metricsItem.base * metricsItem.rel > lineBases[metricsLine]) + { + lineBases[metricsLine] = metricsItem.base * metricsItem.rel; + } + } + } } + // Position characters var charIndex = 0; - - for (i = 0; i < textLength; i++) + for (i = 0; i < items.length; i++) { - charCode = text.charCodeAt(i); + var item = items[i]; - if (charCode === 10) + if (item.newline) { if (current !== null) { @@ -206,8 +328,6 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) current = null; } - lastGlyph = null; - lineWidths[currentLine] = currentLineWidth; if (currentLineWidth > longestLine) @@ -224,26 +344,28 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) currentLineWidth = 0; xAdvance = 0; - yAdvance = (lineHeight + lineSpacing) * currentLine; - continue; - } - - glyph = chars[charCode]; + if (lineHeights) + { + yAdvance += lineHeights[currentLine - 1] + lineSpacing; + } + else + { + yAdvance = (lineHeight + lineSpacing) * currentLine; + } - if (!glyph) - { continue; } - x = xAdvance; + glyph = item.glyph; + + x = xAdvance + item.kerningOffset * item.rel; y = yAdvance; - if (lastGlyph !== null) + // Baseline alignment: raise chars of shorter fonts to the line base + if (lineHeights) { - var kerningOffset = glyph.kerning[lastCharCode]; - - x += (kerningOffset !== undefined) ? kerningOffset : 0; + y += lineBases[currentLine] - item.base * item.rel; } if (bx > x) @@ -256,8 +378,8 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) by = y; } - var gw = x + glyph.xAdvance; - var gh = y + lineHeight; + var gw = x + glyph.xAdvance * item.rel; + var gh = y + (lineHeights ? lineHeights[currentLine] : lineHeight); if (bw < gw) { @@ -269,9 +391,7 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) bh = gh; } - var charWidth = glyph.xOffset + glyph.xAdvance + ((kerningOffset !== undefined) ? kerningOffset : 0); - - if (charCode === wordWrapCharCode) + if (item.isSpace) { if (current !== null) { @@ -295,29 +415,34 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) current = { word: '', i: charIndex, x: xAdvance, y: yAdvance, w: 0, h: lineHeight }; } - current.word = current.word.concat(text[i]); - current.w += charWidth; + current.word = current.word.concat(item.char); + current.w += item.charWidth * item.rel; } - characters.push({ + var charEntry = { i: charIndex, - idx: i, - char: text[i], - code: charCode, - x: (glyph.xOffset + x) * scale, - y: (glyph.yOffset + yAdvance) * scale, - w: glyph.width * scale, - h: glyph.height * scale, + idx: item.idx, + char: item.char, + code: item.code, + x: (glyph.xOffset * item.rel + x) * scale, + y: (glyph.yOffset * item.rel + y) * scale, + w: glyph.width * item.rel * scale, + h: glyph.height * item.rel * scale, t: yAdvance * scale, r: gw * scale, - b: lineHeight * scale, + b: (lineHeights ? lineHeights[currentLine] : lineHeight) * scale, line: currentLine, glyph: glyph - }); + }; - xAdvance += glyph.xAdvance + letterSpacing + ((kerningOffset !== undefined) ? kerningOffset : 0); - lastGlyph = glyph; - lastCharCode = charCode; + if (item.style !== null) + { + charEntry.style = item.style; + } + + characters.push(charEntry); + + xAdvance += item.advance * item.rel; currentLineWidth = gw * scale; charIndex++; } @@ -373,7 +498,7 @@ var GetBitmapTextSize = function (src, round, updateOrigin, out) var local = out.local; var global = out.global; - + lines = out.lines; local.x = bx * scale; diff --git a/src/gameobjects/bitmaptext/ParseXMLBitmapFont.js b/src/gameobjects/bitmaptext/ParseXMLBitmapFont.js index 59443c99f8..20d5fd37ae 100644 --- a/src/gameobjects/bitmaptext/ParseXMLBitmapFont.js +++ b/src/gameobjects/bitmaptext/ParseXMLBitmapFont.js @@ -54,6 +54,8 @@ var ParseXMLBitmapFont = function (xml, frame, xSpacing, ySpacing, texture) data.font = info.getAttribute('face'); data.size = getValue(info, 'size'); data.lineHeight = getValue(common, 'lineHeight') + ySpacing; + data.base = getValue(common, 'base'); + if (isNaN(data.base)) { data.base = data.lineHeight - ySpacing; } data.chars = {}; var letters = xml.getElementsByTagName('char'); diff --git a/src/gameobjects/bitmaptext/RichTextParser.js b/src/gameobjects/bitmaptext/RichTextParser.js new file mode 100644 index 0000000000..92ad0ad9db --- /dev/null +++ b/src/gameobjects/bitmaptext/RichTextParser.js @@ -0,0 +1,111 @@ +/** + * Parses `[style]...[/style]` markup into rich-text segments. + * `[[` and `]]` are escapes for literal brackets. + * + * @function Phaser.GameObjects.BitmapText.ParseRichText + * @since 4.3.0 + * + * @param {string} text - The markup string to parse. + * + * @return {Phaser.Types.GameObjects.BitmapText.Segment[]} The parsed segments. + */ +var ParseRichText = function (text) +{ + var segments = []; + var currentStyle; + var currentText = ''; + var currentTag = ''; + var tagStartIndex = 0; + var isParsingTag = false; + + var flush = function () + { + if (currentText === '') { return; } + + var last = segments[segments.length - 1]; + + if (last !== undefined && last.style === currentStyle) + { + last.text += currentText; + } + else + { + var segment = { text: currentText }; + + if (currentStyle !== undefined) { segment.style = currentStyle; } + + segments.push(segment); + } + + currentText = ''; + }; + + for (var i = 0; i < text.length; i++) + { + var ch = text[i]; + + if (isParsingTag) + { + if (ch !== ']') + { + currentTag += ch; + + continue; + } + + isParsingTag = false; + + if (currentTag[0] === '/') + { + var closing = currentTag.substring(1); + + if (closing !== currentStyle) + { + console.warn('BitmapText rich text: unexpected closing tag [/' + closing + '] in "' + text + '"'); + } + + currentStyle = undefined; + } + else + { + currentStyle = currentTag; + } + + continue; + } + + if (ch === '[' && text[i + 1] !== '[') + { + flush(); + + isParsingTag = true; + currentTag = ''; + tagStartIndex = i; + + continue; + } + + if (ch === ']' && text[i + 1] !== ']') + { + console.warn('BitmapText rich text: unmatched "]" at index ' + i + ' in "' + text + '"'); + + continue; + } + + // Escaped bracket: skip the twin, keep one literal + if (ch === '[' || ch === ']') { i++; } + + currentText += ch; + } + + if (isParsingTag) + { + console.warn('BitmapText rich text: unclosed bracket at index ' + tagStartIndex + ' in "' + text + '"'); + } + + flush(); + + return segments; +}; + +module.exports = ParseRichText; diff --git a/src/gameobjects/bitmaptext/static/BitmapText.js b/src/gameobjects/bitmaptext/static/BitmapText.js index 59ca8a5947..0c6ea6aa47 100644 --- a/src/gameobjects/bitmaptext/static/BitmapText.js +++ b/src/gameobjects/bitmaptext/static/BitmapText.js @@ -12,6 +12,7 @@ var GameObject = require('../../GameObject'); var GetBitmapTextSize = require('../GetBitmapTextSize'); var ParseFromAtlas = require('../ParseFromAtlas'); var ParseXMLBitmapFont = require('../ParseXMLBitmapFont'); +var ParseRichText = require('../RichTextParser'); var Rectangle = require('../../../geom/rectangle/Rectangle'); var Render = require('./BitmapTextRender'); var TintModes = require('../../../renderer/TintModes'); @@ -135,6 +136,37 @@ var BitmapText = new Class({ */ this._text = ''; + /** + * Named text styles from `addTextStyle`, resolved per font key. + * + * @name Phaser.GameObjects.BitmapText#_styles + * @type {Object.} + * @private + * @since 4.3.0 + */ + this._styles = {}; + + /** + * The rich-text segments of this Bitmap Text, or `null` for plain text. + * + * @name Phaser.GameObjects.BitmapText#_segments + * @type {?Phaser.Types.GameObjects.BitmapText.Segment[]} + * @private + * @since 4.3.0 + */ + this._segments = null; + + /** + * The resolved style for each character of the flattened text. + * `null` for plain text. + * + * @name Phaser.GameObjects.BitmapText#_styleByIndex + * @type {?Array.} + * @private + * @since 4.3.0 + */ + this._styleByIndex = null; + /** * The font size of this Bitmap Text. * @@ -438,6 +470,52 @@ var BitmapText = new Class({ return this; }, + /** + * Registers a named text style: a bitmap font plus optional size and color. + * Segments passed to `setRichText` reference it by name. + * + * @method Phaser.GameObjects.BitmapText#addTextStyle + * @since 4.3.0 + * + * @param {string} name - The style name. + * @param {Phaser.Types.GameObjects.BitmapText.TextStyleConfig} config - The style configuration. + * + * @return {this} This BitmapText Object. + */ + addTextStyle: function (name, config) + { + var fontData; + var frame; + + if (config.font !== undefined) + { + var fontEntry = this.scene.sys.cache.bitmapFont.get(config.font); + + if (!fontEntry) + { + console.warn('Invalid BitmapText style key: ' + config.font); + + return this; + } + + fontData = fontEntry.data; + frame = this.scene.sys.textures.getFrame(fontEntry.texture, fontEntry.frame); + } + + var color = config.color; + + if (color === undefined) { color = 0xffffff; } + + this._styles[name] = { + fontData: fontData, + frame: frame, + size: config.size, + color: color + }; + + return this; + }, + /** * Set the textual content of this BitmapText. * @@ -466,6 +544,114 @@ var BitmapText = new Class({ { this._text = value.toString(); + this._segments = null; + this._styleByIndex = null; + + this._dirty = true; + + this.updateDisplayOrigin(); + } + + return this; + }, + + /** + * Sets rich text, either from markup string with `[style]...[/style]` + * tags, or from explicit segments. + * + * @method Phaser.GameObjects.BitmapText#setRichText + * @since 4.3.0 + * + * @param {(string|Phaser.Types.GameObjects.BitmapText.Segment[])} richText - Markup string or segments to set. + * + * @return {this} This BitmapText Object. + */ + setRichText: function (richText) + { + var segments = richText; + if (typeof richText === 'string') + { + segments = ParseRichText(richText); + } + + var defaultStyle = { fontData: this.fontData, frame: this.frame, size: undefined, color: 0xffffff }; + var text = ''; + var styleByIndex = []; + + for (var i = 0; i < segments.length; i++) + { + var segment = segments[i]; + + if (typeof segment.text !== 'string') + { + continue; + } + + var style = defaultStyle; + + if (segment.style !== undefined) + { + var namedStyle = this._styles[segment.style]; + + if (namedStyle === undefined) + { + console.warn('Unknown BitmapText style: ' + segment.style); + } + else + { + style = namedStyle; + } + } + + var fontData = style.fontData; + var frame = style.frame; + var size = style.size; + var color = style.color; + + if (segment.font !== undefined) + { + var fontEntry = this.scene.sys.cache.bitmapFont.get(segment.font); + + if (!fontEntry) + { + console.warn('Invalid BitmapText font key: ' + segment.font); + } + else + { + fontData = fontEntry.data; + frame = this.scene.sys.textures.getFrame(fontEntry.texture, fontEntry.frame); + } + } + + // A style without its own font inherits the BitmapText's font + if (fontData === undefined) + { + fontData = this.fontData; + frame = this.frame; + } + + if (segment.size !== undefined) { size = segment.size; } + if (segment.color !== undefined) { color = segment.color; } + + if (fontData !== style.fontData || size !== style.size || color !== style.color) + { + style = { fontData: fontData, frame: frame, size: size, color: color }; + } + + for (var j = 0; j < segment.text.length; j++) + { + styleByIndex.push(style); + } + + text += segment.text; + } + + if (text !== this.text || segments !== this._segments) + { + this._text = text; + this._segments = segments; + this._styleByIndex = styleByIndex; + this._dirty = true; this.updateDisplayOrigin(); diff --git a/src/gameobjects/bitmaptext/static/BitmapTextCanvasRenderer.js b/src/gameobjects/bitmaptext/static/BitmapTextCanvasRenderer.js index f5909a36b2..d2498051d8 100644 --- a/src/gameobjects/bitmaptext/static/BitmapTextCanvasRenderer.js +++ b/src/gameobjects/bitmaptext/static/BitmapTextCanvasRenderer.js @@ -6,6 +6,8 @@ var SetTransform = require('../../../renderer/canvas/utils/SetTransform'); +var _warned = false; + /** * Renders this Game Object with the Canvas Renderer to the given Camera. * The object will not render if any of its renderFlags are set or it is being actively filtered out by the Camera. @@ -34,6 +36,13 @@ var BitmapTextCanvasRenderer = function (renderer, src, camera, parentMatrix) camera.addToRenderList(src); + if (src._segments && !_warned) + { + _warned = true; + + console.warn('BitmapText rich-text segments are not supported by the Canvas renderer, rendering as flat text'); + } + var textureFrame = src.fromAtlas ? src.frame : src.texture.frames['__BASE']; diff --git a/src/gameobjects/bitmaptext/static/BitmapTextWebGLRenderer.js b/src/gameobjects/bitmaptext/static/BitmapTextWebGLRenderer.js index b6291cdab5..864063f38a 100644 --- a/src/gameobjects/bitmaptext/static/BitmapTextWebGLRenderer.js +++ b/src/gameobjects/bitmaptext/static/BitmapTextWebGLRenderer.js @@ -38,6 +38,14 @@ var tempTintData2 = { * @param {Phaser.Renderer.WebGL.DrawingContext} drawingContext - The current drawing context. * @param {Phaser.GameObjects.Components.TransformMatrix} parentMatrix - This transform matrix is defined if the game object is nested */ +var multiplyTint = function (a, b) +{ + var ar = (a >> 16) & 0xff, ag = (a >> 8) & 0xff, ab = a & 0xff; + var br = (b >> 16) & 0xff, bg = (b >> 8) & 0xff, bb = b & 0xff; + + return (((ar * br / 255) | 0) << 16) | (((ag * bg / 255) | 0) << 8) | ((ab * bb / 255) | 0); +}; + var BitmapTextWebGLRenderer = function (renderer, src, drawingContext, parentMatrix) { var text = src._text; @@ -128,7 +136,23 @@ var BitmapTextWebGLRenderer = function (renderer, src, drawingContext, parentMat } else { - BatchChar(drawingContext, submitterNode, src, char, glyph, 0, 0, calcMatrix, tempTintData1); + var styleColor = char.style ? char.style.color : 0xffffff; + + // White (0xffffff) is the identity, so plain text is unchanged + if (styleColor === 0xffffff) + { + BatchChar(drawingContext, submitterNode, src, char, glyph, 0, 0, calcMatrix, tempTintData1); + } + else + { + tempTintData2.tintEffect = src.tintMode; + tempTintData2.tintTopLeft = getTint(multiplyTint(src.tintTopLeft, styleColor), src._alphaTL); + tempTintData2.tintTopRight = getTint(multiplyTint(src.tintTopRight, styleColor), src._alphaTR); + tempTintData2.tintBottomLeft = getTint(multiplyTint(src.tintBottomLeft, styleColor), src._alphaBL); + tempTintData2.tintBottomRight = getTint(multiplyTint(src.tintBottomRight, styleColor), src._alphaBR); + + BatchChar(drawingContext, submitterNode, src, char, glyph, 0, 0, calcMatrix, tempTintData2); + } } } }; diff --git a/src/gameobjects/bitmaptext/typedefs/BitmapFontData.js b/src/gameobjects/bitmaptext/typedefs/BitmapFontData.js index 33ad5d94fc..4cf819274f 100644 --- a/src/gameobjects/bitmaptext/typedefs/BitmapFontData.js +++ b/src/gameobjects/bitmaptext/typedefs/BitmapFontData.js @@ -7,6 +7,7 @@ * @property {string} font - The name of the font. * @property {number} size - The size of the font. * @property {number} lineHeight - The line height of the font. + * @property {number} base - The baseline offset of the font. * @property {boolean} retroFont - Whether this font is a retro font (monospace). * @property {Object.} chars - The character data of the font, keyed by character code. Each character datum includes a position, size, offset and more. */ diff --git a/src/gameobjects/bitmaptext/typedefs/BitmapTextSegment.js b/src/gameobjects/bitmaptext/typedefs/BitmapTextSegment.js new file mode 100644 index 0000000000..2c1e580dfe --- /dev/null +++ b/src/gameobjects/bitmaptext/typedefs/BitmapTextSegment.js @@ -0,0 +1,12 @@ +/** + * A run of characters sharing one style, used for rich text on a `BitmapText`. + * + * @typedef {object} Phaser.Types.GameObjects.BitmapText.Segment + * @since 4.3.0 + * + * @property {string} text - The segment's text. May contain newlines. + * @property {string} [style] - The name of a style registered via `addTextStyle`. + * @property {string} [font] - The key of a Bitmap Font in the cache. Overrides the style's font. + * @property {number} [size] - Font size for this run. Overrides the style's size. + * @property {number} [color] - Tint color for this run (0xRRGGBB). Overrides the style's color. + */ diff --git a/src/gameobjects/bitmaptext/typedefs/BitmapTextStyle.js b/src/gameobjects/bitmaptext/typedefs/BitmapTextStyle.js new file mode 100644 index 0000000000..6e7722ee4f --- /dev/null +++ b/src/gameobjects/bitmaptext/typedefs/BitmapTextStyle.js @@ -0,0 +1,12 @@ +/** + * A resolved text style: the font data, tint color, texture frame and + * size shared by all characters of a segment. + * + * @typedef {object} Phaser.Types.GameObjects.BitmapText.Style + * @since 4.3.0 + * + * @property {Phaser.Types.GameObjects.BitmapText.BitmapFontData} [fontData] - The font data of the style's bitmap font; `undefined` inherits the BitmapText's font. + * @property {Phaser.Textures.Frame} [frame] - The resolved texture frame of the style's font; `undefined` inherits the BitmapText's font. + * @property {number} [size] - Font size overriding the BitmapText's `fontSize`. + * @property {number} color - Text color (0xRRGGBB). + */ diff --git a/src/gameobjects/bitmaptext/typedefs/BitmapTextStyleConfig.js b/src/gameobjects/bitmaptext/typedefs/BitmapTextStyleConfig.js new file mode 100644 index 0000000000..88a7021b4f --- /dev/null +++ b/src/gameobjects/bitmaptext/typedefs/BitmapTextStyleConfig.js @@ -0,0 +1,10 @@ +/** + * A text style configuration object as used by `BitmapText#addTextStyle`. + * + * @typedef {object} Phaser.Types.GameObjects.BitmapText.TextStyleConfig + * @since 4.3.0 + * + * @property {string} [font] - The key of the Bitmap Font in the cache. Omit to inherit the BitmapText's font. + * @property {number} [size] - Font size for runs using this style. Omit to inherit the BitmapText's `fontSize`. + * @property {number} [color=0xffffff] - Tint color for runs using this style (0xRRGGBB). + */ diff --git a/tests/gameobjects/bitmaptext/GetBitmapTextSize.test.js b/tests/gameobjects/bitmaptext/GetBitmapTextSize.test.js index 670bee97d8..42282966d7 100644 --- a/tests/gameobjects/bitmaptext/GetBitmapTextSize.test.js +++ b/tests/gameobjects/bitmaptext/GetBitmapTextSize.test.js @@ -854,6 +854,26 @@ describe('GetBitmapTextSize', function () GetBitmapTextSize(src, false, false, out); expect(out.wrappedText).toBe('AB\nCD'); + + expect(out.characters.length).toBe(4); + + // 'C' landed on line 1, x reset to the line start, + // y advanced by one lineHeight. + expect(out.characters[2].char).toBe('C'); + expect(out.characters[2].line).toBe(1); + expect(out.characters[2].x).toBe(0); + expect(out.characters[2].y).toBe(16); + + // Line metrics reflect two equal wrapped lines. + expect(out.lines.lengths).toEqual([ 20, 20 ]); + expect(out.lines.longest).toBe(20); + expect(out.lines.shortest).toBe(20); + + // Word splitting survived the wrap: two words, the second on line 1. + expect(out.words.length).toBe(2); + expect(out.words[0].word).toBe('AB'); + expect(out.words[1].word).toBe('CD'); + expect(out.words[1].y).toBe(16); }); it('should not set wrappedText when text fits within maxWidth', function () diff --git a/tests/gameobjects/bitmaptext/RichTextParser.test.js b/tests/gameobjects/bitmaptext/RichTextParser.test.js new file mode 100644 index 0000000000..fe17df4c82 --- /dev/null +++ b/tests/gameobjects/bitmaptext/RichTextParser.test.js @@ -0,0 +1,137 @@ +var ParseRichText = require('../../../src/gameobjects/bitmaptext/RichTextParser'); + +describe('RichTextParser', function () +{ + test('plain text without tags is a single unstyled segment', function () + { + expect(ParseRichText('Hello world')).toEqual([ + { text: 'Hello world' } + ]); + }); + + test('a styled run between plain runs', function () + { + expect(ParseRichText('a[x]b[/x]c')).toEqual([ + { text: 'a' }, + { text: 'b', style: 'x' }, + { text: 'c' } + ]); + }); + + test('an unclosed tag styles the rest of the text', function () + { + expect(ParseRichText('a[x]bc')).toEqual([ + { text: 'a' }, + { text: 'bc', style: 'x' } + ]); + }); + + test('a mismatched closing tag warns and resets the style', function () + { + var warn = vi.spyOn(console, 'warn').mockImplementation(function () {}); + + expect(ParseRichText('[x]a[/y]b')).toEqual([ + { text: 'a', style: 'x' }, + { text: 'b' } + ]); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[x]a[/y]b')); + + warn.mockRestore(); + }); + + test('an unmatched closing tag is ignored, with a warning', function () + { + var warn = vi.spyOn(console, 'warn').mockImplementation(function () {}); + + expect(ParseRichText('a[/x]b')).toEqual([ + { text: 'ab' } + ]); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('a[/x]b')); + + warn.mockRestore(); + }); + + test('adjacent styled runs', function () + { + expect(ParseRichText('a[x]b[/x][y]c[/y]d')).toEqual([ + { text: 'a' }, + { text: 'b', style: 'x' }, + { text: 'c', style: 'y' }, + { text: 'd' } + ]); + }); + + test('a bracket without a closing bracket warns and drops the rest', function () + { + var warn = vi.spyOn(console, 'warn').mockImplementation(function () {}); + + expect(ParseRichText('a[b')).toEqual([ + { text: 'a' } + ]); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('a[b')); + + warn.mockRestore(); + }); + + test('a bracket as the very last character warns and is dropped', function () + { + var warn = vi.spyOn(console, 'warn').mockImplementation(function () {}); + + expect(ParseRichText('ab[')).toEqual([ + { text: 'ab' } + ]); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('ab[')); + + warn.mockRestore(); + }); + + test('empty runs are not emitted', function () + { + expect(ParseRichText('[x][/x]')).toEqual([]); + expect(ParseRichText('')).toEqual([]); + }); + + test('escaped open bracket is a literal bracket', function () + { + expect(ParseRichText('a[[b')).toEqual([ + { text: 'a[b' } + ]); + }); + + test('escaped close bracket is a literal bracket', function () + { + expect(ParseRichText('a]]b')).toEqual([ + { text: 'a]b' } + ]); + }); + + test('a stray close bracket warns and is dropped', function () + { + var warn = vi.spyOn(console, 'warn').mockImplementation(function () {}); + + expect(ParseRichText('a]b')).toEqual([ + { text: 'ab' } + ]); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('a]b')); + + warn.mockRestore(); + }); + + test('newlines pass through unchanged', function () + { + expect(ParseRichText('a\n[x]b\nc[/x]')).toEqual([ + { text: 'a\n' }, + { text: 'b\nc', style: 'x' } + ]); + }); +}); diff --git a/visual-tests/.gitignore b/visual-tests/.gitignore new file mode 100644 index 0000000000..dbd64df830 --- /dev/null +++ b/visual-tests/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/visual-tests/bitmap-text/bitmap-text-canvas.png b/visual-tests/bitmap-text/bitmap-text-canvas.png new file mode 100644 index 0000000000..866e3168d6 Binary files /dev/null and b/visual-tests/bitmap-text/bitmap-text-canvas.png differ diff --git a/visual-tests/bitmap-text/bitmap-text-webgl.png b/visual-tests/bitmap-text/bitmap-text-webgl.png new file mode 100644 index 0000000000..35ec5e97f3 Binary files /dev/null and b/visual-tests/bitmap-text/bitmap-text-webgl.png differ diff --git a/visual-tests/bitmap-text/bitmap-text.spec.js b/visual-tests/bitmap-text/bitmap-text.spec.js new file mode 100644 index 0000000000..bcdeb11711 --- /dev/null +++ b/visual-tests/bitmap-text/bitmap-text.spec.js @@ -0,0 +1,19 @@ +var pw = require('@playwright/test'); +var test = pw.test; +var expect = pw.expect; + +var renderers = ['webgl', 'canvas']; + +renderers.forEach(function (renderer) +{ + test('bitmap-text (' + renderer + ')', async function ({ page }) + { + await page.goto('http://localhost:8080/visual-tests/bitmap-text/index.html?renderer=' + renderer); + + var canvas = page.locator('#game canvas'); + await canvas.waitFor({ state: 'visible', timeout: 15000 }); + await page.waitForFunction(function () { return window.__READY__; }); + + await expect(canvas).toHaveScreenshot('bitmap-text-' + renderer + '.png'); + }); +}); diff --git a/visual-tests/bitmap-text/clarendon.png b/visual-tests/bitmap-text/clarendon.png new file mode 100644 index 0000000000..6def8a8627 Binary files /dev/null and b/visual-tests/bitmap-text/clarendon.png differ diff --git a/visual-tests/bitmap-text/clarendon.xml b/visual-tests/bitmap-text/clarendon.xml new file mode 100644 index 0000000000..c2d1ea48c4 --- /dev/null +++ b/visual-tests/bitmap-text/clarendon.xml @@ -0,0 +1,545 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/visual-tests/bitmap-text/index.html b/visual-tests/bitmap-text/index.html new file mode 100644 index 0000000000..b747a8323f --- /dev/null +++ b/visual-tests/bitmap-text/index.html @@ -0,0 +1,115 @@ + + + + +bitmap-text + + + +
+ + + + diff --git a/visual-tests/bitmap-text/lato.xml b/visual-tests/bitmap-text/lato.xml new file mode 100644 index 0000000000..452a1ffaf7 --- /dev/null +++ b/visual-tests/bitmap-text/lato.xml @@ -0,0 +1,750 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/visual-tests/bitmap-text/lato_0.png b/visual-tests/bitmap-text/lato_0.png new file mode 100644 index 0000000000..35bae1fd72 Binary files /dev/null and b/visual-tests/bitmap-text/lato_0.png differ diff --git a/visual-tests/package-lock.json b/visual-tests/package-lock.json new file mode 100644 index 0000000000..4387fcf461 --- /dev/null +++ b/visual-tests/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "phaser-visual-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phaser-visual-tests", + "devDependencies": { + "@playwright/test": "^1.49.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/visual-tests/package.json b/visual-tests/package.json new file mode 100644 index 0000000000..53d3e98b65 --- /dev/null +++ b/visual-tests/package.json @@ -0,0 +1,13 @@ +{ + "name": "phaser-visual-tests", + "private": true, + "description": "Self-contained Playwright visual-regression tests for Phaser. Not published.", + "scripts": { + "visual-test": "playwright test", + "visual-test:update": "playwright test --update-snapshots", + "setup": "playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "^1.61.1" + } +} diff --git a/visual-tests/playwright.config.js b/visual-tests/playwright.config.js new file mode 100644 index 0000000000..6c41122f80 --- /dev/null +++ b/visual-tests/playwright.config.js @@ -0,0 +1,32 @@ +var pw = require('@playwright/test'); + +module.exports = pw.defineConfig({ + testDir: '.', + snapshotPathTemplate: '{testDir}/{testFileDir}/{arg}{ext}', + retries: 0, + workers: 1, + projects: [ + { + name: 'chromium', + use: { + ...pw.devices['Desktop Chrome'], + viewport: { width: 1920, height: 1080 }, + deviceScaleFactor: 1, + launchOptions: { + args: ['--use-gl=angle'] + } + } + } + ], + webServer: { + command: 'node serve.js', + port: 8080, + timeout: 60000, + reuseExistingServer: !process.env.CI + }, + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0 + } + } +}); diff --git a/visual-tests/serve.js b/visual-tests/serve.js new file mode 100644 index 0000000000..fcdc238431 --- /dev/null +++ b/visual-tests/serve.js @@ -0,0 +1,47 @@ +var http = require('http'); +var fs = require('fs'); +var path = require('path'); + +var ROOT = path.resolve(__dirname, '..'); +var PORT = 8080; +var SEP = path.sep; + +var TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.xml': 'text/xml; charset=utf-8', + '.png': 'image/png', + '.json': 'application/json; charset=utf-8' +}; + +function serve (req, res) +{ + var urlPath = decodeURIComponent((req.url || '/').split('?')[0]); + var filePath = path.resolve(ROOT, '.' + urlPath); + + // Keep served files inside the repo root. + if (filePath !== ROOT && filePath.indexOf(ROOT + SEP) !== 0) + { + res.statusCode = 403; + res.end('forbidden'); + return; + } + + fs.readFile(filePath, function (err, data) + { + if (err) + { + res.statusCode = 404; + res.end('not found: ' + urlPath); + return; + } + + res.setHeader('Content-Type', TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'); + res.end(data); + }); +} + +http.createServer(serve).listen(PORT, function () +{ + console.log('visual-tests server on http://localhost:' + PORT + ' (root: ' + ROOT + ')'); +});