pxt-common-packages
Version:
Microsoft MakeCode (PXT) common packages
365 lines (307 loc) • 12.8 kB
text/typescript
namespace sprites {
export class RenderText {
linebreaks: number[];
font: image.Font;
height: number;
width: number;
constructor(public text: string, maxWidth: number) {
this.font = image.getFontForText(text);
this.setMaxWidth(maxWidth);
}
draw(canvas: Image, left: number, top: number, color: number, lineStart?: number, lineEnd?: number) {
if (lineStart === undefined) lineStart = 0;
if (lineEnd === undefined) lineEnd = this.linebreaks.length + 1;
for (let i = lineStart; i < lineEnd; i++) {
this.drawLine(canvas, left, top, i, color);
top += this.font.charHeight;
}
}
drawLine(canvas: Image, left: number, top: number, lineIndex: number, color: number) {
const start = this.lineStart(lineIndex);
const end = this.lineEnd(lineIndex);
for (let i = start; i < end; i++) {
canvas.print(this.text.charAt(i), left, top, color, this.font);
left += this.font.charWidth;
}
}
drawPartial(canvas: Image, left: number, top: number, color: number, lengthToDraw: number, lineStart?: number, lineEnd?: number) {
if (lineStart === undefined) lineStart = 0;
if (lineEnd === undefined) lineEnd = this.linebreaks.length + 1;
let currentTextIndex = 0;
for (let i = lineStart; i < lineEnd; i++) {
currentTextIndex = this.drawPartialLine(canvas, left, top, i, color, currentTextIndex, lengthToDraw);
top += this.font.charHeight;
if (currentTextIndex >= lengthToDraw) return false;
}
return true;
}
drawPartialLine(canvas: Image, left: number, top: number, lineIndex: number, color: number, currentTextIndex: number, lengthToDraw: number) {
const start = this.lineStart(lineIndex);
const end = this.lineEnd(lineIndex);
for (let i = start; i < end; i++) {
canvas.print(this.text.charAt(i), left, top, color, this.font);
left += this.font.charWidth;
if (currentTextIndex + (i - start) >= lengthToDraw) {
return lengthToDraw;
}
}
return currentTextIndex + end - start;
}
calculatePartialHeight(startLine: number, lengthToDraw: number) {
if (this.linebreaks.length === 0) return this.font.charHeight;
let current = 0;
for (let i = startLine; i < this.linebreaks.length + 1; i++) {
current += this.lineEnd(i) - this.lineStart(i);
if (current > lengthToDraw) return (i - startLine + 1) * this.font.charHeight
}
return this.height;
}
lineHeight() {
return this.font.charHeight;
}
setMaxWidth(maxWidth: number) {
this.linebreaks = getLineBreaks(this.text, [Math.idiv(maxWidth, this.font.charWidth)]);
this.height = (this.linebreaks.length + 1) * this.font.charHeight;
this.width = 0;
for (let i = 0; i < this.linebreaks.length + 1; i++) {
this.width = Math.max(this.lineEnd(i) - this.lineStart(i), this.width);
}
this.width *= this.font.charWidth;
}
printableCharacters() {
let total = 0;
for (let i = 0; i < this.linebreaks.length + 1; i++) {
total += this.lineEnd(i) - this.lineStart(i);
}
return total;
}
lineEnd(lineIndex: number) {
const prevEnd = lineIndex > 0 ? this.linebreaks[lineIndex - 1] : 0;
let end = lineIndex < this.linebreaks.length ? this.linebreaks[lineIndex] : this.text.length;
let didMove = false;
// Trim trailing whitespace
while (end > prevEnd) {
if (this.text.charCodeAt(end) <= 32) {
end--;
didMove = true
}
else if (this.text.charAt(end) === "n" && this.text.charAt(end - 1) === "\\" && end - 1 > prevEnd) {
end -= 2;
didMove = true
}
else {
break;
}
}
return didMove ? end + 1 : end;
}
lineStart(lineIndex: number) {
let start = lineIndex > 0 ? this.linebreaks[lineIndex - 1] : 0;
// Trim leading whitespace
while (start < this.text.length) {
if (this.text.charCodeAt(start) <= 32) {
start ++;
}
else if (this.text.charAt(start) === "\\" && this.text.charAt(start + 1) === "n" && start + 1 < this.text.length) {
start += 2;
}
else {
break;
}
}
return start;
}
widthOfLine(lineIndex: number, fullTextOffset?: number) {
if (fullTextOffset != undefined) {
return (Math.min(this.lineEnd(lineIndex), fullTextOffset + 1) - this.lineStart(lineIndex)) * this.font.charWidth;
}
return (this.lineEnd(lineIndex) - this.lineStart(lineIndex)) * this.font.charWidth;
}
widthOfLines(lineStartIndex: number, lineEndIndex: number, offset?: number) {
if (this.linebreaks.length === 0) return this.widthOfLine(0, offset);
let width = 0;
let fullTextOffset: number;
for (let i = lineStartIndex; i < Math.min(lineEndIndex, this.linebreaks.length + 1); i++) {
if (offset != undefined) {
fullTextOffset = this.lineStart(i) + offset;
offset -= this.lineEnd(i) - this.lineStart(i);
}
if (fullTextOffset !== undefined && this.lineStart(i) > fullTextOffset) break;
width = Math.max(width, this.widthOfLine(i, fullTextOffset));
}
return width;
}
}
function isBreakCharacter(charCode: number) {
return charCode <= 32 ||
(charCode >= 58 && charCode <= 64) ||
(charCode >= 91 && charCode <= 96) ||
(charCode >= 123 && charCode <= 126);
}
function getLineBreaks(text: string, lineLengths: number[]): number[] {
const result: number[] = [];
let lastBreakLocation = 0;
let lastBreak = 0;
let line = 0;
let lineLength = lineLengths[line];
function nextLine() {
line++;
lineLength = lineLengths[line % lineLengths.length];
}
for (let index = 0; index < text.length; index++) {
if (text.charAt(index) === "\n") {
result.push(index);
index++;
lastBreak = index;
nextLine();
}
// Handle \\n in addition to \n because that's how it gets converted from blocks
else if (text.charAt(index) === "\\" && text.charAt(index + 1) === "n") {
result.push(index);
lastBreak = index;
index += 2;
nextLine();
}
else if (isBreakCharacter(text.charCodeAt(index))) {
lastBreakLocation = index;
}
if (index - lastBreak === lineLength) {
if (lastBreakLocation === index || lastBreakLocation <= lastBreak) {
result.push(index);
lastBreak = index;
nextLine();
}
else {
result.push(lastBreakLocation);
lastBreak = lastBreakLocation;
nextLine();
}
}
}
return result;
}
enum RenderTextAnimationState {
Idle,
Printing,
Pausing
}
export class RenderTextAnimation {
protected tickPeriod: number;
protected state: RenderTextAnimationState;
protected pageLine: number;
protected timer: number;
protected pauseMillis: number;
protected onTickCB: () => void;
protected onEndCB: () => void;
protected prevOffset: number;
constructor(public text: RenderText, public height: number) {
this.state = RenderTextAnimationState.Idle;
this.timer = -1;
this.pageLine = 0;
this.setPauseLength(1000);
this.setTextSpeed(30);
}
start() {
this.state = RenderTextAnimationState.Printing;
this.timer = control.millis();
}
numPages() {
const maxLinesPerPage = Math.idiv(this.height, this.text.lineHeight()) + 1;
return Math.floor((this.text.linebreaks.length + 1) / maxLinesPerPage);
}
setPauseLength(millis: number) {
this.pauseMillis = millis;
}
setTextSpeed(charactersPerSecond: number) {
this.tickPeriod = 1000/ charactersPerSecond;
}
currentHeight() {
const minHeight = this.text.lineHeight();
const maxHeight = Math.max(
Math.min(
Math.idiv(this.height, this.text.lineHeight()) + 1,
this.text.linebreaks.length + 1 - this.pageLine
) * this.text.lineHeight(),
minHeight
);
if (this.state === RenderTextAnimationState.Printing) {
return Math.max(Math.min(
this.text.calculatePartialHeight(this.pageLine, this.currentOffset()),
maxHeight
), minHeight)
}
else if (this.state === RenderTextAnimationState.Pausing) {
return maxHeight
}
else {
return 0;
}
}
currentWidth() {
return this.text.widthOfLines(
this.pageLine,
this.pageLine + Math.idiv(this.currentHeight(), this.text.lineHeight()) + 1,
this.state === RenderTextAnimationState.Printing ? this.currentOffset() : undefined
);
}
currentOffset() {
return Math.idiv(control.millis() - this.timer, this.tickPeriod)
}
isDone() {
return this.state === RenderTextAnimationState.Idle;
}
cancel() {
this.state = RenderTextAnimationState.Idle;
}
onCharacterPrinted(cb: () => void) {
this.onTickCB = cb;
}
onAnimationEnd(cb: () => void) {
this.onEndCB = cb;
}
draw(canvas: Image, left: number, top: number, color: number) {
if (this.state === RenderTextAnimationState.Idle) return;
else if (this.state === RenderTextAnimationState.Printing) {
const pageFinished = this.text.drawPartial(
canvas,
left,
top,
color,
this.currentOffset(),
this.pageLine,
this.pageLine + Math.idiv(this.height, this.text.lineHeight()) + 1
);
if (this.onTickCB && this.prevOffset !== this.currentOffset()) {
this.onTickCB();
}
if (pageFinished) {
this.state = RenderTextAnimationState.Pausing;
this.timer = this.pauseMillis
}
}
else {
this.text.draw(
canvas,
left,
top,
color,
this.pageLine,
this.pageLine + Math.idiv(this.height, this.text.lineHeight()) + 1
);
this.timer -= game.currentScene().eventContext.deltaTimeMillis;
if (this.timer < 0) {
this.pageLine += Math.idiv(this.height, this.text.lineHeight()) + 1;
if (this.pageLine > this.text.linebreaks.length) {
this.state = RenderTextAnimationState.Idle;
if (this.onEndCB) this.onEndCB();
}
else {
this.state = RenderTextAnimationState.Printing;
this.timer = control.millis();
}
}
}
this.prevOffset = this.currentOffset();
}
}
}