Files

292 lines
9.5 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
// Package rogue interprets classic Rogue 5.4's visible terminal display.
// Layout and glyphs follow RogueForge's io.c, command.c, and rogue.h:
// https://github.com/rogueforge/rogue5.4/tree/d4ff90dc41053f1962efbf32c7b71b12503b9b62
package rogue
import (
"regexp"
"slices"
"strconv"
"strings"
"git.stormux.org/storm/fenrir/internal/applicationoverrides"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
// Classic Rogue uses a 24 by 80 playfield even in a larger terminal.
const mapBottom = 22
var statusPattern = regexp.MustCompile(`^Level:\s+(\d+)\s+Gold:\s+(\d+)\s+Hp:\s+(-?\d+)\(\s*(\d+)\)\s+Str:\s+(\d+)\(\s*(\d+)\)\s+Arm:\s+(-?\d+)\s+Exp:\s+(\d+)/(\d+)\s*(Hungry|Weak|Faint)?\s*$`)
type entity struct {
x, y int
glyph rune
}
type Adapter struct {
previous screen.Snapshot
stats []string
message, latest string
entities []entity
regions []applicationoverrides.Region
dialog bool
}
func (*Adapter) Name() string { return "Rogue" }
func (*Adapter) Configure(settings *config.Settings) {
settings.Reading.Mode = "cursor"
settings.Review.LeaveReviewOnCursorChange = true
}
func (a *Adapter) Reset() { *a = Adapter{} }
func (a *Adapter) Close() { a.Reset() }
func (a *Adapter) Command(id command.ID) (presentation.CommandResult, bool) {
ids := []command.ID{command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3, command.ReadLiveRegion4, command.ReadLiveRegion5, command.ReadLiveRegion6}
if slot := slices.Index(ids, id); slot >= 0 {
regions := a.regions
if len(regions) == 0 {
regions = region_names()
}
return presentation.CommandResult{Text: regions[slot].Read()}, true
}
if id == command.ListReadingRegions {
var entries []string
for slot, region := range region_names() {
entries = append(entries, i18n.Format("Live region {0}: {1}", strconv.Itoa(slot+1), region.Name))
}
return presentation.CommandResult{Text: strings.Join(entries, ". ")}, true
}
return presentation.CommandResult{}, false
}
func region_names() []applicationoverrides.Region {
return []applicationoverrides.Region{
{Name: i18n.T("Health")}, {Name: i18n.T("Status")},
{Name: i18n.T("Latest message")}, {Name: i18n.T("Surroundings")},
{Name: i18n.T("Visible monsters")}, {Name: i18n.T("Visible objects and landmarks")},
}
}
func (a *Adapter) Observe(next screen.Snapshot) applicationoverrides.Frame {
if !next.Active || next.Rows < 24 || next.Cols < 80 || len(next.Cells) != next.Rows*next.Cols {
a.Reset()
return applicationoverrides.Frame{}
}
stable := a.previous.Active && a.previous.Terminal == next.Terminal && a.previous.Generation == next.Generation && a.previous.Rows == next.Rows && a.previous.Cols == next.Cols
if !stable {
a.Reset()
}
stats := statusPattern.FindStringSubmatch(strings.TrimSpace(next.Text(23)))
// Options and full-screen help/inventory use a separate curses window.
// Let ordinary reading handle their text; never interpret it as map glyphs.
options := strings.Contains(next.Text(0), `Terse output ("terse"):`) && strings.Contains(string(next.Cells), `Inventory style ("inven"):`)
page := strings.Contains(string(next.Cells), "--Press space to continue--")
if options || page && (stable || stats != nil) {
a.regions = region_names()
a.regions[2].Text = a.latest
a.previous, a.dialog = next, true
return applicationoverrides.Frame{Recognized: true, Regions: slices.Clone(a.regions)}
}
x, y, found := player_position(next)
if stats == nil || !found {
a.Reset()
return applicationoverrides.Frame{}
}
frame := applicationoverrides.Frame{Recognized: true, OwnsPresentation: true}
message := strings.TrimSpace(next.Text(0))
if strings.HasSuffix(message, "--More--") {
message = strings.TrimSpace(strings.TrimSuffix(message, "--More--")) + ". " + i18n.T("More; press Space")
}
if message != "" && message != a.message {
a.latest = message
frame.Updates = append(frame.Updates, presentation.Message{Text: message})
}
// A visible clear permits the same message to be announced on a later turn.
// Messages replaced between captures cannot be recovered from screenshots.
a.message = message
regions := region_names()
regions[0].Text = i18n.Format("{0} of {1}", stats[3], stats[4])
regions[1].Text = strings.TrimSpace(next.Text(23))
regions[2].Text = a.latest
regions[3].Text = surroundings(next, x, y)
entities := visible_entities(next)
var monsters, objects []string
for _, item := range entities {
text := describe(item, x, y)
if is_monster(item.glyph) {
monsters = append(monsters, text)
} else {
objects = append(objects, text)
}
}
regions[4].Text, regions[5].Text = join_entities(monsters), join_entities(objects)
if !stable || len(a.stats) == 0 {
frame.Updates = append(frame.Updates, presentation.Message{Text: regions[0].Read()})
if stats[10] != "" {
frame.Updates = append(frame.Updates, presentation.Message{Text: stats[10]})
}
} else {
if stats[3] != a.stats[3] || stats[4] != a.stats[4] {
frame.Updates = append(frame.Updates, presentation.Message{Text: regions[0].Read()})
}
for _, field := range []struct {
index int
label string
}{
{1, i18n.T("Dungeon level")}, {5, i18n.T("Strength")},
{7, i18n.T("Armor")}, {8, i18n.T("Experience level")},
} {
if stats[field.index] != a.stats[field.index] {
frame.Updates = append(frame.Updates, presentation.Message{Text: (applicationoverrides.Region{Name: field.label, Text: stats[field.index]}).Read()})
}
}
if stats[10] != a.stats[10] {
hunger := stats[10]
if hunger == "" {
hunger = i18n.T("Not hungry")
}
frame.Updates = append(frame.Updates, presentation.Message{Text: hunger})
}
}
// Compare monsters by glyph count so a moving monster does not repeatedly
// sound like a new arrival. Objects and landmarks have fixed map positions.
if stable && !a.dialog && len(a.stats) != 0 && stats[1] == a.stats[1] {
counts := map[rune]int{}
for _, item := range a.entities {
if is_monster(item.glyph) {
counts[item.glyph]++
}
}
for _, item := range entities {
if is_monster(item.glyph) && counts[item.glyph] > 0 {
counts[item.glyph]--
continue
}
if !is_monster(item.glyph) && slices.Contains(a.entities, item) {
continue
}
frame.Updates = append(frame.Updates, presentation.Message{Text: describe(item, x, y)})
}
}
a.regions, a.stats, a.entities = regions, stats, entities
a.previous, a.dialog = next, false
frame.Regions = slices.Clone(regions)
return frame
}
func player_position(s screen.Snapshot) (int, int, bool) {
x, y := -1, -1
for row := 1; row <= mapBottom; row++ {
for col, glyph := range s.Line(row)[:80] {
if glyph == '@' {
if x >= 0 {
return 0, 0, false
}
x, y = col, row
}
}
}
return x, y, x >= 0
}
func is_monster(glyph rune) bool { return glyph >= 'A' && glyph <= 'Z' }
func visible_entities(s screen.Snapshot) []entity {
var result []entity
for y := 1; y <= mapBottom; y++ {
for x, glyph := range s.Line(y)[:80] {
if is_monster(glyph) || strings.ContainsRune("+^%*!?$:)],=/", glyph) {
result = append(result, entity{x, y, glyph})
}
}
}
return result
}
func join_entities(items []string) string {
if len(items) == 0 {
return i18n.T("None visible")
}
return strings.Join(items, "; ")
}
func surroundings(s screen.Snapshot, x, y int) string {
var items []string
for _, delta := range [][2]int{{0, -1}, {1, -1}, {1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}} {
col, row := x+delta[0], y+delta[1]
if col < 0 || col >= 80 || row < 1 || row > mapBottom {
continue
}
items = append(items, describe(entity{col, row, s.Line(row)[col]}, x, y))
}
return strings.Join(items, "; ")
}
func describe(item entity, x, y int) string {
var offsets []string
if item.y < y {
offsets = append(offsets, i18n.Format("north {0}", strconv.Itoa(y-item.y)))
}
if item.y > y {
offsets = append(offsets, i18n.Format("south {0}", strconv.Itoa(item.y-y)))
}
if item.x < x {
offsets = append(offsets, i18n.Format("west {0}", strconv.Itoa(x-item.x)))
}
if item.x > x {
offsets = append(offsets, i18n.Format("east {0}", strconv.Itoa(item.x-x)))
}
return i18n.Format("{0}, {1}", glyph_name(item.glyph), strings.Join(offsets, ", "))
}
func glyph_name(glyph rune) string {
// These are Rogue's own English monster names, rather than Fenrir labels.
if is_monster(glyph) {
return []string{"aquator", "bat", "centaur", "dragon", "emu", "venus flytrap", "griffin", "hobgoblin", "ice monster", "jabberwock", "kestrel", "leprechaun", "medusa", "nymph", "orc", "phantom", "quagga", "rattlesnake", "snake", "troll", "black unicorn", "vampire", "wraith", "xeroc", "yeti", "zombie"}[glyph-'A']
}
switch glyph {
case ' ', 0:
return i18n.T("Unseen")
case '|', '-':
return i18n.T("Wall")
case '.':
return i18n.T("Floor")
case '#':
return i18n.T("Passage")
case '+':
return i18n.T("Door")
case '^':
return i18n.T("Trap")
case '%':
return i18n.T("Stairs")
case '*':
return i18n.T("Gold")
case '!':
return i18n.T("Potion")
case '?':
return i18n.T("Scroll")
case '$':
return i18n.T("Magic")
case ':':
return i18n.T("Food")
case ')':
return i18n.T("Weapon")
case ']':
return i18n.T("Armor")
case ',':
return i18n.T("Amulet")
case '=':
return i18n.T("Ring")
case '/':
return i18n.T("Wand or staff")
}
return string(glyph)
}