Skip to content

Commit

Permalink
allow users to define a variable number of lines in font_config (#59)
Browse files Browse the repository at this point in the history
* Parser now correctly breaks at 3 lines

* Change maxLineWidth for RSE fonts

* Updated originalMaxLineLength to stop cutoffs https://i.imgur.com/URmIl4u.png

* Create a modular version that takes a number of lines from
font_config.json and formats text accordingly

* Simplified block to use two statements as suggested in #59 (comment)

* renamed isMaxLineOrGreater to shouldUseLineFeed #59 (comment)

* renamed lineNumber to curLineNum  #59 (comment)

* zero-indexed curLineNum #59 (comment)

* Added numLines, maxLineLength, fontId as named parameters

* renamed numLines

* Added maxLineLength as unnamed paramter

* Added cursorOverlapWidth as a named parameter

* Fixed typo with cursorOverlapWidth
Added support for unnamed fontId parameter

* Reset curLineNum to zero when the user enters their own paragraph break https://github.com/huderlem/poryscript/pull/59\#discussion_r1436539600

* Changed isFirstLine to use bang operator instead of checking for false https://github.com/huderlem/poryscript/pull/59\#discussion_r1436539772

* Removed redundant parenthesis in shouldUseLineFeed https://github.com/huderlem/poryscript/pull/59\#discussion_r1436539985

* Created setEmptyParametersToDefault to warn users and handle unset values
#59 (comment)
#59 (comment)

* Reworked high-level logic and created isNamedParameter, handleUnnamedParameter, handleNamedParameter
#59 (comment)

* Created reportDuplicateParameterError #59 (comment)

* Fixup format() named parameters parsing and tests

* Update README

* Improve format() error message and disallow duplicated named parameters

---------

Co-authored-by: Marcus Huderle <huderlem@gmail.com>
  • Loading branch information
pkmnsnfrn and huderlem authored Jan 15, 2024
1 parent c4eb930 commit 6bdc81e
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 42 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```
Expand All @@ -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:
```
Expand Down
2 changes: 2 additions & 0 deletions font_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"fonts": {
"1_latin_rse": {
"maxLineLength": 208,
"numLines": 2,
"cursorOverlapWidth": 0,
"widths": {
" ": 3,
Expand Down Expand Up @@ -177,6 +178,7 @@
},
"1_latin_frlg": {
"maxLineLength": 208,
"numLines": 2,
"cursorOverlapWidth": 10,
"widths": {
" ": 6,
Expand Down
30 changes: 20 additions & 10 deletions parser/formattext.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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`)
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion parser/formattext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
140 changes: 117 additions & 23 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 '('")
Expand Down Expand Up @@ -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())
}
Expand Down
Loading

0 comments on commit 6bdc81e

Please sign in to comment.