diff --git a/README.md b/README.md index c6ca042..bec0f06 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,8 @@ The font configuration JSON file informs Poryscript how many pixels wide each ch `cursorOverlapWidth` can be used to ensure there is always enough room for the cursor icon to be displayed in the text box. (This "cursor icon" is the small icon that's shown when the player needs to press A to advance the text box.) +`numLines` is the number of lines displayed within a single message box. If editing text for a taller space, this can be adjusted in `font_config.json`. + The length of a line can optionally be specified as the third parameter to `format()` if a font id was specified as the second parameter. ``` @@ -454,6 +456,17 @@ Becomes: .string "you!$" ``` +Finally, `format()` takes the following optional named parameters, which override settings from the font config: +- `fontId` +- `maxLineLength` +- `numLines` +- `cursorOverlapWidth` +``` +text MyText { + format("This is an example of named parameters!", numLines=3, maxLineLength=100) +} +``` + ### Custom Text Encoding When Poryscript compiles text, the resulting text content is rendered using the `.string` assembler directive. The decomp projects' build process then processes those `.string` directives and substituted the string characters with the game-specific text representation. It can be useful to specify different types of strings, though. For example, implementing print-debugging commands might make use of ASCII text. Poryscript allows you to specify which assembler directive to use for text. Simply add the directive as a prefix to the string content like this: ``` diff --git a/font_config.json b/font_config.json index 42640eb..5f2c6bf 100644 --- a/font_config.json +++ b/font_config.json @@ -3,6 +3,7 @@ "fonts": { "1_latin_rse": { "maxLineLength": 208, + "numLines": 2, "cursorOverlapWidth": 0, "widths": { " ": 3, @@ -177,6 +178,7 @@ }, "1_latin_frlg": { "maxLineLength": 208, + "numLines": 2, "cursorOverlapWidth": 10, "widths": { " ": 6, diff --git a/parser/formattext.go b/parser/formattext.go index 86a065e..27201c2 100644 --- a/parser/formattext.go +++ b/parser/formattext.go @@ -19,6 +19,7 @@ type Fonts struct { Widths map[string]int `json:"widths"` CursorOverlapWidth int `json:"cursorOverlapWidth"` MaxLineLength int `json:"maxLineLength"` + NumLines int `json:"numLines"` } // LoadFontConfig reads a font width config JSON file. @@ -40,7 +41,7 @@ const testFontID = "TEST" // FormatText automatically inserts line breaks into text // according to in-game text box widths. -func (fc *FontConfig) FormatText(text string, maxWidth int, cursorOverlapWidth int, fontID string) (string, error) { +func (fc *FontConfig) FormatText(text string, maxWidth int, cursorOverlapWidth int, fontID string, numLines int) (string, error) { if !fc.isFontIDValid(fontID) && len(fontID) > 0 && fontID != testFontID { validFontIDs := make([]string, len(fc.Fonts)) i := 0 @@ -56,7 +57,7 @@ func (fc *FontConfig) FormatText(text string, maxWidth int, cursorOverlapWidth i var formattedSb strings.Builder var curLineSb strings.Builder curWidth := 0 - isFirstLine := true + curLineNum := 0 isFirstWord := true spaceCharWidth := fc.getRunePixelWidth(' ', fontID) pos, word := fc.getNextWord(text) @@ -71,7 +72,7 @@ func (fc *FontConfig) FormatText(text string, maxWidth int, cursorOverlapWidth i curWidth = 0 formattedSb.WriteString(curLineSb.String()) if fc.isAutoLineBreak(word) { - if isFirstLine { + if fc.isFirstLine(curLineNum) { formattedSb.WriteString(`\n`) } else { formattedSb.WriteString(`\l`) @@ -81,9 +82,9 @@ func (fc *FontConfig) FormatText(text string, maxWidth int, cursorOverlapWidth i } formattedSb.WriteByte('\n') if fc.isParagraphBreak(word) { - isFirstLine = true + curLineNum = 0 } else { - isFirstLine = false + curLineNum++ } isFirstWord = true curLineSb.Reset() @@ -98,17 +99,18 @@ func (fc *FontConfig) FormatText(text string, maxWidth int, cursorOverlapWidth i // it could span multiple words. The true solution would require optimistically trying to fit all // remaining words onto the same line, rather than only looking at the current word + cursor. However, // this is "good enough" and likely works for almost all actual use cases in practice. - if len(nextWord) > 0 && (!isFirstLine || fc.isParagraphBreak(nextWord)) { + if len(nextWord) > 0 && (curLineNum >= numLines-1 || fc.isParagraphBreak(nextWord)) { nextWidth += cursorOverlapWidth } if nextWidth > maxWidth && curLineSb.Len() > 0 { formattedSb.WriteString(curLineSb.String()) - if isFirstLine { - formattedSb.WriteString(`\n`) - isFirstLine = false - } else { + if fc.shouldUseLineFeed(curLineNum, numLines) { formattedSb.WriteString(`\l`) + } else { + formattedSb.WriteString(`\n`) } + + curLineNum++ formattedSb.WriteByte('\n') isFirstWord = false curLineSb.Reset() @@ -133,6 +135,14 @@ func (fc *FontConfig) FormatText(text string, maxWidth int, cursorOverlapWidth i return formattedSb.String(), nil } +func (fc *FontConfig) isFirstLine(curLineNum int) bool { + return curLineNum == 0 +} + +func (fc *FontConfig) shouldUseLineFeed(curLineNum int, numLines int) bool { + return curLineNum >= numLines-1 +} + func (fc *FontConfig) getNextWord(text string) (int, string) { escape := false endPos := 0 diff --git a/parser/formattext_test.go b/parser/formattext_test.go index c9ac462..72a7b74 100644 --- a/parser/formattext_test.go +++ b/parser/formattext_test.go @@ -33,7 +33,7 @@ func TestFormatText(t *testing.T) { fc := FontConfig{} for i, tt := range tests { - result, _ := fc.FormatText(tt.inputText, tt.maxWidth, tt.cursorOverlapWidth, testFontID) + result, _ := fc.FormatText(tt.inputText, tt.maxWidth, tt.cursorOverlapWidth, testFontID, 2) if result != tt.expected { t.Errorf("FormatText Test %d: Expected '%s', but Got '%s'", i, tt.expected, result) } diff --git a/parser/parser.go b/parser/parser.go index 9b5bb4a..7b0ea12 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -1044,6 +1044,20 @@ func (p *Parser) parseMapscriptsStatement() (*ast.MapScriptsStatement, []impText return statement, implicitTexts, nil } +const ( + formatParamFontId = "fontId" + formatParamMaxLineLength = "maxLineLength" + formatParamNumLines = "numLines" + formatParamCursorOverlapWidth = "cursorOverlapWidth" +) + +var namedParameters = map[string]struct{}{ + formatParamFontId: {}, + formatParamMaxLineLength: {}, + formatParamNumLines: {}, + formatParamCursorOverlapWidth: {}, +} + func (p *Parser) parseFormatStringOperator() (token.Token, string, string, error) { if err := p.expectPeek(token.LPAREN); err != nil { return token.Token{}, "", "", NewRangeParseError(p.curToken, p.peekToken, "format operator must begin with an open parenthesis '('") @@ -1074,47 +1088,127 @@ func (p *Parser) parseFormatStringOperator() (token.Token, string, string, error } } - maxTextLength := p.maxLineLength + maxLineLength := p.maxLineLength + numLines := -1 + cursorOverlapWidth := -1 + specifiedParams := map[string]struct{}{} if p.peekTokenIs(token.COMMA) { p.nextToken() - if p.peekTokenIs(token.STRING) { - p.nextToken() - fontID = p.curToken.Literal - fontIdToken = p.curToken - if p.peekTokenIs(token.COMMA) { + // format()'s api is a mess... In the name of backwards compatibility, it supports specifying the font and/or max line length as + // unnamed parameters in either order. After those, a collection of named parameters are supported. + expectingNamedParam := true + hadParam := false + if p.peekTokenIs(token.INT) || p.peekTokenIs(token.STRING) { + // Handle the font id and max line length unnamed parameters. + hadParam = true + if p.peekTokenIs(token.STRING) { p.nextToken() - if err := p.expectPeek(token.INT); err != nil { - return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid format() maxLineLength '%s'. Expected integer", p.peekToken.Literal)) + fontID = p.curToken.Literal + fontIdToken = p.curToken + specifiedParams[formatParamFontId] = struct{}{} + if p.peekTokenIs(token.COMMA) && !p.peek2TokenIs(token.IDENT) { + p.nextToken() + if err := p.expectPeek(token.INT); err != nil { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid format() maxLineLength '%s'. Expected integer", p.peekToken.Literal)) + } + num, _ := strconv.ParseInt(p.curToken.Literal, 0, 64) + maxLineLength = int(num) } + } else { + p.nextToken() num, _ := strconv.ParseInt(p.curToken.Literal, 0, 64) - maxTextLength = int(num) + maxLineLength = int(num) + specifiedParams[formatParamMaxLineLength] = struct{}{} + if p.peekTokenIs(token.COMMA) && !p.peek2TokenIs(token.IDENT) { + p.nextToken() + if err := p.expectPeek(token.STRING); err != nil { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid format() fontId '%s'. Expected string", p.peekToken.Literal)) + } + fontID = p.curToken.Literal + fontIdToken = p.curToken + } } - } else if p.peekTokenIs(token.INT) { - p.nextToken() - num, _ := strconv.ParseInt(p.curToken.Literal, 0, 64) - maxTextLength = int(num) - if p.peekTokenIs(token.COMMA) { + expectingNamedParam = p.peekTokenIs(token.COMMA) + if expectingNamedParam { + p.nextToken() + } + } + + if expectingNamedParam { + // Now, handle named parameters + for p.peekTokenIs(token.IDENT) { + hadParam = true p.nextToken() - if err := p.expectPeek(token.STRING); err != nil { - return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid format() fontId '%s'. Expected string", p.peekToken.Literal)) + if _, ok := namedParameters[p.curToken.Literal]; !ok { + return token.Token{}, "", "", NewParseError(p.curToken, fmt.Sprintf("invalid format() named parameter '%s'", p.curToken.Literal)) + } + paramToken := p.curToken + paramName := p.curToken.Literal + if err := p.expectPeek(token.ASSIGN); err != nil { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("missing '=' after format() named parameter '%s'", paramName)) + } + if _, ok := specifiedParams[paramName]; ok { + return token.Token{}, "", "", NewParseError(paramToken, fmt.Sprintf("duplicate parameter '%s'", paramName)) + } + + specifiedParams[paramName] = struct{}{} + switch paramName { + case formatParamFontId: + if err := p.expectPeek(token.STRING); err != nil { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid %s '%s'. Expected string", formatParamFontId, p.peekToken.Literal)) + } + fontID = p.curToken.Literal + fontIdToken = p.curToken + case formatParamMaxLineLength: + if err := p.expectPeek(token.INT); err != nil { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid %s '%s'. Expected integer", formatParamMaxLineLength, p.peekToken.Literal)) + } + num, _ := strconv.ParseInt(p.curToken.Literal, 0, 64) + maxLineLength = int(num) + case formatParamNumLines: + if err := p.expectPeek(token.INT); err != nil { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid %s '%s'. Expected integer", formatParamNumLines, p.peekToken.Literal)) + } + num, _ := strconv.ParseInt(p.curToken.Literal, 0, 64) + numLines = int(num) + case formatParamCursorOverlapWidth: + if err := p.expectPeek(token.INT); err != nil { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid %s '%s'. Expected integer", formatParamCursorOverlapWidth, p.peekToken.Literal)) + } + num, _ := strconv.ParseInt(p.curToken.Literal, 0, 64) + cursorOverlapWidth = int(num) + } + + if p.peekTokenIs(token.COMMA) { + p.nextToken() + if !(p.peekTokenIs(token.IDENT) || p.peekTokenIs(token.RPAREN)) { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid parameter '%s'. Expected named parameter", p.peekToken.Literal)) + } } - fontID = p.curToken.Literal - fontIdToken = p.curToken } - } else { - return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid format() parameter '%s'. Expected either fontId (string) or maxLineLength (integer)", p.peekToken.Literal)) + } + + if !hadParam { + return token.Token{}, "", "", NewParseError(p.peekToken, fmt.Sprintf("invalid format() parameter '%s'", p.peekToken.Literal)) } } if err := p.expectPeek(token.RPAREN); err != nil { return token.Token{}, "", "", NewParseError(p.peekToken, "missing closing parenthesis ')' for format()") } - if maxTextLength <= 0 { - maxTextLength = p.fonts.Fonts[fontID].MaxLineLength + // Read default values from font config, if they weren't explicitly specified. + if maxLineLength <= 0 { + maxLineLength = p.fonts.Fonts[fontID].MaxLineLength + } + if numLines <= 0 { + numLines = p.fonts.Fonts[fontID].NumLines + } + if cursorOverlapWidth <= 0 { + cursorOverlapWidth = p.fonts.Fonts[fontID].CursorOverlapWidth } - formatted, err := p.fonts.FormatText(textToken.Literal, maxTextLength, p.fonts.Fonts[fontID].CursorOverlapWidth, fontID) + formatted, err := p.fonts.FormatText(textToken.Literal, maxLineLength, cursorOverlapWidth, fontID, numLines) if err != nil && p.enableEnvironmentErrors { return token.Token{}, "", "", NewParseError(fontIdToken, err.Error()) } diff --git a/parser/parser_test.go b/parser/parser_test.go index 5d70349..db160a8 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -733,6 +733,10 @@ text MyText3 { text MyText4 { format("aaaa aaa aa aaa aa aaa aa aaa aa aaa aa aaa", "1_latin_frlg") } + +text MyText5 { + format("aaaa aaa aa a", numLines=3, maxLineLength=1, cursorOverlapWidth=100, fontId="1_latin_rse") +} ` l := lexer.New(input) p := New(l, "../font_config.json", "1_latin_frlg", 0, nil) @@ -741,18 +745,18 @@ text MyText4 { t.Fatalf(err.Error()) } - if len(program.Texts) != 6 { - t.Fatalf("len(program.Texts) != 6. Got '%d' instead.", len(program.Texts)) + if len(program.Texts) != 7 { + t.Fatalf("len(program.Texts) != 7. Got '%d' instead.", len(program.Texts)) } defaultTest := "Test»{BLAH} and a bunch of extra stuff to\\n\noverflow the line$" if program.Texts[0].Value != defaultTest { t.Fatalf("Incorrect format() evaluation. Got '%s' instead of '%s'", program.Texts[0].Value, defaultTest) } - testBlank := "FooBar\\n\nand\\l\na\\l\nbunch\\l\nof\\l\nextra\\l\nstuff\\l\nto\\l\noverflow\\l\nthe\\l\nline$" + testBlank := "FooBar\\l\nand\\l\na\\l\nbunch\\l\nof\\l\nextra\\l\nstuff\\l\nto\\l\noverflow\\l\nthe\\l\nline$" if program.Texts[1].Value != testBlank { t.Fatalf("Incorrect format() evaluation. Got '%s' instead of '%s'", program.Texts[1].Value, testBlank) } - test100 := "FooBar and\\n\na bunch of\\l\nextra\\l\nstuff to\\l\noverflow\\l\nthe line$" + test100 := "FooBar and\\l\na bunch of\\l\nextra\\l\nstuff to\\l\noverflow\\l\nthe line$" if program.Texts[2].Value != test100 { t.Fatalf("Incorrect format() evaluation. Got '%s' instead of '%s'", program.Texts[2].Value, test100) } @@ -767,6 +771,10 @@ text MyText4 { if program.Texts[5].Value != defaultFont { t.Fatalf("Incorrect format() evaluation. Got '%s' instead of '%s'", program.Texts[5].Value, defaultFont) } + namedParams := "aaaa\\n\naaa\\n\naa\\l\na$" + if program.Texts[6].Value != namedParams { + t.Fatalf("Incorrect format() evaluation. Got '%s' instead of '%s'", program.Texts[6].Value, namedParams) + } } @@ -1855,8 +1863,8 @@ text Foo { text Foo { format("Hi", ) }`, - expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 14, Utf8CharStart: 14, CharEnd: 15, Utf8CharEnd: 15, Message: "invalid format() parameter ')'. Expected either fontId (string) or maxLineLength (integer)"}, - expectedErrorMsg: "line 3: invalid format() parameter ')'. Expected either fontId (string) or maxLineLength (integer)", + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 14, Utf8CharStart: 14, CharEnd: 15, Utf8CharEnd: 15, Message: "invalid format() parameter ')'"}, + expectedErrorMsg: "line 3: invalid format() parameter ')'", }, { input: ` @@ -1868,6 +1876,54 @@ text Foo { }, { input: ` +text Foo { + format("Hi", invalid=5) +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 14, Utf8CharStart: 14, CharEnd: 21, Utf8CharEnd: 21, Message: "invalid format() named parameter 'invalid'"}, + expectedErrorMsg: "line 3: invalid format() named parameter 'invalid'", + }, + { + input: ` +text Foo { + format("Hi", numLines 5) +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 23, Utf8CharStart: 23, CharEnd: 24, Utf8CharEnd: 24, Message: "missing '=' after format() named parameter 'numLines'"}, + expectedErrorMsg: "line 3: missing '=' after format() named parameter 'numLines'", + }, + { + input: ` +text Foo { + format("Hi", fontId=5) +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 21, Utf8CharStart: 21, CharEnd: 22, Utf8CharEnd: 22, Message: "invalid fontId '5'. Expected string"}, + expectedErrorMsg: "line 3: invalid fontId '5'. Expected string", + }, + { + input: ` +text Foo { + format("Hi", maxLineLength="hi") +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 28, Utf8CharStart: 28, CharEnd: 32, Utf8CharEnd: 32, Message: "invalid maxLineLength 'hi'. Expected integer"}, + expectedErrorMsg: "line 3: invalid maxLineLength 'hi'. Expected integer", + }, + { + input: ` +text Foo { + format("Hi", numLines="hi") +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 23, Utf8CharStart: 23, CharEnd: 27, Utf8CharEnd: 27, Message: "invalid numLines 'hi'. Expected integer"}, + expectedErrorMsg: "line 3: invalid numLines 'hi'. Expected integer", + }, + { + input: ` +text Foo { + format("Hi", cursorOverlapWidth="hi") +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 33, Utf8CharStart: 33, CharEnd: 37, Utf8CharEnd: 37, Message: "invalid cursorOverlapWidth 'hi'. Expected integer"}, + expectedErrorMsg: "line 3: invalid cursorOverlapWidth 'hi'. Expected integer", + }, + { + input: ` text Foo { format("Hi", "TEST", "NOT_AN_INT") }`, @@ -1887,8 +1943,8 @@ text Foo { script Foo { msgbox(format("Hi", )) }`, - expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 21, Utf8CharStart: 21, CharEnd: 22, Utf8CharEnd: 22, Message: "invalid format() parameter ')'. Expected either fontId (string) or maxLineLength (integer)"}, - expectedErrorMsg: "line 3: invalid format() parameter ')'. Expected either fontId (string) or maxLineLength (integer)", + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 21, Utf8CharStart: 21, CharEnd: 22, Utf8CharEnd: 22, Message: "invalid format() parameter ')'"}, + expectedErrorMsg: "line 3: invalid format() parameter ')'", }, { input: ` @@ -1900,6 +1956,38 @@ text Foo { }, { input: ` +text Foo { + format("Hi", cursorOverlapWidth=3, 100) +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 36, Utf8CharStart: 36, CharEnd: 39, Utf8CharEnd: 39, Message: "invalid parameter '100'. Expected named parameter"}, + expectedErrorRegex: `line 3: invalid parameter '100'. Expected named parameter`, + }, + { + input: ` +text Foo { + format("Hi", cursorOverlapWidth=3, cursorOverlapWidth=4) +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 36, Utf8CharStart: 36, CharEnd: 54, Utf8CharEnd: 54, Message: "duplicate parameter 'cursorOverlapWidth'"}, + expectedErrorRegex: `line 3: duplicate parameter 'cursorOverlapWidth'`, + }, + { + input: ` +text Foo { + format("Hi", "fakeFont", fontId="otherfont) +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 26, Utf8CharStart: 26, CharEnd: 32, Utf8CharEnd: 32, Message: "duplicate parameter 'fontId'"}, + expectedErrorRegex: `line 3: duplicate parameter 'fontId'`, + }, + { + input: ` +text Foo { + format("Hi", 100, maxLineLength=50) +}`, + expectedError: ParseError{LineNumberStart: 3, LineNumberEnd: 3, CharStart: 19, Utf8CharStart: 19, CharEnd: 32, Utf8CharEnd: 32, Message: "duplicate parameter 'maxLineLength'"}, + expectedErrorRegex: `line 3: duplicate parameter 'maxLineLength'`, + }, + { + input: ` mapscripts { }`, expectedError: ParseError{LineNumberStart: 2, LineNumberEnd: 2, CharStart: 0, Utf8CharStart: 0, CharEnd: 12, Utf8CharEnd: 12, Message: "missing name for mapscripts statement"},