Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow users to define a variable number of lines in font_config #59

Merged
merged 23 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
78445a9
Parser now correctly breaks at 3 lines
pkmnsnfrn Sep 6, 2023
8c777fb
Change maxLineWidth for RSE fonts
pkmnsnfrn Sep 6, 2023
55ee476
Updated originalMaxLineLength to stop cutoffs https://i.imgur.com/URm…
pkmnsnfrn Oct 2, 2023
024a6ac
Merged in latest poryscript
pkmnsnfrn Dec 4, 2023
2539bc8
Create a modular version that takes a number of lines from
pkmnsnfrn Dec 21, 2023
9296e06
Simplified block to use two statements as suggested in https://github…
pkmnsnfrn Dec 25, 2023
adc1855
renamed isMaxLineOrGreater to shouldUseLineFeed https://github.com/hu…
pkmnsnfrn Dec 25, 2023
9d543a6
renamed lineNumber to curLineNum https://github.com/huderlem/poryscr…
pkmnsnfrn Dec 25, 2023
5d2bde4
zero-indexed curLineNum https://github.com/huderlem/poryscript/pull/5…
pkmnsnfrn Dec 25, 2023
8f4a7a4
Added numLines, maxLineLength, fontId as named parameters
pkmnsnfrn Dec 26, 2023
407bce1
renamed numLines
pkmnsnfrn Dec 26, 2023
5dd965d
Added maxLineLength as unnamed paramter
pkmnsnfrn Dec 26, 2023
9cb78e9
Added cursorOverlapWidth as a named parameter
pkmnsnfrn Dec 26, 2023
e70de91
Fixed typo with cursorOverlapWidth
pkmnsnfrn Dec 26, 2023
58583e9
Reset curLineNum to zero when the user enters their own paragraph bre…
pkmnsnfrn Dec 26, 2023
5e52164
Changed isFirstLine to use bang operator instead of checking for fals…
pkmnsnfrn Dec 26, 2023
3cf189f
Removed redundant parenthesis in shouldUseLineFeed https://github.com…
pkmnsnfrn Dec 26, 2023
667acc2
Created setEmptyParametersToDefault to warn users and handle unset va…
pkmnsnfrn Dec 26, 2023
66a2742
Reworked high-level logic and created isNamedParameter, handleUnnamed…
pkmnsnfrn Dec 26, 2023
b834f9d
Created reportDuplicateParameterError https://github.com/huderlem/por…
pkmnsnfrn Dec 27, 2023
f9ef671
Fixup format() named parameters parsing and tests
huderlem Jan 1, 2024
7d86835
Update README
huderlem Jan 1, 2024
3e872d7
Improve format() error message and disallow duplicated named parameters
huderlem Jan 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
pkmnsnfrn marked this conversation as resolved.
Show resolved Hide resolved
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