nearby heroes

master
Denis Ranneft 1 month ago
parent 272d8a492c
commit c770f886da

@ -1818,7 +1818,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
victoryDrops = e.onEnemyDeath(hero, enemy, now) victoryDrops = e.onEnemyDeath(hero, enemy, now)
} }
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{ e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{
MonstersKilled: 1, MonstersKilled: 1,
@ -1829,7 +1828,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
}) })
cancel() cancel()
e.emitEvent(model.CombatEvent{ e.emitEvent(model.CombatEvent{
Type: "combat_end", Type: "combat_end",
HeroID: cs.HeroID, HeroID: cs.HeroID,

@ -386,11 +386,12 @@ func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase
hm.SyncToHero() hm.SyncToHero()
px, py := ph.Hero.PositionX, ph.Hero.PositionY px, py := ph.Hero.PositionX, ph.Hero.PositionY
partner := model.HeroMeetPartnerSnapshot{ partner := model.HeroMeetPartnerSnapshot{
ID: ph.Hero.ID, ID: ph.Hero.ID,
Name: ph.Hero.Name, Name: ph.Hero.Name,
Level: ph.Hero.Level, Level: ph.Hero.Level,
PositionX: px, ModelVariant: ph.Hero.ModelVariant,
PositionY: py, PositionX: px,
PositionY: py,
} }
anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid) anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid)
var promptEnds *time.Time var promptEnds *time.Time

@ -1618,15 +1618,15 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
return return
} }
// Default radius: 500 units, max 50 heroes. // Default radius: 50 units, max 5 heroes.
radius := 500.0 radius := 50.0
if rStr := r.URL.Query().Get("radius"); rStr != "" { if rStr := r.URL.Query().Get("radius"); rStr != "" {
if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 { if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 {
radius = parsed radius = parsed
} }
} }
if radius > 2000 { if radius > 100 {
radius = 2000 radius = 100
} }
posX, posY := hero.PositionX, hero.PositionY posX, posY := hero.PositionX, hero.PositionY
@ -1636,7 +1636,7 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
} }
} }
nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 50) nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 5)
if err != nil { if err != nil {
h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err) h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{

@ -13,57 +13,58 @@ const (
) )
type Hero struct { type Hero struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TelegramID int64 `json:"telegramId"` TelegramID int64 `json:"telegramId"`
Name string `json:"name"` Name string `json:"name"`
HP int `json:"hp"` ModelVariant int `json:"modelVariant"`
MaxHP int `json:"maxHp"` HP int `json:"hp"`
Attack int `json:"attack"` MaxHP int `json:"maxHp"`
Defense int `json:"defense"` Attack int `json:"attack"`
Speed float64 `json:"speed"` // attacks per second base rate Defense int `json:"defense"`
Strength int `json:"strength"` Speed float64 `json:"speed"` // attacks per second base rate
Constitution int `json:"constitution"` Strength int `json:"strength"`
Agility int `json:"agility"` Constitution int `json:"constitution"`
Luck int `json:"luck"` Agility int `json:"agility"`
State GameState `json:"state"` Luck int `json:"luck"`
State GameState `json:"state"`
Gear map[EquipmentSlot]*GearItem `json:"gear"` Gear map[EquipmentSlot]*GearItem `json:"gear"`
// Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots. // Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots.
Inventory []*GearItem `json:"inventory,omitempty"` Inventory []*GearItem `json:"inventory,omitempty"`
Buffs []ActiveBuff `json:"buffs,omitempty"` Buffs []ActiveBuff `json:"buffs,omitempty"`
Debuffs []ActiveDebuff `json:"debuffs,omitempty"` Debuffs []ActiveDebuff `json:"debuffs,omitempty"`
// DebuffCatalog is effective debuff definitions (durations from live catalog); not persisted. // DebuffCatalog is effective debuff definitions (durations from live catalog); not persisted.
DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"` DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"`
Gold int64 `json:"gold"` Gold int64 `json:"gold"`
XP int64 `json:"xp"` XP int64 `json:"xp"`
Level int `json:"level"` Level int `json:"level"`
XPToNext int64 `json:"xpToNext"` XPToNext int64 `json:"xpToNext"`
AttackSpeed float64 `json:"attackSpeed,omitempty"` AttackSpeed float64 `json:"attackSpeed,omitempty"`
AttackPower int `json:"attackPower,omitempty"` AttackPower int `json:"attackPower,omitempty"`
DefensePower int `json:"defensePower,omitempty"` DefensePower int `json:"defensePower,omitempty"`
MoveSpeed float64 `json:"moveSpeed,omitempty"` MoveSpeed float64 `json:"moveSpeed,omitempty"`
PositionX float64 `json:"positionX"` PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"` PositionY float64 `json:"positionY"`
Potions int `json:"potions"` Potions int `json:"potions"`
ReviveCount int `json:"reviveCount"` ReviveCount int `json:"reviveCount"`
SubscriptionActive bool `json:"subscriptionActive"` SubscriptionActive bool `json:"subscriptionActive"`
SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"` SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"`
// Deprecated: BuffFreeChargesRemaining is the legacy shared counter. Use BuffCharges instead. // Deprecated: BuffFreeChargesRemaining is the legacy shared counter. Use BuffCharges instead.
BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"` BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"`
// Deprecated: BuffQuotaPeriodEnd is the legacy shared period end. Use BuffCharges instead. // Deprecated: BuffQuotaPeriodEnd is the legacy shared period end. Use BuffCharges instead.
BuffQuotaPeriodEnd *time.Time `json:"buffQuotaPeriodEnd,omitempty"` BuffQuotaPeriodEnd *time.Time `json:"buffQuotaPeriodEnd,omitempty"`
// BuffCharges holds per-buff-type free charge state (remaining count + period window). // BuffCharges holds per-buff-type free charge state (remaining count + period window).
BuffCharges map[string]BuffChargeState `json:"buffCharges"` BuffCharges map[string]BuffChargeState `json:"buffCharges"`
// Stat tracking for achievements. // Stat tracking for achievements.
TotalKills int `json:"totalKills"` TotalKills int `json:"totalKills"`
EliteKills int `json:"eliteKills"` EliteKills int `json:"eliteKills"`
TotalDeaths int `json:"totalDeaths"` TotalDeaths int `json:"totalDeaths"`
KillsSinceDeath int `json:"killsSinceDeath"` KillsSinceDeath int `json:"killsSinceDeath"`
LegendaryDrops int `json:"legendaryDrops"` LegendaryDrops int `json:"legendaryDrops"`
// Movement state (persisted to DB for reconnect recovery). // Movement state (persisted to DB for reconnect recovery).
CurrentTownID *int64 `json:"currentTownId,omitempty"` CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"` DestinationTownID *int64 `json:"destinationTownId,omitempty"`
RestKind RestKind `json:"restKind,omitempty"` RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise. // ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"` ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
// ExcursionKind is "roadside" | "adventure" | "town" during attractor-based sessions; empty otherwise. // ExcursionKind is "roadside" | "adventure" | "town" during attractor-based sessions; empty otherwise.
@ -80,9 +81,9 @@ type Hero struct {
// WsDisconnectedAt is when the last WebSocket session ended (DB only; optional telemetry). // WsDisconnectedAt is when the last WebSocket session ended (DB only; optional telemetry).
WsDisconnectedAt *time.Time `json:"-"` WsDisconnectedAt *time.Time `json:"-"`
// ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only). // ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
ChangelogAckVersion string `json:"-"` ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// BuffChargeState tracks the remaining free charges and period window for a single buff type. // BuffChargeState tracks the remaining free charges and period window for a single buff type.
@ -139,7 +140,7 @@ func (h *Hero) LevelUp() bool {
h.MaxHP += hpBase + h.Constitution/6 h.MaxHP += hpBase + h.Constitution/6
} }
if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 { if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack ++ h.Attack++
} }
if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 { if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 {
h.Defense++ h.Defense++
@ -162,29 +163,29 @@ func (h *Hero) LevelUp() bool {
} }
type statBonuses struct { type statBonuses struct {
strengthBonus int strengthBonus int
constitutionBonus int constitutionBonus int
agilityBonus int agilityBonus int
attackMultiplier float64 attackMultiplier float64
speedMultiplier float64 speedMultiplier float64
defenseMultiplier float64 defenseMultiplier float64
critChanceBonus float64 critChanceBonus float64
critDamageBonus float64 critDamageBonus float64
blockChanceBonus float64 blockChanceBonus float64
movementMultiplier float64 movementMultiplier float64
} }
func (h *Hero) activeStatBonuses(now time.Time) statBonuses { func (h *Hero) activeStatBonuses(now time.Time) statBonuses {
out := statBonuses{ out := statBonuses{
strengthBonus: 0, strengthBonus: 0,
constitutionBonus: 0, constitutionBonus: 0,
agilityBonus: 0, agilityBonus: 0,
attackMultiplier: 1.0, attackMultiplier: 1.0,
speedMultiplier: 1.0, speedMultiplier: 1.0,
defenseMultiplier: 1.0, defenseMultiplier: 1.0,
critChanceBonus: 0.0, critChanceBonus: 0.0,
critDamageBonus: 0.0, critDamageBonus: 0.0,
blockChanceBonus: 0.0, blockChanceBonus: 0.0,
movementMultiplier: 1.0, movementMultiplier: 1.0,
} }
for _, ab := range h.Buffs { for _, ab := range h.Buffs {

@ -286,11 +286,12 @@ type ExcursionEndPayload struct{}
// HeroMeetPartnerSnapshot is the other hero for UI (render + name). // HeroMeetPartnerSnapshot is the other hero for UI (render + name).
type HeroMeetPartnerSnapshot struct { type HeroMeetPartnerSnapshot struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Level int `json:"level"` Level int `json:"level"`
PositionX float64 `json:"positionX"` ModelVariant int `json:"modelVariant"`
PositionY float64 `json:"positionY"` PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
} }
// HeroMeetStartPayload begins a paired meet session (server → client). // HeroMeetStartPayload begins a paired meet session (server → client).

@ -21,7 +21,7 @@ import (
// Gear is loaded separately via GearStore.GetHeroGear after the hero row is loaded. // Gear is loaded separately via GearStore.GetHeroGear after the hero row is loaded.
const heroSelectQuery = ` const heroSelectQuery = `
SELECT SELECT
h.id, h.telegram_id, h.name, h.id, h.telegram_id, h.name, h.hero_model_variant,
h.hp, h.max_hp, h.attack, h.defense, h.speed, h.hp, h.max_hp, h.attack, h.defense, h.speed,
h.strength, h.constitution, h.agility, h.luck, h.strength, h.constitution, h.agility, h.luck,
h.state, h.state,
@ -265,12 +265,14 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.CreatedAt = now hero.CreatedAt = now
hero.UpdatedAt = now hero.UpdatedAt = now
hero.ModelVariant = randomHeroModelVariant()
buffChargesJSON := marshalBuffCharges(hero.BuffCharges) buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
query := ` query := `
INSERT INTO heroes ( INSERT INTO heroes (
telegram_id, name, telegram_id, name,
hero_model_variant,
hp, max_hp, attack, defense, speed, hp, max_hp, attack, defense, speed,
strength, constitution, agility, luck, strength, constitution, agility, luck,
state, state,
@ -284,22 +286,24 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
current_town_id, destination_town_id current_town_id, destination_town_id
) VALUES ( ) VALUES (
$1, $2, $1, $2,
$3, $4, $5, $6, $7, $3,
$8, $9, $10, $11, $4, $5, $6, $7, $8,
$12, $9, $10, $11, $12,
$13, $14, $15, $13,
$16, $17, $18, $14, $15, $16,
$19, $20, $21, $17, $18, $19,
$22, $23, $24, $20, $21, $22,
$25, $26, $27, $28, $29, $23, $24, $25,
$30, $26, $27, $28, $29, $30,
$31, $32, $31,
$33, $34 $32, $33,
$34, $35
) RETURNING id ) RETURNING id
` `
err := s.pool.QueryRow(ctx, query, err := s.pool.QueryRow(ctx, query,
hero.TelegramID, hero.Name, hero.TelegramID, hero.Name,
hero.ModelVariant,
hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), string(hero.State),
@ -553,26 +557,28 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
query := ` query := `
UPDATE heroes SET UPDATE heroes SET
hp = $1, max_hp = $2, hero_model_variant = $1,
attack = $3, defense = $4, speed = $5, hp = $2, max_hp = $3,
strength = $6, constitution = $7, agility = $8, luck = $9, attack = $4, defense = $5, speed = $6,
state = $10, strength = $7, constitution = $8, agility = $9, luck = $10,
gold = $11, xp = $12, level = $13, state = $11,
revive_count = $14, subscription_active = $15, subscription_expires_at = $16, gold = $12, xp = $13, level = $14,
buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19, revive_count = $15, subscription_active = $16, subscription_expires_at = $17,
position_x = $20, position_y = $21, potions = $22, buff_free_charges_remaining = $18, buff_quota_period_end = $19, buff_charges = $20,
total_kills = $23, elite_kills = $24, total_deaths = $25, position_x = $21, position_y = $22, potions = $23,
kills_since_death = $26, legendary_drops = $27, total_kills = $24, elite_kills = $25, total_deaths = $26,
last_online_at = $28, kills_since_death = $27, legendary_drops = $28,
updated_at = $29, last_online_at = $29,
destination_town_id = $30, updated_at = $30,
current_town_id = $31, destination_town_id = $31,
town_pause = $32 current_town_id = $32,
WHERE id = $33 town_pause = $33
WHERE id = $34
` `
townPauseJSON := marshalTownPause(hero.TownPause) townPauseJSON := marshalTownPause(hero.TownPause)
tag, err := s.pool.Exec(ctx, query, tag, err := s.pool.Exec(ctx, query,
hero.ModelVariant,
hero.HP, hero.MaxHP, hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
@ -701,7 +707,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
var townPauseRaw []byte var townPauseRaw []byte
err := rows.Scan( err := rows.Scan(
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name, &h.ModelVariant,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &state,
@ -720,6 +726,9 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
} }
h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
h.State = model.GameState(state) h.State = model.GameState(state)
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
h.Gear = make(map[model.EquipmentSlot]*model.GearItem) h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw) h.TownPause = unmarshalTownPause(townPauseRaw)
@ -736,7 +745,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
var townPauseRaw []byte var townPauseRaw []byte
err := row.Scan( err := row.Scan(
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name, &h.ModelVariant,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &state,
@ -758,6 +767,9 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
} }
h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
h.State = model.GameState(state) h.State = model.GameState(state)
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
h.Gear = make(map[model.EquipmentSlot]*model.GearItem) h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw) h.TownPause = unmarshalTownPause(townPauseRaw)
@ -956,11 +968,12 @@ func unmarshalBuffCharges(raw []byte) map[string]model.BuffChargeState {
// HeroSummary is a lightweight projection of a hero for nearby-heroes queries. // HeroSummary is a lightweight projection of a hero for nearby-heroes queries.
type HeroSummary struct { type HeroSummary struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Level int `json:"level"` Level int `json:"level"`
PositionX float64 `json:"positionX"` ModelVariant int `json:"modelVariant"`
PositionY float64 `json:"positionY"` PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
} }
// UpdateOnlineStatus updates last_online_at and position for shared-world presence. // UpdateOnlineStatus updates last_online_at and position for shared-world presence.
@ -977,24 +990,21 @@ func (s *HeroStore) UpdateOnlineStatus(ctx context.Context, heroID int64, posX,
// GetNearbyHeroes returns other heroes within radius who were online recently (< 2 min). // GetNearbyHeroes returns other heroes within radius who were online recently (< 2 min).
func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, posY, radius float64, limit int) ([]HeroSummary, error) { func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, posY, radius float64, limit int) ([]HeroSummary, error) {
if limit <= 0 { if limit < 1 {
limit = 20 limit = 1
} }
if limit > 100 { if limit > 5 {
limit = 100 limit = 5
} }
cutoff := time.Now().Add(-2 * time.Minute)
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, name, level, position_x, position_y SELECT id, name, level, hero_model_variant, position_x, position_y
FROM heroes FROM heroes
WHERE id != $1 WHERE id != $1
AND last_online_at > $2 AND sqrt(power(position_x - $2, 2) + power(position_y - $3, 2)) <= $4
AND sqrt(power(position_x - $3, 2) + power(position_y - $4, 2)) <= $5
ORDER BY last_online_at DESC ORDER BY last_online_at DESC
LIMIT $6 LIMIT $5
`, heroID, cutoff, posX, posY, radius, limit) `, heroID, posX, posY, radius, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("get nearby heroes: %w", err) return nil, fmt.Errorf("get nearby heroes: %w", err)
} }
@ -1003,9 +1013,12 @@ func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, pos
var heroes []HeroSummary var heroes []HeroSummary
for rows.Next() { for rows.Next() {
var h HeroSummary var h HeroSummary
if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.PositionX, &h.PositionY); err != nil { if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.ModelVariant, &h.PositionX, &h.PositionY); err != nil {
return nil, fmt.Errorf("scan nearby hero: %w", err) return nil, fmt.Errorf("scan nearby hero: %w", err)
} }
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
heroes = append(heroes, h) heroes = append(heroes, h)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -1043,6 +1056,10 @@ func (s *HeroStore) CreatePayment(ctx context.Context, p *model.Payment) error {
).Scan(&p.ID) ).Scan(&p.ID)
} }
func randomHeroModelVariant() int {
return rand.Intn(model.HeroModelVariantMax-model.HeroModelVariantMin+1) + model.HeroModelVariantMin
}
func derefStr(p *string) string { func derefStr(p *string) string {
if p == nil { if p == nil {
return "" return ""

@ -1,5 +1,5 @@
{ {
"version": 27, "version": 32,
"assetsRoot": "frontend/assets", "assetsRoot": "frontend/assets",
"note": "file paths relative to frontend/assets. Rest camp: prop.camp_tent/fire/bag.v0 (wild rest). Other props + heroes + NPC.", "note": "file paths relative to frontend/assets. Rest camp: prop.camp_tent/fire/bag.v0 (wild rest). Other props + heroes + NPC.",
"textures": { "textures": {
@ -903,152 +903,182 @@
"enemy.battle_lizard_l7_8_meadow.south": { "enemy.battle_lizard_l7_8_meadow.south": {
"file": "enemies/enemy.battle_lizard_l7_8_meadow.south.png", "file": "enemies/enemy.battle_lizard_l7_8_meadow.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "178ab49f-dbf7-4dc9-904b-e7c44af5597e"
}, },
"enemy.battle_lizard_l7_8_forest.south": { "enemy.battle_lizard_l7_8_forest.south": {
"file": "enemies/enemy.battle_lizard_l7_8_forest.south.png", "file": "enemies/enemy.battle_lizard_l7_8_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "6c523a79-a9c7-442a-a443-94777a826694"
}, },
"enemy.battle_lizard_l9_10_forest.south": { "enemy.battle_lizard_l9_10_forest.south": {
"file": "enemies/enemy.battle_lizard_l9_10_forest.south.png", "file": "enemies/enemy.battle_lizard_l9_10_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "8186ea7f-4b01-472b-b5f7-efe63ef02b09"
}, },
"enemy.battle_lizard_l9_10_ruins.south": { "enemy.battle_lizard_l9_10_ruins.south": {
"file": "enemies/enemy.battle_lizard_l9_10_ruins.south.png", "file": "enemies/enemy.battle_lizard_l9_10_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "3cca65eb-26d5-4f1f-884d-d222a4316f97"
}, },
"enemy.battle_lizard_l11_12_ruins.south": { "enemy.battle_lizard_l11_12_ruins.south": {
"file": "enemies/enemy.battle_lizard_l11_12_ruins.south.png", "file": "enemies/enemy.battle_lizard_l11_12_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "80552aec-a742-4d69-922a-89be636c6313"
}, },
"enemy.battle_lizard_l11_12_canyon.south": { "enemy.battle_lizard_l11_12_canyon.south": {
"file": "enemies/enemy.battle_lizard_l11_12_canyon.south.png", "file": "enemies/enemy.battle_lizard_l11_12_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "b45dfcee-a428-479f-9160-127a1f2fc27b"
}, },
"enemy.battle_lizard_l13_14_canyon.south": { "enemy.battle_lizard_l13_14_canyon.south": {
"file": "enemies/enemy.battle_lizard_l13_14_canyon.south.png", "file": "enemies/enemy.battle_lizard_l13_14_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "6e77f976-98e3-449f-9bf3-e222be0166e2"
}, },
"enemy.battle_lizard_l13_14_swamp.south": { "enemy.battle_lizard_l13_14_swamp.south": {
"file": "enemies/enemy.battle_lizard_l13_14_swamp.south.png", "file": "enemies/enemy.battle_lizard_l13_14_swamp.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "f44d0028-a0a1-4c71-b85c-dd104fc61a24"
}, },
"enemy.battle_lizard_l15_15_volcanic.south": { "enemy.battle_lizard_l15_15_volcanic.south": {
"file": "enemies/enemy.battle_lizard_l15_15_volcanic.south.png", "file": "enemies/enemy.battle_lizard_l15_15_volcanic.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "7ccc658e-fae3-41f9-a600-730f58d2aeb3"
}, },
"enemy.battle_lizard_l15_15_astral.south": { "enemy.battle_lizard_l15_15_astral.south": {
"file": "enemies/enemy.battle_lizard_l15_15_astral.south.png", "file": "enemies/enemy.battle_lizard_l15_15_astral.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "32c8c165-d92c-47d5-b997-bcb15f391bea"
}, },
"enemy.element_l18_20_meadow.south": { "enemy.element_l18_20_meadow.south": {
"file": "enemies/enemy.element_l18_20_meadow.south.png", "file": "enemies/enemy.element_l18_20_meadow.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "a9205f81-3a5e-4e21-ab29-e902064abc9f"
}, },
"enemy.element_l12_14_forest.south": { "enemy.element_l12_14_forest.south": {
"file": "enemies/enemy.element_l12_14_forest.south.png", "file": "enemies/enemy.element_l12_14_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "7d2ca2d6-ee37-4f6e-848a-7b2c8596a69e"
}, },
"enemy.element_l21_22_forest.south": { "enemy.element_l21_22_forest.south": {
"file": "enemies/enemy.element_l21_22_forest.south.png", "file": "enemies/enemy.element_l21_22_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "da0c0b8b-a2cd-4a02-b2e7-f554e49be4ca"
}, },
"enemy.element_l15_16_ruins.south": { "enemy.element_l15_16_ruins.south": {
"file": "enemies/enemy.element_l15_16_ruins.south.png", "file": "enemies/enemy.element_l15_16_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "5ffa8eee-5672-4286-bcb8-f92691c4a1f3"
}, },
"enemy.element_l23_24_ruins.south": { "enemy.element_l23_24_ruins.south": {
"file": "enemies/enemy.element_l23_24_ruins.south.png", "file": "enemies/enemy.element_l23_24_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "5ab2150e-c992-4713-9b3e-10c0c0a9bcc8"
}, },
"enemy.element_l17_18_canyon.south": { "enemy.element_l17_18_canyon.south": {
"file": "enemies/enemy.element_l17_18_canyon.south.png", "file": "enemies/enemy.element_l17_18_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "ddb676da-c342-4094-8022-e100892b1ccf"
}, },
"enemy.element_l25_26_canyon.south": { "enemy.element_l25_26_canyon.south": {
"file": "enemies/enemy.element_l25_26_canyon.south.png", "file": "enemies/enemy.element_l25_26_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "6afe13a2-e96e-48bd-bb97-b1c0f850ea54"
}, },
"enemy.element_l19_20_swamp.south": { "enemy.element_l19_20_swamp.south": {
"file": "enemies/enemy.element_l19_20_swamp.south.png", "file": "enemies/enemy.element_l19_20_swamp.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "472ba2aa-6178-44a6-b385-6902ef30de7a"
}, },
"enemy.element_l27_28_volcanic.south": { "enemy.element_l27_28_volcanic.south": {
"file": "enemies/enemy.element_l27_28_volcanic.south.png", "file": "enemies/enemy.element_l27_28_volcanic.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "cc984511-8ee9-4b24-9a27-87298da7f052"
}, },
"enemy.element_l21_22_astral.south": { "enemy.element_l21_22_astral.south": {
"file": "enemies/enemy.element_l21_22_astral.south.png", "file": "enemies/enemy.element_l21_22_astral.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "63a41e4c-e608-4333-afcf-03fab592293f"
}, },
"enemy.demon_l10_12_meadow.south": { "enemy.demon_l10_12_meadow.south": {
"file": "enemies/enemy.demon_l10_12_meadow.south.png", "file": "enemies/enemy.demon_l10_12_meadow.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "4e7e8204-5668-4a5b-88f8-eb915bd5414c"
}, },
"enemy.demon_l10_12_forest.south": { "enemy.demon_l10_12_forest.south": {
"file": "enemies/enemy.demon_l10_12_forest.south.png", "file": "enemies/enemy.demon_l10_12_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "842a964e-3f57-4dbd-a923-c39d2f9f4764"
}, },
"enemy.demon_l13_14_forest.south": { "enemy.demon_l13_14_forest.south": {
"file": "enemies/enemy.demon_l13_14_forest.south.png", "file": "enemies/enemy.demon_l13_14_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "9a0ce33f-4807-437c-8bdc-f0bc055d8cf6"
}, },
"enemy.demon_l13_14_ruins.south": { "enemy.demon_l13_14_ruins.south": {
"file": "enemies/enemy.demon_l13_14_ruins.south.png", "file": "enemies/enemy.demon_l13_14_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "ea2338c9-cfe1-4e46-a46c-5bb5c2f640a3"
}, },
"enemy.demon_l15_16_ruins.south": { "enemy.demon_l15_16_ruins.south": {
"file": "enemies/enemy.demon_l15_16_ruins.south.png", "file": "enemies/enemy.demon_l15_16_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "a6d012c6-24af-4de7-b282-64cffea42ed5"
}, },
"enemy.demon_l15_16_canyon.south": { "enemy.demon_l15_16_canyon.south": {
"file": "enemies/enemy.demon_l15_16_canyon.south.png", "file": "enemies/enemy.demon_l15_16_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "6e71fa5a-877c-4453-93e8-2e7a9c3bc84c"
}, },
"enemy.demon_l17_18_canyon.south": { "enemy.demon_l17_18_canyon.south": {
"file": "enemies/enemy.demon_l17_18_canyon.south.png", "file": "enemies/enemy.demon_l17_18_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "0aeebaae-8202-4232-9987-c5040a0349e2"
}, },
"enemy.demon_l17_18_swamp.south": { "enemy.demon_l17_18_swamp.south": {
"file": "enemies/enemy.demon_l17_18_swamp.south.png", "file": "enemies/enemy.demon_l17_18_swamp.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "1254d0fc-1e97-496c-9a42-b5d0b911e69e"
}, },
"enemy.demon_l19_20_volcanic.south": { "enemy.demon_l19_20_volcanic.south": {
"file": "enemies/enemy.demon_l19_20_volcanic.south.png", "file": "enemies/enemy.demon_l19_20_volcanic.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "ee10f4e6-cbd1-4614-8de6-064c38fd5b07"
}, },
"enemy.demon_l19_20_astral.south": { "enemy.demon_l19_20_astral.south": {
"file": "enemies/enemy.demon_l19_20_astral.south.png", "file": "enemies/enemy.demon_l19_20_astral.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "ed0cc675-e3c9-497d-a200-66e8250529b9"
}, },
"enemy.skeleton_king_l15_17_meadow.south": { "enemy.skeleton_king_l15_17_meadow.south": {
"file": "enemies/enemy.skeleton_king_l15_17_meadow.south.png", "file": "enemies/enemy.skeleton_king_l15_17_meadow.south.png",
@ -1103,52 +1133,62 @@
"enemy.forest_warden_l20_22_meadow.south": { "enemy.forest_warden_l20_22_meadow.south": {
"file": "enemies/enemy.forest_warden_l20_22_meadow.south.png", "file": "enemies/enemy.forest_warden_l20_22_meadow.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "97fcaa46-cfb2-4fc8-960e-361f26cdbcf9"
}, },
"enemy.forest_warden_l20_22_forest.south": { "enemy.forest_warden_l20_22_forest.south": {
"file": "enemies/enemy.forest_warden_l20_22_forest.south.png", "file": "enemies/enemy.forest_warden_l20_22_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "c94c42a6-49bc-46b1-ad59-92e8a0a36efe"
}, },
"enemy.forest_warden_l23_24_forest.south": { "enemy.forest_warden_l23_24_forest.south": {
"file": "enemies/enemy.forest_warden_l23_24_forest.south.png", "file": "enemies/enemy.forest_warden_l23_24_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "051886bc-aa90-4506-a19b-5573fc32bc82"
}, },
"enemy.forest_warden_l23_24_ruins.south": { "enemy.forest_warden_l23_24_ruins.south": {
"file": "enemies/enemy.forest_warden_l23_24_ruins.south.png", "file": "enemies/enemy.forest_warden_l23_24_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "b8a2179b-6776-484b-ac48-1e248837de3f"
}, },
"enemy.forest_warden_l25_26_ruins.south": { "enemy.forest_warden_l25_26_ruins.south": {
"file": "enemies/enemy.forest_warden_l25_26_ruins.south.png", "file": "enemies/enemy.forest_warden_l25_26_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "e50f50b1-b43e-4cc4-9251-7181b044850c"
}, },
"enemy.forest_warden_l25_26_canyon.south": { "enemy.forest_warden_l25_26_canyon.south": {
"file": "enemies/enemy.forest_warden_l25_26_canyon.south.png", "file": "enemies/enemy.forest_warden_l25_26_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "416e283b-b09f-4970-81d6-ca3fb85decc6"
}, },
"enemy.forest_warden_l27_28_canyon.south": { "enemy.forest_warden_l27_28_canyon.south": {
"file": "enemies/enemy.forest_warden_l27_28_canyon.south.png", "file": "enemies/enemy.forest_warden_l27_28_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "17ee0740-2c27-404d-ba56-e66ecd76acfb"
}, },
"enemy.forest_warden_l27_28_swamp.south": { "enemy.forest_warden_l27_28_swamp.south": {
"file": "enemies/enemy.forest_warden_l27_28_swamp.south.png", "file": "enemies/enemy.forest_warden_l27_28_swamp.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "de1c3fef-2f91-44c0-b080-f4750f48e6f6"
}, },
"enemy.forest_warden_l29_30_volcanic.south": { "enemy.forest_warden_l29_30_volcanic.south": {
"file": "enemies/enemy.forest_warden_l29_30_volcanic.south.png", "file": "enemies/enemy.forest_warden_l29_30_volcanic.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "453a1ba7-434e-42ac-89de-5d93e604cad8"
}, },
"enemy.forest_warden_l29_30_astral.south": { "enemy.forest_warden_l29_30_astral.south": {
"file": "enemies/enemy.forest_warden_l29_30_astral.south.png", "file": "enemies/enemy.forest_warden_l29_30_astral.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "9fa1b822-692a-41d6-a8f1-a9f9bc58227f"
}, },
"enemy.titan_l25_27_meadow.south": { "enemy.titan_l25_27_meadow.south": {
"file": "enemies/enemy.titan_l25_27_meadow.south.png", "file": "enemies/enemy.titan_l25_27_meadow.south.png",
@ -1203,52 +1243,62 @@
"enemy.golem_l8_10_meadow.south": { "enemy.golem_l8_10_meadow.south": {
"file": "enemies/enemy.golem_l8_10_meadow.south.png", "file": "enemies/enemy.golem_l8_10_meadow.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "d56fc0ce-b6a2-4fd7-b528-e2c2cf30a40c"
}, },
"enemy.golem_l8_10_forest.south": { "enemy.golem_l8_10_forest.south": {
"file": "enemies/enemy.golem_l8_10_forest.south.png", "file": "enemies/enemy.golem_l8_10_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "1126edff-9084-49dd-9c05-a6901f57cfb3"
}, },
"enemy.golem_l11_12_forest.south": { "enemy.golem_l11_12_forest.south": {
"file": "enemies/enemy.golem_l11_12_forest.south.png", "file": "enemies/enemy.golem_l11_12_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "8528a01d-515b-4c6f-85e4-ba3d218bc07f"
}, },
"enemy.golem_l11_12_ruins.south": { "enemy.golem_l11_12_ruins.south": {
"file": "enemies/enemy.golem_l11_12_ruins.south.png", "file": "enemies/enemy.golem_l11_12_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "0a29a08a-bedd-44bb-b286-d4023f66d4aa"
}, },
"enemy.golem_l13_14_ruins.south": { "enemy.golem_l13_14_ruins.south": {
"file": "enemies/enemy.golem_l13_14_ruins.south.png", "file": "enemies/enemy.golem_l13_14_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "3ecbed81-0459-490b-8160-339dd6efdba8"
}, },
"enemy.golem_l13_14_canyon.south": { "enemy.golem_l13_14_canyon.south": {
"file": "enemies/enemy.golem_l13_14_canyon.south.png", "file": "enemies/enemy.golem_l13_14_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "eb7de5d0-5083-4ae7-b6c0-43adc96129a2"
}, },
"enemy.golem_l15_16_canyon.south": { "enemy.golem_l15_16_canyon.south": {
"file": "enemies/enemy.golem_l15_16_canyon.south.png", "file": "enemies/enemy.golem_l15_16_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "bf4969f6-46b0-4847-bf15-1bba1edc850f"
}, },
"enemy.golem_l15_16_swamp.south": { "enemy.golem_l15_16_swamp.south": {
"file": "enemies/enemy.golem_l15_16_swamp.south.png", "file": "enemies/enemy.golem_l15_16_swamp.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "41294b8f-6dcf-41cc-b32c-6128d9740ecc"
}, },
"enemy.golem_l17_18_volcanic.south": { "enemy.golem_l17_18_volcanic.south": {
"file": "enemies/enemy.golem_l17_18_volcanic.south.png", "file": "enemies/enemy.golem_l17_18_volcanic.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "cf5139fa-d186-4e4d-a576-6ec5afa7994f"
}, },
"enemy.golem_l17_18_astral.south": { "enemy.golem_l17_18_astral.south": {
"file": "enemies/enemy.golem_l17_18_astral.south.png", "file": "enemies/enemy.golem_l17_18_astral.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "e5021b18-29ec-457a-9e35-5a6340228a85"
}, },
"enemy.wraith_l5_6_meadow.south": { "enemy.wraith_l5_6_meadow.south": {
"file": "enemies/enemy.wraith_l5_6_meadow.south.png", "file": "enemies/enemy.wraith_l5_6_meadow.south.png",
@ -1363,52 +1413,62 @@
"enemy.cultist_l6_8_meadow.south": { "enemy.cultist_l6_8_meadow.south": {
"file": "enemies/enemy.cultist_l6_8_meadow.south.png", "file": "enemies/enemy.cultist_l6_8_meadow.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "2c8d4a66-1a87-4727-b85f-a09cc3e35b7d"
}, },
"enemy.cultist_l6_8_forest.south": { "enemy.cultist_l6_8_forest.south": {
"file": "enemies/enemy.cultist_l6_8_forest.south.png", "file": "enemies/enemy.cultist_l6_8_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "581ad2b6-aede-4e9c-a97a-ffea83f6de3b"
}, },
"enemy.cultist_l9_10_forest.south": { "enemy.cultist_l9_10_forest.south": {
"file": "enemies/enemy.cultist_l9_10_forest.south.png", "file": "enemies/enemy.cultist_l9_10_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "b45ef21e-05a0-463e-837f-bc2c7de2c526"
}, },
"enemy.cultist_l9_10_ruins.south": { "enemy.cultist_l9_10_ruins.south": {
"file": "enemies/enemy.cultist_l9_10_ruins.south.png", "file": "enemies/enemy.cultist_l9_10_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "caba4624-9ed8-41b3-adc9-6a5119ae5eb7"
}, },
"enemy.cultist_l11_12_ruins.south": { "enemy.cultist_l11_12_ruins.south": {
"file": "enemies/enemy.cultist_l11_12_ruins.south.png", "file": "enemies/enemy.cultist_l11_12_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "5468b86d-836b-439d-b2a3-39397f17d8a8"
}, },
"enemy.cultist_l11_12_canyon.south": { "enemy.cultist_l11_12_canyon.south": {
"file": "enemies/enemy.cultist_l11_12_canyon.south.png", "file": "enemies/enemy.cultist_l11_12_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "18c1d500-b2b6-43fa-8b67-8fc02d200a35"
}, },
"enemy.cultist_l13_14_canyon.south": { "enemy.cultist_l13_14_canyon.south": {
"file": "enemies/enemy.cultist_l13_14_canyon.south.png", "file": "enemies/enemy.cultist_l13_14_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "f8f9d60c-722e-4f8e-b0f4-31e3deed413d"
}, },
"enemy.cultist_l13_14_swamp.south": { "enemy.cultist_l13_14_swamp.south": {
"file": "enemies/enemy.cultist_l13_14_swamp.south.png", "file": "enemies/enemy.cultist_l13_14_swamp.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "94bca324-2d16-4ecc-866a-8e7cf9139787"
}, },
"enemy.cultist_l15_16_volcanic.south": { "enemy.cultist_l15_16_volcanic.south": {
"file": "enemies/enemy.cultist_l15_16_volcanic.south.png", "file": "enemies/enemy.cultist_l15_16_volcanic.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "ad3d8ecc-cc7e-4086-8128-66b077d0399e"
}, },
"enemy.cultist_l15_16_astral.south": { "enemy.cultist_l15_16_astral.south": {
"file": "enemies/enemy.cultist_l15_16_astral.south.png", "file": "enemies/enemy.cultist_l15_16_astral.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "0c4d44c4-83c9-4e8e-943b-8ac380ad9782"
}, },
"enemy.treant_l18_20_meadow.south": { "enemy.treant_l18_20_meadow.south": {
"file": "enemies/enemy.treant_l18_20_meadow.south.png", "file": "enemies/enemy.treant_l18_20_meadow.south.png",
@ -1463,12 +1523,14 @@
"enemy.basilisk_l9_11_meadow.south": { "enemy.basilisk_l9_11_meadow.south": {
"file": "enemies/enemy.basilisk_l9_11_meadow.south.png", "file": "enemies/enemy.basilisk_l9_11_meadow.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "9cfcca7a-4206-4366-9d5a-e831a0cd29af"
}, },
"enemy.basilisk_l9_11_forest.south": { "enemy.basilisk_l9_11_forest.south": {
"file": "enemies/enemy.basilisk_l9_11_forest.south.png", "file": "enemies/enemy.basilisk_l9_11_forest.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "c8a953a2-e5a1-4e66-bb2f-46f1d21cc6eb"
}, },
"enemy.basilisk_l12_13_forest.south": { "enemy.basilisk_l12_13_forest.south": {
"file": "enemies/enemy.basilisk_l12_13_forest.south.png", "file": "enemies/enemy.basilisk_l12_13_forest.south.png",
@ -1509,12 +1571,14 @@
"enemy.basilisk_l18_19_volcanic.south": { "enemy.basilisk_l18_19_volcanic.south": {
"file": "enemies/enemy.basilisk_l18_19_volcanic.south.png", "file": "enemies/enemy.basilisk_l18_19_volcanic.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "3c36859c-e53d-4cbf-979f-caac5ed03331"
}, },
"enemy.basilisk_l18_19_astral.south": { "enemy.basilisk_l18_19_astral.south": {
"file": "enemies/enemy.basilisk_l18_19_astral.south.png", "file": "enemies/enemy.basilisk_l18_19_astral.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "89600a0a-059e-4e58-8a28-1660c2363097"
}, },
"enemy.wyvern_l12_14_meadow.south": { "enemy.wyvern_l12_14_meadow.south": {
"file": "enemies/enemy.wyvern_l12_14_meadow.south.png", "file": "enemies/enemy.wyvern_l12_14_meadow.south.png",
@ -1589,12 +1653,14 @@
"enemy.harpy_l10_11_ruins.south": { "enemy.harpy_l10_11_ruins.south": {
"file": "enemies/enemy.harpy_l10_11_ruins.south.png", "file": "enemies/enemy.harpy_l10_11_ruins.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "b9dd0946-76cf-432e-bfac-b57719dcc1bf"
}, },
"enemy.harpy_l10_11_canyon.south": { "enemy.harpy_l10_11_canyon.south": {
"file": "enemies/enemy.harpy_l10_11_canyon.south.png", "file": "enemies/enemy.harpy_l10_11_canyon.south.png",
"kind": "map_object", "kind": "map_object",
"rotation": "south" "rotation": "south",
"pixellabObjectId": "726fa3a3-5654-4cc3-a19b-ab2c8292b288"
}, },
"enemy.harpy_l12_13_canyon.south": { "enemy.harpy_l12_13_canyon.south": {
"file": "enemies/enemy.harpy_l12_13_canyon.south.png", "file": "enemies/enemy.harpy_l12_13_canyon.south.png",

@ -172,6 +172,10 @@ function xpToNextLevel(level: number): number {
return Math.round(23000 * Math.pow(1.10, level - 30)); return Math.round(23000 * Math.pow(1.10, level - 30));
} }
function normalizeHeroModelVariant(v: unknown): number {
return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 2 ? v : 0;
}
/** Map backend buffCharges (keyed by string) into typed Partial<Record<BuffType, BuffChargeState>>. */ /** Map backend buffCharges (keyed by string) into typed Partial<Record<BuffType, BuffChargeState>>. */
function mapBuffCharges( function mapBuffCharges(
raw: Record<string, { remaining: number; periodEnd: string | null }> | undefined, raw: Record<string, { remaining: number; periodEnd: string | null }> | undefined,
@ -299,6 +303,7 @@ function heroResponseToState(res: HeroResponse): HeroState {
const now = Date.now(); const now = Date.now();
return { return {
id: res.id, id: res.id,
modelVariant: normalizeHeroModelVariant(res.modelVariant),
hp: res.hp, hp: res.hp,
maxHp: res.maxHp, maxHp: res.maxHp,
position: { x: res.positionX ?? 0, y: res.positionY ?? 0 }, position: { x: res.positionX ?? 0, y: res.positionY ?? 0 },
@ -827,6 +832,7 @@ export function App() {
id: h.id, id: h.id,
name: h.name, name: h.name,
level: h.level, level: h.level,
modelVariant: normalizeHeroModelVariant(h.modelVariant),
positionX: h.positionX, positionX: h.positionX,
positionY: h.positionY, positionY: h.positionY,
})))) }))))
@ -837,6 +843,7 @@ export function App() {
id: h.id, id: h.id,
name: h.name, name: h.name,
level: h.level, level: h.level,
modelVariant: normalizeHeroModelVariant(h.modelVariant),
positionX: h.positionX, positionX: h.positionX,
positionY: h.positionY, positionY: h.positionY,
})))) }))))
@ -1082,12 +1089,13 @@ export function App() {
onHeroMeetStart: (p) => { onHeroMeetStart: (p) => {
const partner: NearbyHeroData = { const partner: NearbyHeroData = {
id: p.partner.id, id: p.partner.id,
name: p.partner.name, name: p.partner.name,
level: p.partner.level, level: p.partner.level,
positionX: p.partner.positionX, modelVariant: normalizeHeroModelVariant(p.partner.modelVariant),
positionY: p.partner.positionY, positionX: p.partner.positionX,
}; positionY: p.partner.positionY,
};
engine.setHeroMeetOverlay(partner); engine.setHeroMeetOverlay(partner);
const phase = p.meetPhase; const phase = p.meetPhase;
const showChat = phase === 'meet' || phase === undefined; const showChat = phase === 'meet' || phase === undefined;

@ -1,7 +1,6 @@
export const ROAD_SPRITE_KEY = 'terrain.road.v1'; export const ROAD_SPRITE_KEY = 'terrain.road.v1';
const DEFAULT_TERRAIN_KEY = 'terrain.grass.v1'; const DEFAULT_TERRAIN_KEY = 'terrain.grass.v1';
const HERO_SPRITE_KEY = 'hero.player.v1.south'; const HERO_MODEL_VARIANTS = [0, 1, 2] as const;
const MEET_PARTNER_SPRITE_KEY = 'hero.meet_partner';
/** Wilderness rest: separate props (transparent PNG), tent ~30px art height. */ /** Wilderness rest: separate props (transparent PNG), tent ~30px art height. */
export const CAMP_TENT_TEXTURE_KEY = 'prop.camp_tent.v0'; export const CAMP_TENT_TEXTURE_KEY = 'prop.camp_tent.v0';
@ -75,12 +74,11 @@ export function buildingTypeToTextureKey(buildingType: string): string | null {
return BUILDING_TEXTURE_BY_TYPE[buildingType] ?? null; return BUILDING_TEXTURE_BY_TYPE[buildingType] ?? null;
} }
export function heroTextureKey(): string { export function heroTextureKey(modelVariant: number, facing: 'south' | 'north'): string {
return HERO_SPRITE_KEY; const variant = Number.isInteger(modelVariant) && modelVariant >= 0 && modelVariant <= 2
} ? modelVariant
: 0;
export function meetPartnerTextureKey(): string { return `hero.player.v${variant}.${facing}`;
return MEET_PARTNER_SPRITE_KEY;
} }
export function restCampTextureKeys(): [string, string, string] { export function restCampTextureKeys(): [string, string, string] {
@ -106,8 +104,7 @@ export function getRequiredSpriteKeys(): string[] {
...objectKeys, ...objectKeys,
...npcKeys, ...npcKeys,
...buildingKeys, ...buildingKeys,
HERO_SPRITE_KEY, ...HERO_MODEL_VARIANTS.flatMap((v) => [`hero.player.v${v}.south`, `hero.player.v${v}.north`]),
MEET_PARTNER_SPRITE_KEY,
CAMP_TENT_TEXTURE_KEY, CAMP_TENT_TEXTURE_KEY,
CAMP_FIRE_TEXTURE_KEY, CAMP_FIRE_TEXTURE_KEY,
CAMP_BAG_TEXTURE_KEY, CAMP_BAG_TEXTURE_KEY,

@ -990,6 +990,7 @@ export class GameEngine {
this._heroDisplayY, this._heroDisplayY,
animPhase, animPhase,
now, now,
state.hero.modelVariant ?? 0,
); );
// Thought bubble during rest/town pauses // Thought bubble during rest/town pauses
@ -1024,14 +1025,21 @@ export class GameEngine {
// Draw nearby heroes from the shared world (meet partner uses full sprite below, not diamonds) // Draw nearby heroes from the shared world (meet partner uses full sprite below, not diamonds)
const nearbyMerged = this._mergedNearbyHeroes(now); const nearbyMerged = this._mergedNearbyHeroes(now);
if (nearbyMerged.length > 0) { if (nearbyMerged.length > 0) {
this.renderer.drawNearbyHeroes(nearbyMerged, now); this.renderer.drawNearbyHeroes(nearbyMerged);
} else { } else {
this.renderer.clearNearbyHeroes(); this.renderer.clearNearbyHeroes();
} }
const meetOv = this._heroMeetOverlay; const meetOv = this._heroMeetOverlay;
if (meetOv) { if (meetOv) {
this.renderer.drawMeetPartner(meetOv.positionX, meetOv.positionY, meetOv.name, meetOv.level, now); this.renderer.drawMeetPartner(
meetOv.positionX,
meetOv.positionY,
meetOv.name,
meetOv.level,
now,
meetOv.modelVariant ?? 0,
);
} else { } else {
this.renderer.clearMeetPartner(); this.renderer.clearMeetPartner();
} }

@ -8,7 +8,6 @@ import { GameSpriteRegistry } from './assets/gameSpriteRegistry';
import { import {
buildingTypeToTextureKey, buildingTypeToTextureKey,
heroTextureKey, heroTextureKey,
meetPartnerTextureKey,
npcTypeToTextureKey, npcTypeToTextureKey,
objectToTextureKey, objectToTextureKey,
restCampTextureKeys, restCampTextureKeys,
@ -155,6 +154,8 @@ export class GameRenderer {
private _nearbyHeroGfx: Graphics | null = null; private _nearbyHeroGfx: Graphics | null = null;
private _nearbyHeroLabels: Text[] = []; private _nearbyHeroLabels: Text[] = [];
private _nearbyHeroLabelPool: Text[] = []; private _nearbyHeroLabelPool: Text[] = [];
private _nearbyHeroSpritePool = new Map<string, SpritePoolEntry>();
private _usedNearbyHeroSprites = new Set<string>();
private _lastEntitySortMs = 0; private _lastEntitySortMs = 0;
private _entitySortIntervalMs = 120; private _entitySortIntervalMs = 120;
@ -894,11 +895,11 @@ export class GameRenderer {
/** /**
* Draw the hero as a compact adventurer (silhouette + cape + blade) with bob / combat flash. * Draw the hero as a compact adventurer (silhouette + cape + blade) with bob / combat flash.
*/ */
drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number): void { drawHero(wx: number, wy: number, phase: 'walk' | 'fight' | 'idle', now: number, modelVariant: number): void {
const gfx = this._heroGfx; const gfx = this._heroGfx;
if (!gfx) return; if (!gfx) return;
const iso = worldToScreen(wx, wy); const iso = worldToScreen(wx, wy);
const textureKey = this._spritesReady ? heroTextureKey() : null; const textureKey = this._spritesReady ? heroTextureKey(modelVariant, 'south') : null;
const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null; const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
let cy = iso.y; let cy = iso.y;
@ -937,12 +938,12 @@ export class GameRenderer {
/** /**
* Meet opponent: same figure as the main hero, idle stance, distinct tint + name label. * Meet opponent: same figure as the main hero, idle stance, distinct tint + name label.
*/ */
drawMeetPartner(wx: number, wy: number, name: string, level: number, now: number): void { drawMeetPartner(wx: number, wy: number, name: string, level: number, now: number, modelVariant: number): void {
const gfx = this._meetPartnerGfx; const gfx = this._meetPartnerGfx;
const lbl = this._meetPartnerLabel; const lbl = this._meetPartnerLabel;
if (!gfx || !lbl) return; if (!gfx || !lbl) return;
const iso = worldToScreen(wx, wy); const iso = worldToScreen(wx, wy);
const textureKey = this._spritesReady ? meetPartnerTextureKey() : null; const textureKey = this._spritesReady ? heroTextureKey(modelVariant, 'north') : null;
const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null; const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
let cy = iso.y; let cy = iso.y;
@ -969,7 +970,7 @@ export class GameRenderer {
} }
lbl.text = `${name} Lv.${level}`; lbl.text = `${name} Lv.${level}`;
lbl.x = iso.x; lbl.x = iso.x;
lbl.y = iso.y - 42; lbl.y = iso.y + 10;
lbl.visible = true; lbl.visible = true;
lbl.zIndex = cy + 199; lbl.zIndex = cy + 199;
} }
@ -1790,10 +1791,8 @@ export class GameRenderer {
Math.abs(iso.y - camY) > halfH Math.abs(iso.y - camY) > halfH
) continue; ) continue;
// Idle sway based on NPC id
const swayY = Math.sin(now * 0.002 + npc.id * 1.7) * 2.5;
const cx = iso.x; const cx = iso.x;
const cy = iso.y + swayY; const cy = iso.y;
// Shadow // Shadow
gfx.ellipse(cx, cy + 8, 10, 3.5); gfx.ellipse(cx, cy + 8, 10, 3.5);
@ -2015,12 +2014,18 @@ export class GameRenderer {
} }
/** /**
* Draw nearby heroes as semi-transparent green diamonds with name + level labels. * Draw nearby heroes using player sprites + name/level labels.
* Each hero gets a subtle idle sway animation. * Each hero gets a subtle idle sway animation.
*/ */
drawNearbyHeroes( drawNearbyHeroes(
heroes: ReadonlyArray<{ name: string; level: number; positionX: number; positionY: number }>, heroes: ReadonlyArray<{
now: number, id: number;
name: string;
level: number;
modelVariant: number;
positionX: number;
positionY: number;
}>,
): void { ): void {
const gfx = this._nearbyHeroGfx; const gfx = this._nearbyHeroGfx;
if (!gfx) return; if (!gfx) return;
@ -2032,34 +2037,37 @@ export class GameRenderer {
} }
let labelIdx = 0; let labelIdx = 0;
const usedNearbySprites = this._usedNearbyHeroSprites;
usedNearbySprites.clear();
for (const hero of heroes) { for (const hero of heroes) {
const iso = worldToScreen(hero.positionX, hero.positionY); const iso = worldToScreen(hero.positionX, hero.positionY);
// Idle sway: use a per-hero offset based on name hash
const hashOffset = hero.name.length * 1.37;
const swayY = Math.sin(now * 0.003 + hashOffset) * 2;
const cx = iso.x; const cx = iso.x;
const cy = iso.y + swayY; const cy = iso.y;
// Shadow // Shadow
gfx.ellipse(cx, cy + 8, 10, 3); gfx.ellipse(cx, cy + 8, 10, 3);
gfx.fill({ color: 0x000000, alpha: 0.2 }); gfx.fill({ color: 0x000000, alpha: 0.2 });
// Green diamond body (smaller than hero) const textureKey = this._spritesReady ? heroTextureKey(hero.modelVariant ?? 0, 'south') : null;
const s = 0.7; const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
gfx.poly([ if (textureKey && texture) {
cx, cy - 16 * s, const poolKey = `nearby_hero:${hero.id}`;
cx + 10 * s, cy, usedNearbySprites.add(poolKey);
cx, cy + 8 * s, const entry = this._ensureSprite(
cx - 10 * s, cy, this._nearbyHeroSpritePool,
]); poolKey,
gfx.fill({ color: 0x44aa55, alpha: 0.55 }); textureKey,
gfx.stroke({ color: 0x2d7a3a, width: 1.2, alpha: 0.6 }); texture,
this.entityLayer,
// Small head circle );
gfx.circle(cx, cy - 14 * s, 4 * s); entry.sprite.x = cx;
gfx.fill({ color: 0x66cc77, alpha: 0.5 }); entry.sprite.y = cy;
entry.sprite.scale.set(0.85);
entry.sprite.zIndex = cy + 100;
entry.sprite.visible = true;
}
// Label: "Name Lv.X" // Label: "Name Lv.X"
let label: Text; let label: Text;
@ -2087,7 +2095,7 @@ export class GameRenderer {
label.text = `${hero.name} Lv.${hero.level}`; label.text = `${hero.name} Lv.${hero.level}`;
label.x = cx; label.x = cx;
label.y = cy - 22; label.y = cy - 90;
label.visible = true; label.visible = true;
label.zIndex = cy + 101; label.zIndex = cy + 101;
labelIdx++; labelIdx++;
@ -2095,6 +2103,8 @@ export class GameRenderer {
// Set gfx z-index for depth sorting // Set gfx z-index for depth sorting
gfx.zIndex = cy + 100; gfx.zIndex = cy + 100;
} }
this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites);
} }
/** /**
@ -2168,6 +2178,7 @@ export class GameRenderer {
for (const lbl of this._nearbyHeroLabels) { for (const lbl of this._nearbyHeroLabels) {
lbl.visible = false; lbl.visible = false;
} }
this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet);
} }
/** Sort entity layer by y-position for correct isometric depth */ /** Sort entity layer by y-position for correct isometric depth */

@ -129,6 +129,7 @@ export interface Position {
export interface HeroState { export interface HeroState {
id: number; id: number;
modelVariant: number;
hp: number; hp: number;
maxHp: number; maxHp: number;
position: Position; position: Position;
@ -460,6 +461,7 @@ export interface NearbyHeroData {
id: number; id: number;
name: string; name: string;
level: number; level: number;
modelVariant: number;
positionX: number; positionX: number;
positionY: number; positionY: number;
} }
@ -673,6 +675,7 @@ export interface HeroMeetPartnerSnapshot {
id: number; id: number;
name: string; name: string;
level: number; level: number;
modelVariant: number;
positionX: number; positionX: number;
positionY: number; positionY: number;
} }

@ -94,6 +94,7 @@ export interface HeroResponse {
id: number; id: number;
telegramId: number; telegramId: number;
name: string; name: string;
modelVariant?: number;
hp: number; hp: number;
maxHp: number; maxHp: number;
attack: number; attack: number;
@ -823,6 +824,7 @@ export interface NearbyHero {
id: number; id: number;
name: string; name: string;
level: number; level: number;
modelVariant?: number;
positionX: number; positionX: number;
positionY: number; positionY: number;
} }
@ -830,7 +832,9 @@ export interface NearbyHero {
/** Fetch heroes near the player's current position */ /** Fetch heroes near the player's current position */
export async function getNearbyHeroes(telegramId?: number): Promise<NearbyHero[]> { export async function getNearbyHeroes(telegramId?: number): Promise<NearbyHero[]> {
const query = telegramId != null ? `?telegramId=${telegramId}` : ''; const query = telegramId != null ? `?telegramId=${telegramId}` : '';
return apiGet<NearbyHero[]>(`/hero/nearby${query}`); const raw = await apiGet<{ heroes?: NearbyHero[] } | NearbyHero[]>(`/hero/nearby${query}`);
if (Array.isArray(raw)) return raw;
return raw.heroes ?? [];
} }
// ---- Daily Tasks (backend-driven, spec 10.1) ---- // ---- Daily Tasks (backend-driven, spec 10.1) ----

Loading…
Cancel
Save