diff options
| author | historia <[not public]> | 2026-06-16 01:59:27 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-06-16 01:59:27 -0400 |
| commit | 43f21aabae8924f35c12c8485e690aeefe0089fb (patch) | |
| tree | b5fbadb89df3cf4ffec86503bb8e2d7e52b27454 /internal/game | |
| parent | 4cc1ddcd185509080b4b3e15d1646567edd51c9d (diff) | |
| download | thehouseoficarus-43f21aabae8924f35c12c8485e690aeefe0089fb.tar.gz | |
feat: overhauled ansi color, added prompt options
Diffstat (limited to 'internal/game')
| -rw-r--r-- | internal/game/action_gather.go | 33 | ||||
| -rw-r--r-- | internal/game/action_talk.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_attack.go | 38 | ||||
| -rw-r--r-- | internal/game/cmd_color.go | 168 | ||||
| -rw-r--r-- | internal/game/cmd_drop.go | 15 | ||||
| -rw-r--r-- | internal/game/cmd_equipment.go | 2 | ||||
| -rw-r--r-- | internal/game/cmd_get.go | 23 | ||||
| -rw-r--r-- | internal/game/cmd_inventory.go | 5 | ||||
| -rw-r--r-- | internal/game/cmd_look.go | 124 | ||||
| -rw-r--r-- | internal/game/cmd_move.go | 7 | ||||
| -rw-r--r-- | internal/game/color.go | 66 | ||||
| -rw-r--r-- | internal/game/doc.go | 30 | ||||
| -rw-r--r-- | internal/game/game.go | 19 | ||||
| -rw-r--r-- | internal/game/help.go | 87 | ||||
| -rw-r--r-- | internal/game/login_account.go | 4 | ||||
| -rw-r--r-- | internal/game/prompt.go | 133 |
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 +} |
