aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-06-16 01:59:27 -0400
committerhistoria <[not public]>2026-06-16 01:59:27 -0400
commit43f21aabae8924f35c12c8485e690aeefe0089fb (patch)
treeb5fbadb89df3cf4ffec86503bb8e2d7e52b27454 /internal/game
parent4cc1ddcd185509080b4b3e15d1646567edd51c9d (diff)
downloadthehouseoficarus-43f21aabae8924f35c12c8485e690aeefe0089fb.tar.gz
feat: overhauled ansi color, added prompt options
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/action_gather.go33
-rw-r--r--internal/game/action_talk.go2
-rw-r--r--internal/game/cmd_attack.go38
-rw-r--r--internal/game/cmd_color.go168
-rw-r--r--internal/game/cmd_drop.go15
-rw-r--r--internal/game/cmd_equipment.go2
-rw-r--r--internal/game/cmd_get.go23
-rw-r--r--internal/game/cmd_inventory.go5
-rw-r--r--internal/game/cmd_look.go124
-rw-r--r--internal/game/cmd_move.go7
-rw-r--r--internal/game/color.go66
-rw-r--r--internal/game/doc.go30
-rw-r--r--internal/game/game.go19
-rw-r--r--internal/game/help.go87
-rw-r--r--internal/game/login_account.go4
-rw-r--r--internal/game/prompt.go133
16 files changed, 588 insertions, 168 deletions
diff --git a/internal/game/action_gather.go b/internal/game/action_gather.go
index 08d2589..e3ea105 100644
--- a/internal/game/action_gather.go
+++ b/internal/game/action_gather.go
@@ -6,7 +6,6 @@ import (
"strings"
"thirdcollapse/internal/action"
- "thirdcollapse/internal/color"
"thirdcollapse/internal/engine"
"thirdcollapse/internal/net"
"thirdcollapse/internal/object"
@@ -178,7 +177,6 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
wait := p.Action.Data["effective_wait"].(float64)
instanceKey := p.Action.Data["instance_key"].(string)
_, shared := p.Action.Data["deplete_timer"]
- mode := g.colorMode(sess)
if step == 0 {
if cfg.Bait != "" {
@@ -187,12 +185,12 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
if def, err := g.ItemStore.Load(cfg.Bait); err == nil {
baitName = def.Name
}
- sess.WriteLine(color.Wrap(mode, "red", fmt.Sprintf("You've run out of %s.", baitName)))
+ sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName)))
g.CancelAction(p)
return
}
}
- sess.WriteLine(fmt.Sprintf("\n%s", color.Tag(mode, cfg.GatherMsg)))
+ sess.WriteLine(fmt.Sprintf("\n%s", cfg.GatherMsg))
p.Action.Data["step"] = 1
p.Action.WaitLeft = engine.ToTicks(wait)
return
@@ -205,7 +203,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
return
}
if cfg.RespawnMsg != "" {
- sess.WriteLine(fmt.Sprintf("\n%s", color.Tag(mode, cfg.RespawnMsg)))
+ sess.WriteLine(fmt.Sprintf("\n%s", cfg.RespawnMsg))
}
p.Action.Data["step"] = 0
p.Action.WaitLeft = engine.ToTicks(wait)
@@ -218,7 +216,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
if def, err := g.ItemStore.Load(cfg.Bait); err == nil {
baitName = def.Name
}
- sess.WriteLine(color.Wrap(mode, "red", fmt.Sprintf("You've run out of %s.", baitName)))
+ sess.WriteLine(g.colorize(sess, "error", fmt.Sprintf("You've run out of %s.", baitName)))
g.CancelAction(p)
return
}
@@ -234,7 +232,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
if drop != nil {
freeSlot := p.FirstFreeSlot()
if freeSlot == -1 {
- sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!"))
+ sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
g.CancelAction(p)
return
}
@@ -245,8 +243,9 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
itemName := drop.ItemID
- if def, err := g.ItemStore.Load(drop.ItemID); err == nil {
- itemName = def.Name
+ itemDef, _ := g.ItemStore.Load(drop.ItemID)
+ if itemDef != nil {
+ itemName = itemDef.Name
}
p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: drop.ItemID, Quantity: qty})
@@ -256,19 +255,17 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
if xp > 0 {
if newLevel := p.AddSkillXP(player.SkillName(cfg.Skill), xp); newLevel > 0 {
- sess.WriteLine(color.Wrap(mode, "bright_yellow", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill)))
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", newLevel, cfg.Skill)))
}
}
g.AccountStore.SaveCharacter(p)
msg := drop.Message
if msg == "" {
- msg = fmt.Sprintf("You manage to get some %s.", color.Wrap(mode, "green", itemName))
- } else {
- msg = color.Tag(mode, msg)
+ msg = fmt.Sprintf("You manage to get some %s.", g.itemColorize(sess, itemDef, itemName))
}
if xp > 0 && p.OptionBool("xp_drops") {
- msg += color.Wrap(mode, "yellow", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)]))
+ msg += g.colorize(sess, "xp", fmt.Sprintf(" (+%dxp %s)", xp, player.SkillAbbr[player.SkillName(cfg.Skill)]))
}
sess.WriteLine(msg)
@@ -309,7 +306,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
continue
}
if op.Action.Data["instance_key"] == instanceKey {
- other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", color.Wrap(mode, "green", p.Action.TargetName), color.Wrap(mode, "bright_white", p.Name)))
+ other.WriteLine(fmt.Sprintf("\nThe %s was depleted by %s!", g.colorize(sess, "item", p.Action.TargetName), g.colorize(sess, "player_name", p.Name)))
g.CancelAction(op)
g.writePrompt(other)
}
@@ -321,7 +318,7 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
if p.FirstFreeSlot() == -1 {
- sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!"))
+ sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
g.CancelAction(p)
return
}
@@ -331,9 +328,9 @@ func (g *Game) advanceGather(sess *net.Session, p *player.Player) {
}
}
- sess.WriteLine(color.Tag(mode, cfg.FailMsg))
+ sess.WriteLine(cfg.FailMsg)
if p.FirstFreeSlot() == -1 {
- sess.WriteLine(color.Wrap(mode, "red", "Your inventory is too full!"))
+ sess.WriteLine(g.colorize(sess, "error", "Your inventory is too full!"))
g.CancelAction(p)
return
}
diff --git a/internal/game/action_talk.go b/internal/game/action_talk.go
index 4102841..9e90f93 100644
--- a/internal/game/action_talk.go
+++ b/internal/game/action_talk.go
@@ -54,7 +54,7 @@ func (g *Game) startTalk(sess *net.Session, p *player.Player, obj *object.Object
}
func (g *Game) showTalkNode(sess *net.Session, node action.TalkNode) {
- sess.WriteLine(fmt.Sprintf("\n%s", g.colorTag(sess, node.Message)))
+ sess.WriteLine(fmt.Sprintf("\n%s", node.Message))
if node.Action != nil {
g.applyNodeAction(sess, node.Action)
diff --git a/internal/game/cmd_attack.go b/internal/game/cmd_attack.go
index 08780c3..ba4a2e1 100644
--- a/internal/game/cmd_attack.go
+++ b/internal/game/cmd_attack.go
@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
- "thirdcollapse/internal/color"
"thirdcollapse/internal/combat"
"thirdcollapse/internal/engine"
"thirdcollapse/internal/net"
@@ -132,8 +131,7 @@ func (g *Game) startCombat(sess *net.Session, p *player.Player, mob *world.MobIn
if len(styleParts) > 0 {
styleStr = " Style: " + string(p.AttackStyle) + " (" + strings.Join(styleParts, ", ") + ")"
}
- mode := g.colorMode(sess)
- sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", color.Wrap(mode, "bright_red", mobDisplayName(mob, true)), styleStr))
+ sess.WriteLine(fmt.Sprintf("\nYou attack %s!%s", g.colorize(sess, "mob_name", mobDisplayName(mob, true)), styleStr))
p.ActionState = &ActionState{Type: ActionCombating, TargetName: mob.Name}
g.Ticks.Subscribe(engine.ToTicks(playerSpeed), func() bool {
@@ -185,8 +183,6 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
}
}
- mode := g.colorMode(sess)
-
attRoll := combat.AttackRoll(p.Level(player.Attack), attBonus, equipAtt)
defRoll := combat.DefenseRoll(mob.Defense, 0, 0)
@@ -204,7 +200,7 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
gains, leveled := g.awardCombatXP(p, dmg)
for _, skill := range leveled {
- sess.WriteLine(color.Wrap(mode, "bright_yellow", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill)))
+ sess.WriteLine(g.colorize(sess, "level_up", fmt.Sprintf("*** You are now level %d %s! ***", p.Level(skill), skill)))
}
mobName := mobDisplayName(mob, true)
@@ -217,19 +213,19 @@ func (g *Game) playerAttack(sess *net.Session, p *player.Player, mob *world.MobI
w = w2
}
g.combatPadWidth = w
- prefix := fmt.Sprintf(" You hit %s for %s damage.", color.Wrap(mode, "bright_red", mobName), color.Wrap(mode, "red", fmt.Sprint(dmg)))
- hpPart := fmt.Sprintf("[%d/%dhp]", mob.HP, mob.MaxHP)
+ prefix := fmt.Sprintf(" You hit %s for %s damage.", g.colorize(sess, "mob_name", mobName), g.colorize(sess, "damage", fmt.Sprint(dmg)))
+ hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(mob.HP)), mob.MaxHP)
line := fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart)
if p.OptionBool("xp_drops") && len(gains) > 0 {
var parts []string
for _, gain := range gains {
parts = append(parts, fmt.Sprintf("+%dxp %s", gain.XP, player.SkillAbbr[player.SkillName(gain.Skill)]))
}
- line += color.Wrap(mode, "yellow", " ("+strings.Join(parts, ", ")+")")
+ line += g.colorize(sess, "xp", " ("+strings.Join(parts, ", ")+")")
}
sess.WriteLine(line)
} else {
- sess.WriteLine(color.Wrap(mode, "dim", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))))
+ sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" You miss %s.", mobDisplayName(mob, true))))
}
}
@@ -247,8 +243,6 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst
attRoll := combat.AttackRoll(mob.Attack, 0, 0)
defRoll := combat.DefenseRoll(p.Level(player.Defense), defBonus, equipDef)
- mode := g.colorMode(sess)
-
if combat.HitCheck(attRoll, defRoll) {
maxHit := combat.MaxHit(mob.Strength, 0, 0)
dmg := combat.RollDamage(maxHit)
@@ -270,15 +264,15 @@ func (g *Game) mobAttack(sess *net.Session, p *player.Player, mob *world.MobInst
w = w2
}
g.combatPadWidth = w
- prefix := fmt.Sprintf(" %s hits you for %s damage.", color.Wrap(mode, "bright_red", attacker), color.Wrap(mode, "red", fmt.Sprint(dmg)))
- hpPart := fmt.Sprintf("[%d/%dhp]", p.HP, p.MaxHP())
+ prefix := fmt.Sprintf(" %s hits you for %s damage.", g.colorize(sess, "mob_name", attacker), g.colorize(sess, "damage", fmt.Sprint(dmg)))
+ hpPart := fmt.Sprintf("[%s/%dhp]", g.colorize(sess, "character_hp", fmt.Sprint(p.HP)), p.MaxHP())
sess.WriteLine(fmt.Sprintf("%-*s %s", g.combatPadWidth, prefix, hpPart))
} else {
attacker := mob.Name
if !mob.Unique {
attacker = "The " + mob.Name
}
- sess.WriteLine(color.Wrap(mode, "dim", fmt.Sprintf(" %s misses you.", attacker)))
+ sess.WriteLine(g.colorize(sess, "miss", fmt.Sprintf(" %s misses you.", attacker)))
}
}
@@ -286,10 +280,9 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
g.combatPadWidth = 0
combat.LeaveCombat(p.Name)
p.ActionState = nil
- mode := g.colorMode(sess)
if p.HP <= 0 {
- sess.WriteLine(color.Wrap(mode, "red", "\nOh dear, you are dead!"))
+ sess.WriteLine(g.colorize(sess, "death", "\nOh dear, you are dead!"))
g.dropItemsOnDeath(p)
p.HP = p.MaxHP()
p.RoomID = 1
@@ -303,11 +296,14 @@ func (g *Game) endCombat(sess *net.Session, p *player.Player, mob *world.MobInst
}
if mob != nil && mob.HP <= 0 {
- sess.WriteLine(color.Wrap(mode, "green", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))))
+ sess.WriteLine(g.colorize(sess, "victory", fmt.Sprintf("\nYou have defeated %s!", mobDisplayName(mob, true))))
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(p.RoomID) {
if other != sess && other.Player != nil {
- other.WriteLine(fmt.Sprintf("\n%s has slain %s (level %d)!", p.Name, mobDisplayName(mob, false), mobCombatLevel(mob)))
+ op := other.Player.(*player.Player)
+ mobLvl := mobCombatLevel(mob)
+ levelStr := g.levelColorize(other, op.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
+ other.WriteLine(fmt.Sprintf("\n%s has slain %s %s!", p.Name, mobDisplayName(mob, false), levelStr))
}
}
}
@@ -473,7 +469,9 @@ func (g *Game) respawnMob(instanceID string) {
if g.Hub != nil {
for _, sess := range g.Hub.PlayersInRoom(homeRoom) {
if p, ok := sess.Player.(*player.Player); ok && p.OptionBool("mob_spawn") {
- sess.WriteLine(fmt.Sprintf("\n%s (level %d) spawns in the area.", mobDisplayName(inst, false), mobCombatLevel(inst)))
+ mobLvl := mobCombatLevel(inst)
+ levelStr := g.levelColorize(sess, p.CombatLevel(), mobLvl, fmt.Sprintf("(level %d)", mobLvl))
+ sess.WriteLine(fmt.Sprintf("\n%s %s spawns in the area.", mobDisplayName(inst, false), levelStr))
}
}
}
diff --git a/internal/game/cmd_color.go b/internal/game/cmd_color.go
new file mode 100644
index 0000000..7a208a2
--- /dev/null
+++ b/internal/game/cmd_color.go
@@ -0,0 +1,168 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/color"
+ "thirdcollapse/internal/config"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) doColor(sess *net.Session, input string) {
+ if sess.Account == nil {
+ sess.WriteLine("\nYou must be logged into an account to set colors.")
+ return
+ }
+
+ if input == "" {
+ showColorTable(g, sess)
+ return
+ }
+
+ parts := strings.Fields(input)
+ target := strings.ToLower(parts[0])
+
+ if _, ok := config.DefaultColors()[target]; !ok {
+ sess.WriteLine(fmt.Sprintf("\nUnknown color target: %s", target))
+ sess.WriteLine("Available targets:")
+ for _, t := range colorCategoryOrder {
+ sess.WriteLine(fmt.Sprintf(" %s", t))
+ }
+ return
+ }
+
+ if len(parts) == 1 {
+ current := g.getCurrentColor(sess, target)
+ source := g.colorSource(sess, target)
+ sess.WriteLine(fmt.Sprintf("\n%s = %s [source: %s]", target, current, source))
+ return
+ }
+
+ value := strings.Join(parts[1:], " ")
+
+ if len(parts) == 2 && parts[1] == "reset" {
+ g.saveAccountColor(sess, target, "")
+ sess.WriteLine(fmt.Sprintf("\n%s reset to default (%s).", target, config.DefaultColors()[target]))
+ return
+ }
+
+ if value == "off" {
+ g.saveAccountColor(sess, target, "off")
+ sess.WriteLine(fmt.Sprintf("\n%s set to off.", target))
+ return
+ }
+
+ spec := color.Parse(value)
+ if spec.Empty() && value != "" {
+ sess.WriteLine(fmt.Sprintf("\nInvalid color string: %s", value))
+ sess.WriteLine("Format: fg=<color> bg=<color> bold dim italic underline")
+ sess.WriteLine("Example: fg=green bg=black bold")
+ return
+ }
+
+ g.saveAccountColor(sess, target, value)
+ sess.WriteLine(fmt.Sprintf("\n%s set to %s.", target, value))
+}
+
+func (g *Game) saveAccountColor(sess *net.Session, target, value string) {
+ if value == "" {
+ if sess.Account.Colors != nil {
+ delete(sess.Account.Colors, target)
+ }
+ } else {
+ if sess.Account.Colors == nil {
+ sess.Account.Colors = make(map[string]string)
+ }
+ sess.Account.Colors[target] = value
+ }
+
+ acc, err := g.AccountStore.LoadAccount(sess.Account.Name)
+ if err != nil {
+ sess.WriteLine("\nError loading account.")
+ return
+ }
+ if value == "" {
+ if acc.Colors != nil {
+ delete(acc.Colors, target)
+ }
+ } else {
+ if acc.Colors == nil {
+ acc.Colors = make(map[string]string)
+ }
+ acc.Colors[target] = value
+ }
+ g.AccountStore.SaveAccount(acc)
+}
+
+func (g *Game) getCurrentColor(sess *net.Session, category string) string {
+ if sess.Account != nil && sess.Account.Colors != nil {
+ if val, ok := sess.Account.Colors[category]; ok {
+ if val == "off" {
+ return "off"
+ }
+ if val != "" {
+ return val
+ }
+ }
+ }
+ if g.ColorConfig != nil {
+ if val, ok := (*g.ColorConfig)[category]; ok && val != "" {
+ return val
+ }
+ }
+ return config.DefaultColors()[category]
+}
+
+func (g *Game) colorSource(sess *net.Session, category string) string {
+ if sess.Account != nil && sess.Account.Colors != nil {
+ if _, ok := sess.Account.Colors[category]; ok {
+ return "account"
+ }
+ }
+ if g.ColorConfig != nil {
+ if _, ok := (*g.ColorConfig)[category]; ok {
+ return "default"
+ }
+ }
+ return "builtin"
+}
+
+var colorCategoryOrder = []string{
+ "room_name",
+ "room_number",
+ "room_desc",
+ "direction",
+ "exit_direction",
+ "exit_name",
+ "mob_name",
+ "friendly_npc",
+ "hostile_npc",
+ "damage",
+ "enemy_hp",
+ "character_hp",
+ "xp",
+ "level_up",
+ "item",
+ "player_name",
+ "death",
+ "victory",
+ "miss",
+ "error",
+}
+
+func showColorTable(g *Game, sess *net.Session) {
+ p := sess.Player.(*player.Player)
+ table := &Table{
+ Columns: []string{"Target", "Color", "Source"},
+ }
+ for _, cat := range colorCategoryOrder {
+ val := g.getCurrentColor(sess, cat)
+ source := g.colorSource(sess, cat)
+ table.Rows = append(table.Rows, []string{cat, val, source})
+ }
+ for _, line := range table.Render(p.OptionBool("unicode")) {
+ sess.WriteLine(line)
+ }
+}
diff --git a/internal/game/cmd_drop.go b/internal/game/cmd_drop.go
index 2bf00ce..b08eadf 100644
--- a/internal/game/cmd_drop.go
+++ b/internal/game/cmd_drop.go
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
- "thirdcollapse/internal/color"
"thirdcollapse/internal/net"
"thirdcollapse/internal/player"
)
@@ -54,6 +53,7 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) {
if def != nil {
name = def.Name
}
+ coloredName := g.itemColorize(sess, def, name)
p.ActionState = &ActionState{Type: ActionDropping, TargetName: name}
if def != nil && def.Stackable {
@@ -63,9 +63,9 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) {
g.World.AddGroundItem(p.RoomID, itemID, qty)
g.AccountStore.SaveCharacter(p)
if qty == 1 {
- sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", coloredName))
} else {
- sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, name))
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, coloredName))
}
return
}
@@ -82,16 +82,15 @@ func (g *Game) doDropAllNamed(sess *net.Session, input string) {
}
g.AccountStore.SaveCharacter(p)
if total == 1 {
- sess.WriteLine(fmt.Sprintf("You drop a %s.", name))
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", coloredName))
} else {
- sess.WriteLine(fmt.Sprintf("You drop %d x %s.", total, name))
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", total, coloredName))
}
}
func (g *Game) doDrop(sess *net.Session, input string) {
p := sess.Player.(*player.Player)
g.CancelAction(p)
- mode := g.colorMode(sess)
qty, itemName := parseQty(input)
@@ -151,9 +150,9 @@ func (g *Game) doDrop(sess *net.Session, input string) {
g.World.AddGroundItem(p.RoomID, itemID, qty)
g.AccountStore.SaveCharacter(p)
if qty == 1 {
- sess.WriteLine(fmt.Sprintf("You drop a %s.", color.Wrap(mode, "green", name)))
+ sess.WriteLine(fmt.Sprintf("You drop a %s.", g.itemColorize(sess, def, name)))
} else {
- sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, color.Wrap(mode, "green", name)))
+ sess.WriteLine(fmt.Sprintf("You drop %d x %s.", qty, g.itemColorize(sess, def, name)))
}
return
}
diff --git a/internal/game/cmd_equipment.go b/internal/game/cmd_equipment.go
index cda39b1..4947d2b 100644
--- a/internal/game/cmd_equipment.go
+++ b/internal/game/cmd_equipment.go
@@ -23,6 +23,6 @@ func (g *Game) doEquipment(sess *net.Session) {
if err == nil {
name = def.Name
}
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name))
+ sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, def, name)))
}
}
diff --git a/internal/game/cmd_get.go b/internal/game/cmd_get.go
index 9591657..e75c59e 100644
--- a/internal/game/cmd_get.go
+++ b/internal/game/cmd_get.go
@@ -3,7 +3,6 @@ package game
import (
"fmt"
- "thirdcollapse/internal/color"
"thirdcollapse/internal/net"
"thirdcollapse/internal/player"
)
@@ -11,7 +10,6 @@ import (
func (g *Game) doGet(sess *net.Session, input string) {
p := sess.Player.(*player.Player)
g.CancelAction(p)
- mode := g.colorMode(sess)
ground := g.World.GroundItems(p.RoomID)
if len(ground) == 0 {
sess.WriteLine("There's nothing on the ground to pick up.")
@@ -87,7 +85,7 @@ func (g *Game) doGet(sess *net.Session, input string) {
}
slot.Quantity += qty
g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, color.Wrap(mode, "green", def.Name), slot.Quantity))
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", qty, g.itemColorize(sess, def, def.Name), slot.Quantity))
return
}
}
@@ -103,9 +101,9 @@ func (g *Game) doGet(sess *net.Session, input string) {
p.SetInvSlot(freeSlot, g.newInventorySlot(itemID, qty))
g.AccountStore.SaveCharacter(p)
if qty == 1 {
- sess.WriteLine(fmt.Sprintf("You pick up a %s.", color.Wrap(mode, "green", def.Name)))
- } else {
- sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, color.Wrap(mode, "green", def.Name)))
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", g.itemColorize(sess, def, def.Name)))
+ } else {
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", qty, g.itemColorize(sess, def, def.Name)))
}
return
}
@@ -202,6 +200,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
if def != nil {
name = def.Name
}
+ coloredName := g.itemColorize(sess, def, name)
if def != nil && def.Stackable {
for i := 0; i < 28; i++ {
@@ -214,7 +213,7 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
}
slot.Quantity += removed
g.AccountStore.SaveCharacter(p)
- sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, name, slot.Quantity))
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s. (now %d)", removed, coloredName, slot.Quantity))
return
}
}
@@ -231,9 +230,9 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: removed})
g.AccountStore.SaveCharacter(p)
if removed == 1 {
- sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName))
} else {
- sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", removed, name))
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", removed, coloredName))
}
return
}
@@ -256,12 +255,12 @@ func (g *Game) doGetAllNamed(sess *net.Session, input string) {
if picked == 0 {
sess.WriteLine("Your inventory is full.")
} else if picked == 1 {
- sess.WriteLine(fmt.Sprintf("You pick up a %s.", name))
+ sess.WriteLine(fmt.Sprintf("You pick up a %s.", coloredName))
} else {
- sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, name))
+ sess.WriteLine(fmt.Sprintf("You pick up %d x %s.", picked, coloredName))
}
if picked < available {
- sess.WriteLine(fmt.Sprintf("You're only able to take %d x %s!", picked, name))
+ sess.WriteLine(fmt.Sprintf("You're only able to take %d x %s!", picked, coloredName))
}
}
diff --git a/internal/game/cmd_inventory.go b/internal/game/cmd_inventory.go
index 9cc4ac6..7017b18 100644
--- a/internal/game/cmd_inventory.go
+++ b/internal/game/cmd_inventory.go
@@ -26,10 +26,11 @@ func (g *Game) doInventory(sess *net.Session) {
if err == nil {
name = def.Name
}
+ coloredName := g.itemColorize(sess, def, name)
if slot.Quantity > 1 {
- sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, name))
+ sess.WriteLine(fmt.Sprintf(" %2d) %d x %s", num, slot.Quantity, coloredName))
} else {
- sess.WriteLine(fmt.Sprintf(" %2d) %s", num, name))
+ sess.WriteLine(fmt.Sprintf(" %2d) %s", num, coloredName))
}
}
sess.WriteLine(fmt.Sprintf(" (%d empty slots)", empty))
diff --git a/internal/game/cmd_look.go b/internal/game/cmd_look.go
index 992f3d0..bd738be 100644
--- a/internal/game/cmd_look.go
+++ b/internal/game/cmd_look.go
@@ -2,13 +2,14 @@ package game
import (
"fmt"
+ "regexp"
"sort"
"strconv"
"strings"
- "thirdcollapse/internal/color"
"thirdcollapse/internal/combat"
"thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
"thirdcollapse/internal/player"
"thirdcollapse/internal/world"
)
@@ -21,17 +22,20 @@ func (g *Game) doLook(sess *net.Session) {
return
}
- mode := g.colorMode(sess)
sess.WriteLines(
"",
- fmt.Sprintf("%s (#%d)", color.Wrap(mode, "cyan", room.Name), room.ID),
+ fmt.Sprintf("%s (%s)", g.colorize(sess, "room_name", room.Name), g.colorize(sess, "room_number", fmt.Sprintf("#%d", room.ID))),
)
descWidth := p.OptionInt("room_desc_width")
if descWidth <= 0 {
descWidth = 70
}
- descLines := wrapText(g.colorTag(sess, room.Description), descWidth)
+ rawLines := wrapText(room.Description, descWidth)
+ descLines := make([]string, len(rawLines))
+ for i, l := range rawLines {
+ descLines[i] = g.colorize(sess, "room_desc", l)
+ }
wroteDesc := false
if p.OptionString("tiny_map") != "off" {
mapLines := buildTinyMap(g, p.RoomID, mapGlyphsForPlayer(p.OptionBool("unicode")))
@@ -46,7 +50,7 @@ func (g *Game) doLook(sess *net.Session) {
}
}
- mobs := g.MobStore.MobsInRoom(p.RoomID)
+ mobs := g.MobStore.MobsInRoom(p.RoomID)
if len(mobs) > 0 {
sort.Slice(mobs, func(i, j int) bool {
iDamaged := mobs[i].HP < mobs[i].MaxHP
@@ -57,10 +61,11 @@ func (g *Game) doLook(sess *net.Session) {
return mobs[i].InstanceID < mobs[j].InstanceID
})
sess.WriteLine("")
+ playerLevel := p.CombatLevel()
for _, m := range mobs {
hp := ""
if m.HP < m.MaxHP {
- hp = fmt.Sprintf(" [%d/%dhp]", m.HP, m.MaxHP)
+ hp = fmt.Sprintf(" [%s/%dhp]", g.colorize(sess, "enemy_hp", fmt.Sprint(m.HP)), m.MaxHP)
}
var desc string
if combat.IsMobInCombat(m.InstanceID) {
@@ -77,7 +82,13 @@ func (g *Game) doLook(sess *net.Session) {
if !m.Unique {
displayName = "A " + m.Name
}
- sess.WriteLine(fmt.Sprintf(" %s (level %d)%s%s", displayName, mobCombatLevel(m), hp, desc))
+ npcColor := "hostile_npc"
+ if m.Protected {
+ npcColor = "friendly_npc"
+ }
+ mobLevel := mobCombatLevel(m)
+ levelStr := g.levelColorize(sess, playerLevel, mobLevel, fmt.Sprintf("(level %d)", mobLevel))
+ sess.WriteLine(fmt.Sprintf(" %s %s%s%s", g.colorize(sess, npcColor, displayName), levelStr, hp, desc))
}
}
@@ -138,15 +149,17 @@ func (g *Game) doLook(sess *net.Session) {
}
roomDesc := def.InRoomDescription
+ coloredName := g.objColorize(sess, def, def.Name)
+ coloredPlural := g.objColorize(sess, def, def.Name+"s")
if len(freshIdxs) > 0 {
var line string
if roomDesc != "" {
line = roomDesc
} else if len(freshIdxs) == 1 {
- line = "A " + def.Name + " is here."
+ line = fmt.Sprintf("A %s is here.", coloredName)
} else {
- line = fmt.Sprintf("%d %ss are here.", len(freshIdxs), def.Name)
+ line = fmt.Sprintf("%d %s are here.", len(freshIdxs), coloredPlural)
}
var suffix string
if multi && (len(timed) > 0 || len(depleted) > 0) {
@@ -164,7 +177,7 @@ func (g *Game) doLook(sess *net.Session) {
if roomDesc != "" {
line = roomDesc
} else {
- line = "A " + def.Name + " is here."
+ line = fmt.Sprintf("A %s is here.", coloredName)
}
tag := ""
if multi {
@@ -182,7 +195,7 @@ func (g *Game) doLook(sess *net.Session) {
if roomDesc != "" {
line = roomDesc
} else {
- line = "A " + def.Name + " is here."
+ line = fmt.Sprintf("A %s is here.", coloredName)
}
tag := ""
if multi {
@@ -227,14 +240,15 @@ func (g *Game) doLook(sess *net.Session) {
if err == nil {
name = def.Name
}
+ coloredName := g.itemColorize(sess, def, name)
prefix := ""
if info.Quantity > 1 {
- prefix = fmt.Sprintf(" %d x %s", info.Quantity, name)
+ prefix = fmt.Sprintf(" %d x %s", info.Quantity, coloredName)
} else {
- prefix = fmt.Sprintf(" %s", name)
+ prefix = fmt.Sprintf(" %s", coloredName)
}
- if len(prefix) > maxPrefix {
- maxPrefix = len(prefix)
+ if visibleLen(prefix) > maxPrefix {
+ maxPrefix = visibleLen(prefix)
}
var parts []string
if info.ReservedFor != "" {
@@ -256,7 +270,8 @@ func (g *Game) doLook(sess *net.Session) {
for _, l := range lines {
if l.reserved != "" {
- sess.WriteLine(fmt.Sprintf("%-*s%s", maxPrefix+1, l.prefix, l.reserved))
+ pad := maxPrefix + 1 + (len(l.prefix) - visibleLen(l.prefix))
+ sess.WriteLine(fmt.Sprintf("%-*s%s", pad, l.prefix, l.reserved))
} else {
sess.WriteLine(l.prefix)
}
@@ -265,22 +280,36 @@ func (g *Game) doLook(sess *net.Session) {
if len(room.Exits) > 0 {
sess.WriteLine("")
- if p.OptionBool("exits") {
+ if p.OptionBool("exits") {
sess.WriteLine("Exits:")
+ type exitLine struct {
+ dir string
+ targetName string
+ }
+ var lines []exitLine
+ maxDirLen := 0
for _, dir := range world.ExitOrder {
exitDef, ok := room.Exits[dir]
if !ok {
continue
}
+ coloredDir := g.colorize(sess, "exit_direction", string(dir))
targetRoom, err := g.World.LoadRoom(exitDef.Room)
- targetName := fmt.Sprintf("#%d", exitDef.Room)
+ targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room))
if err == nil {
- targetName = targetRoom.Name
+ targetName = g.colorize(sess, "exit_name", targetRoom.Name)
}
if exitDef.Condition != nil && !g.checkCondition(sess, exitDef.Condition) {
targetName += " (blocked)"
}
- sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
+ lines = append(lines, exitLine{coloredDir, targetName})
+ if visibleLen(coloredDir) > maxDirLen {
+ maxDirLen = visibleLen(coloredDir)
+ }
+ }
+ for _, l := range lines {
+ pad := maxDirLen + (len(l.dir) - visibleLen(l.dir))
+ sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.targetName))
}
} else {
sess.Write("Exits: ")
@@ -290,7 +319,7 @@ func (g *Game) doLook(sess *net.Session) {
if !first {
sess.Write(", ")
}
- sess.Write(string(dir))
+ sess.Write(g.colorize(sess, "exit_direction", string(dir)))
first = false
}
}
@@ -355,9 +384,15 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
}
if best != nil {
+ npcColor := "hostile_npc"
+ if best.Protected {
+ npcColor = "friendly_npc"
+ }
+ mobLevel := mobCombatLevel(best)
+ levelStr := g.levelColorize(sess, p.CombatLevel(), mobLevel, fmt.Sprintf("(level %d)", mobLevel))
sess.WriteLines(
"",
- fmt.Sprintf("%s (level %d)", best.Name, mobCombatLevel(best)),
+ fmt.Sprintf("%s %s", g.colorize(sess, npcColor, best.Name), levelStr),
)
if best.IdleDescription != "" {
sess.WriteLine(fmt.Sprintf(" %s", best.IdleDescription))
@@ -423,7 +458,7 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
sess.WriteLines(
"",
- def.Name,
+ g.itemColorize(sess, def, def.Name),
fmt.Sprintf(" %s", def.Description),
fmt.Sprintf(" Value: %d credits", def.Value),
)
@@ -469,10 +504,13 @@ func (g *Game) doLookTarget(sess *net.Session, input string) {
}
func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) {
+ myP := sess.Player.(*player.Player)
+ theirLevel := p.CombatLevel()
+ levelStr := g.levelColorize(sess, myP.CombatLevel(), theirLevel, fmt.Sprint(theirLevel))
sess.WriteLines(
"",
p.Name,
- fmt.Sprintf(" Combat Level: %d", p.CombatLevel()),
+ fmt.Sprintf(" Combat Level: %s", levelStr),
fmt.Sprintf(" HP: %d/%d", p.HP, p.MaxHP()),
"",
)
@@ -490,10 +528,12 @@ func showPlayerInfo(g *Game, sess *net.Session, p *player.Player) {
continue
}
name := itemID
+ var itemDef *object.ItemDef
if def, err := g.ItemStore.Load(itemID); err == nil {
name = def.Name
+ itemDef = def
}
- sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, name))
+ sess.WriteLine(fmt.Sprintf(" %-12s %s", slot, g.itemColorize(sess, itemDef, name)))
}
if p.Description != "" {
@@ -509,17 +549,31 @@ func (g *Game) doExits(sess *net.Session) {
sess.WriteLine("There are no exits here.")
return
}
+ type exitLine struct {
+ dir string
+ name string
+ }
+ var lines []exitLine
+ maxDirLen := 0
for _, dir := range world.ExitOrder {
exitDef, ok := room.Exits[dir]
if !ok {
continue
}
+ coloredDir := g.colorize(sess, "exit_direction", string(dir))
targetRoom, err := g.World.LoadRoom(exitDef.Room)
- targetName := fmt.Sprintf("#%d", exitDef.Room)
+ targetName := g.colorize(sess, "room_number", fmt.Sprintf("#%d", exitDef.Room))
if err == nil {
- targetName = targetRoom.Name
+ targetName = g.colorize(sess, "exit_name", targetRoom.Name)
+ }
+ lines = append(lines, exitLine{coloredDir, targetName})
+ if visibleLen(coloredDir) > maxDirLen {
+ maxDirLen = visibleLen(coloredDir)
}
- sess.WriteLine(fmt.Sprintf(" %-6s - %s", dir, targetName))
+ }
+ for _, l := range lines {
+ pad := maxDirLen + (len(l.dir) - visibleLen(l.dir))
+ sess.WriteLine(fmt.Sprintf(" %-*s - %s", pad, l.dir, l.name))
}
}
@@ -552,6 +606,12 @@ func mobInstanceIdx(mob *world.MobInstance, roomMobs []*world.MobInstance) int {
return 0
}
+var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
+
+func visibleLen(s string) int {
+ return len(ansiRe.ReplaceAllString(s, ""))
+}
+
func wrapText(text string, width int) []string {
if width <= 0 {
return []string{text}
@@ -563,7 +623,7 @@ func wrapText(text string, width int) []string {
var lines []string
current := words[0]
for _, word := range words[1:] {
- if len(current)+1+len(word) <= width {
+ if visibleLen(current)+1+visibleLen(word) <= width {
current += " " + word
} else {
lines = append(lines, current)
@@ -596,7 +656,11 @@ func (g *Game) writeLookSideBySide(sess *net.Session, p *player.Player, descLine
if leftMap {
sess.WriteLine(fmt.Sprintf("%s %s", mapLine, desc))
} else {
- sess.WriteLine(fmt.Sprintf("%-*s %s", mapWidth, desc, mapLine))
+ pad := mapWidth + (len(desc) - visibleLen(desc))
+ if pad < 0 {
+ pad = 0
+ }
+ sess.WriteLine(fmt.Sprintf("%-*s %s", pad, desc, mapLine))
}
}
}
diff --git a/internal/game/cmd_move.go b/internal/game/cmd_move.go
index 65a2875..11aa17e 100644
--- a/internal/game/cmd_move.go
+++ b/internal/game/cmd_move.go
@@ -3,7 +3,6 @@ package game
import (
"fmt"
- "thirdcollapse/internal/color"
"thirdcollapse/internal/combat"
"thirdcollapse/internal/net"
"thirdcollapse/internal/player"
@@ -65,8 +64,6 @@ func (g *Game) doMove(sess *net.Session, dir string) {
g.seedRoomMobs(p.RoomID)
g.seedRoomObjects(p.RoomID)
- mode := g.colorMode(sess)
-
if g.Hub != nil {
for _, other := range g.Hub.PlayersInRoom(oldRoom) {
if other != sess {
@@ -81,14 +78,14 @@ func (g *Game) doMove(sess *net.Session, dir string) {
}
}
- sess.WriteLine(fmt.Sprintf("\nYou walk %s.", color.Wrap(mode, "cyan", string(exitDir))))
+ sess.WriteLine(fmt.Sprintf("\nYou walk %s.", g.colorize(sess, "direction", string(exitDir))))
p.ActionState = &ActionState{Type: ActionMoving, Direction: string(exitDir)}
if p.OptionBool("description") {
g.doLook(sess)
} else {
targetRoom, _ := g.World.LoadRoom(targetID)
if targetRoom != nil {
- sess.WriteLine(color.Wrap(mode, "cyan", targetRoom.Name))
+ sess.WriteLine(g.colorize(sess, "room_name", targetRoom.Name))
}
}
diff --git a/internal/game/color.go b/internal/game/color.go
index 45df212..f93f097 100644
--- a/internal/game/color.go
+++ b/internal/game/color.go
@@ -2,7 +2,9 @@ package game
import (
"thirdcollapse/internal/color"
+ "thirdcollapse/internal/config"
"thirdcollapse/internal/net"
+ "thirdcollapse/internal/object"
"thirdcollapse/internal/player"
)
@@ -13,10 +15,66 @@ func (g *Game) colorMode(sess *net.Session) string {
return "none"
}
-func (g *Game) colorize(sess *net.Session, name, text string) string {
- return color.Wrap(g.colorMode(sess), name, text)
+func (g *Game) resolveColor(sess *net.Session, category string) color.ColorSpec {
+ if sess.Account != nil && sess.Account.Colors != nil {
+ if val, ok := sess.Account.Colors[category]; ok {
+ if val == "off" {
+ return color.ColorSpec{}
+ }
+ if val != "" {
+ return color.Parse(val)
+ }
+ }
+ }
+ if g.ColorConfig != nil {
+ if val, ok := (*g.ColorConfig)[category]; ok && val != "" {
+ return color.Parse(val)
+ }
+ }
+ if val, ok := config.DefaultColors()[category]; ok && val != "" {
+ return color.Parse(val)
+ }
+ return color.ColorSpec{}
+}
+
+func (g *Game) colorize(sess *net.Session, category, text string) string {
+ spec := g.resolveColor(sess, category)
+ return color.Render(g.colorMode(sess), spec, text)
+}
+
+func levelColorSpec(myLevel, theirLevel int) color.ColorSpec {
+ diff := theirLevel - myLevel
+ switch {
+ case diff == 0:
+ return color.Parse("fg=white")
+ case diff > 0 && diff < 5:
+ return color.Parse("fg=bright_yellow")
+ case diff >= 5:
+ return color.Parse("fg=bright_red")
+ case diff < 0 && diff > -5:
+ return color.Parse("fg=yellow")
+ default:
+ return color.Parse("fg=green")
+ }
+}
+
+func (g *Game) levelColorize(sess *net.Session, myLevel, theirLevel int, text string) string {
+ spec := levelColorSpec(myLevel, theirLevel)
+ return color.Render(g.colorMode(sess), spec, text)
}
-func (g *Game) colorTag(sess *net.Session, text string) string {
- return color.Tag(g.colorMode(sess), text)
+func (g *Game) objColorize(sess *net.Session, objDef *object.ObjectDef, text string) string {
+ if objDef != nil && objDef.Color != "" {
+ spec := color.Parse(objDef.Color)
+ return color.Render(g.colorMode(sess), spec, text)
+ }
+ return text
+}
+
+func (g *Game) itemColorize(sess *net.Session, itemDef *object.ItemDef, text string) string {
+ if itemDef != nil && itemDef.Color != "" {
+ spec := color.Parse(itemDef.Color)
+ return color.Render(g.colorMode(sess), spec, text)
+ }
+ return g.colorize(sess, "item", text)
}
diff --git a/internal/game/doc.go b/internal/game/doc.go
deleted file mode 100644
index e276994..0000000
--- a/internal/game/doc.go
+++ /dev/null
@@ -1,30 +0,0 @@
-// Package game is the core session handler, command dispatch, login flow,
-// and action system for the MUD.
-//
-// The Game struct holds all world state: room/world access, item/object
-// stores, mob instances, behavior definitions, the player hub, and the
-// tick engine. HandleSession routes incoming input through the login
-// state machine into the in-game command dispatch.
-//
-// Commands are dispatched via handleGameCommand in game.go, which
-// resolves account aliases before switching on the command word.
-// Most commands cancel the player's ongoing action (gathering, etc.).
-//
-// Action system: StartAction normalizes verbs, resolves object/mob
-// targets, loads behaviors, and routes to type-specific handlers.
-// Actions that take time (gather, use, burn, stoke) tick via
-// AdvanceActions, called each 600ms tick.
-//
-// Tick handlers: ProcessQueuedCommands, DisconnectTick, RegenTick,
-// WanderTick, SharedDepletionTick, and FireTick run each game tick to
-// advance world simulation.
-//
-// File organization:
-// cmd_*.go — player command handlers (doLook, doMove, doGet, etc.)
-// action_*.go — action lifecycle (startGather, advanceBurn, talk, toggle, etc.)
-// login.go — account creation, authentication, character management
-// map.go — BFS graph builder, tiny + full-map rendering
-// tick.go — per-tick world updates (disconnect, regen, wander, woodcutting)
-// utils.go — shared types and helper functions
-// help.go — help topic loading and display
-package game
diff --git a/internal/game/game.go b/internal/game/game.go
index 3c00d7e..456419f 100644
--- a/internal/game/game.go
+++ b/internal/game/game.go
@@ -9,6 +9,7 @@ import (
"thirdcollapse/internal/action"
"thirdcollapse/internal/combat"
+ "thirdcollapse/internal/config"
"thirdcollapse/internal/engine"
"thirdcollapse/internal/net"
"thirdcollapse/internal/object"
@@ -42,6 +43,7 @@ type Game struct {
Hub *net.Hub
Ticks *engine.Engine
WorldFlags map[string]any
+ ColorConfig *config.ColorsConfig
dataDir string
restTimers map[string]uint64
charsMu sync.Mutex
@@ -52,7 +54,7 @@ type Game struct {
pendingDepletions []pendingDepletion
}
-func New(dataDir string) *Game {
+func New(dataDir string, colorConfig *config.ColorsConfig) *Game {
return &Game{
World: world.New(dataDir),
ObjectStore: object.NewObjectStore(dataDir),
@@ -62,6 +64,7 @@ func New(dataDir string) *Game {
BehaviorStore: action.NewStore(dataDir),
Ticks: engine.New(),
WorldFlags: make(map[string]any),
+ ColorConfig: colorConfig,
dataDir: dataDir,
restTimers: make(map[string]uint64),
loggedInChars: make(map[string]*net.Session),
@@ -123,7 +126,7 @@ func classifyCommand(cmd string) CommandClass {
case "say", "score", "sc", "inventory", "i", "inv",
"equipment", "eq", "look", "l", "exits", "help",
"map", "option", "options", "alias", "unalias",
- "description", "desc", "queued":
+ "description", "desc", "queued", "color", "colors":
return ClassInstant
case "wear", "wield", "remove", "unwear", "unwield", "style":
return ClassFree
@@ -140,16 +143,6 @@ func classifyCommand(cmd string) CommandClass {
return ClassUnknown
}
-func (g *Game) promptStr(sess *net.Session) string {
- prompt := "> "
- if p, ok := sess.Player.(*player.Player); ok && p != nil {
- if custom := p.OptionString("prompt"); custom != "" {
- prompt = custom
- }
- }
- return g.colorTag(sess, prompt)
-}
-
func (g *Game) writePrompt(sess *net.Session) {
sess.Write("\r\n" + g.promptStr(sess))
}
@@ -307,6 +300,8 @@ func (g *Game) executeCommand(sess *net.Session, cmd string, args []string, rawI
g.doDescription(sess)
case "option", "options":
g.doOption(sess, strings.Join(args, " "))
+ case "color", "colors":
+ g.doColor(sess, strings.Join(args, " "))
case "exits":
g.doExits(sess)
case "map":
diff --git a/internal/game/help.go b/internal/game/help.go
index 2e9caa4..4d716ad 100644
--- a/internal/game/help.go
+++ b/internal/game/help.go
@@ -17,6 +17,47 @@ type HelpDef struct {
Content string `yaml:"description"`
}
+type cmdEntry struct {
+ Name string
+ Type string
+ Desc string
+}
+
+var commandList = []cmdEntry{
+ {"alias", "Instant", "Create command shortcuts"},
+ {"attack / kill", "Active", "Attack a mob"},
+ {"burn", "Active", "Start a fire"},
+ {"chop / cut", "Active", "Chop trees (Woodcutting)"},
+ {"color / colors", "Instant", "Customize display colors"},
+ {"description / desc", "Instant", "Set your character description"},
+ {"drop", "Active", "Drop items to the ground"},
+ {"equipment / eq", "Instant", "Show equipped items"},
+ {"exits", "Instant", "List available exits"},
+ {"fish", "Active", "Fish at fishing spots (Fishing)"},
+ {"get / take / pick", "Active", "Pick up items from the ground"},
+ {"help", "Instant", "Show help topics"},
+ {"inventory / i / inv", "Instant", "Show your inventory"},
+ {"look / l", "Instant", "Look around or examine things"},
+ {"map", "Instant", "Display an ASCII map of the area"},
+ {"mine", "Active", "Mine rocks (Mining)"},
+ {"north / south / east / west / up / down", "Active", "Move in a direction"},
+ {"option / options", "Instant", "View or change settings"},
+ {"pull / push", "Active", "Toggle levers and switches"},
+ {"queued", "Instant", "Show pending tick actions"},
+ {"quit", "Active", "Rest and disconnect"},
+ {"remove / unwear / unwield", "Free", "Unequip items"},
+ {"say", "Instant", "Chat with players in your room"},
+ {"score / sc", "Instant", "View your stats and skills"},
+ {"search", "Active", "Search items for loot"},
+ {"stoke", "Active", "Add logs to a fire"},
+ {"style", "Free", "Change combat style"},
+ {"talk / speak / ask", "Active", "Talk to NPCs"},
+ {"unalias", "Instant", "Remove command shortcuts"},
+ {"use", "Active", "Use an object (crafting)"},
+ {"walk", "Active", "Pathfind to a room or multi-step walk"},
+ {"wear / wield", "Free", "Equip items"},
+}
+
func LoadHelp(dataDir string) ([]HelpDef, error) {
dir := filepath.Join(dataDir, "help")
entries, err := os.ReadDir(dir)
@@ -43,39 +84,35 @@ func LoadHelp(dataDir string) ([]HelpDef, error) {
}
func (g *Game) doHelp(sess *net.Session, topic string) {
- helps, err := LoadHelp(g.dataDir)
- if err != nil || len(helps) == 0 {
- sess.WriteLine("No help available.")
- return
- }
-
if topic == "" {
- cats := make(map[string][]HelpDef)
- var catOrder []string
- for _, h := range helps {
- if _, ok := cats[h.Category]; !ok {
- catOrder = append(catOrder, h.Category)
- }
- cats[h.Category] = append(cats[h.Category], h)
- }
-
unicode := true
if p, _ := sess.Player.(*player.Player); p != nil {
unicode = p.OptionBool("unicode")
}
sess.WriteLine("")
- for _, cat := range catOrder {
- t := &Table{Title: cat}
- for _, h := range cats[cat] {
- t.Rows = append(t.Rows, []string{h.Name, firstLine(h.Content)})
- }
- for _, line := range t.Render(unicode) {
- sess.WriteLine(line)
- }
- sess.WriteLine("")
+ t := &Table{
+ Title: "Commands",
+ Columns: []string{"Command", "Type", "Description"},
+ }
+ for _, c := range commandList {
+ t.Rows = append(t.Rows, []string{c.Name, c.Type, c.Desc})
+ }
+ for _, line := range t.Render(unicode) {
+ sess.WriteLine(line)
}
- sess.WriteLine("Use 'help <command>' for details.")
+ sess.WriteLine("")
+ sess.WriteLine("Command types: Instant (runs immediately), Free (queued before active),")
+ sess.WriteLine("Active (replaces current action, queued per tick).")
+ sess.WriteLine("")
+ sess.WriteLine("Use 'help <command>' for detailed usage of a specific command.")
+ sess.WriteLine("Use 'option' to view and change settings, 'color' to customize colors.")
+ return
+ }
+
+ helps, err := LoadHelp(g.dataDir)
+ if err != nil || len(helps) == 0 {
+ sess.WriteLine("No help available.")
return
}
diff --git a/internal/game/login_account.go b/internal/game/login_account.go
index 7243955..3ad1fee 100644
--- a/internal/game/login_account.go
+++ b/internal/game/login_account.go
@@ -68,10 +68,14 @@ func (g *Game) handlePassword(sess *net.Session, input string) {
PasswordHash: acc.PasswordHash,
Characters: acc.Characters,
Aliases: acc.Aliases,
+ Colors: acc.Colors,
}
if sess.Account.Aliases == nil {
sess.Account.Aliases = make(map[string]string)
}
+ if sess.Account.Colors == nil {
+ sess.Account.Colors = make(map[string]string)
+ }
g.showMenu(sess)
}
diff --git a/internal/game/prompt.go b/internal/game/prompt.go
new file mode 100644
index 0000000..2fc36ac
--- /dev/null
+++ b/internal/game/prompt.go
@@ -0,0 +1,133 @@
+package game
+
+import (
+ "fmt"
+ "strings"
+
+ "thirdcollapse/internal/color"
+ "thirdcollapse/internal/combat"
+ "thirdcollapse/internal/net"
+ "thirdcollapse/internal/player"
+)
+
+func (g *Game) promptStr(sess *net.Session) string {
+ prompt := "> "
+ if p, ok := sess.Player.(*player.Player); ok && p != nil {
+ if custom := p.OptionString("prompt"); custom != "" {
+ prompt = custom
+ }
+ }
+
+ prompt = g.expandPromptColors(sess, prompt)
+ prompt = g.expandPromptVars(sess, prompt)
+ return prompt
+}
+
+func (g *Game) expandPromptColors(sess *net.Session, text string) string {
+ mode := g.colorMode(sess)
+ for {
+ start := strings.Index(text, "{")
+ if start < 0 {
+ break
+ }
+
+ end := strings.Index(text[start+1:], "}")
+ if end < 0 {
+ break
+ }
+ end += start + 1
+ specStr := text[start+1 : end]
+
+ if specStr == "" || specStr == "/" {
+ break
+ }
+
+ spec := color.Parse(specStr)
+ if spec.Empty() {
+ break
+ }
+
+ closeStart := strings.Index(text[end+1:], "{/}")
+ if closeStart < 0 {
+ break
+ }
+ closeStart += end + 1
+ closeEnd := closeStart + 3
+
+ inner := text[end+1 : closeStart]
+ colored := color.Render(mode, spec, inner)
+
+ text = text[:start] + colored + text[closeEnd:]
+ }
+ return text
+}
+
+func (g *Game) expandPromptVars(sess *net.Session, text string) string {
+ p, ok := sess.Player.(*player.Player)
+ if !ok || p == nil {
+ return text
+ }
+
+ text = strings.ReplaceAll(text, "%%", "\x00")
+ text = strings.ReplaceAll(text, "%h", fmt.Sprint(p.HP))
+ text = strings.ReplaceAll(text, "%H", fmt.Sprint(p.MaxHP()))
+ text = strings.ReplaceAll(text, "%c", fmt.Sprint(p.Credits))
+ text = strings.ReplaceAll(text, "%i", fmt.Sprint(p.FreeSlots()))
+ text = strings.ReplaceAll(text, "%s", attackStyleShort(p.AttackStyle))
+ text = strings.ReplaceAll(text, "%S", attackStyleLong(p.AttackStyle))
+
+ mobHP, mobMaxHP := "", ""
+ if cs := combat.GetCombat(p.Name); cs != nil {
+ mob := g.MobStore.GetInstance(cs.MobID)
+ if mob != nil && mob.HP > 0 {
+ mobHP = fmt.Sprint(mob.HP)
+ mobMaxHP = fmt.Sprint(mob.MaxHP)
+ }
+ }
+ text = strings.ReplaceAll(text, "%m", mobHP)
+ text = strings.ReplaceAll(text, "%M", mobMaxHP)
+
+ for _, sk := range player.AllSkills {
+ tag := string(sk)
+ text = strings.ReplaceAll(text, "%X_"+tag, fmt.Sprint(p.Skills[sk]))
+ text = strings.ReplaceAll(text, "%x_"+tag, fmt.Sprint(xpToLevel(p, sk)))
+ }
+
+ text = strings.ReplaceAll(text, "\x00", "%")
+ return text
+}
+
+func attackStyleShort(s player.AttackStyle) string {
+ switch s {
+ case player.Accurate:
+ return "acc"
+ case player.Aggressive:
+ return "agg"
+ case player.Defensive:
+ return "def"
+ case player.Balanced:
+ return "bal"
+ }
+ return "acc"
+}
+
+func attackStyleLong(s player.AttackStyle) string {
+ switch s {
+ case player.Accurate:
+ return "accurate"
+ case player.Aggressive:
+ return "aggressive"
+ case player.Defensive:
+ return "defensive"
+ case player.Balanced:
+ return "balanced"
+ }
+ return "accurate"
+}
+
+func xpToLevel(p *player.Player, sk player.SkillName) int {
+ currentXP := p.Skills[sk]
+ currentLevel := p.Level(sk)
+ nextLevelXP := player.XPForLevel(currentLevel + 1)
+ return nextLevelXP - currentXP
+}