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

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

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

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

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

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

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

@ -990,6 +990,7 @@ export class GameEngine {
this._heroDisplayY,
animPhase,
now,
state.hero.modelVariant ?? 0,
);
// 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)
const nearbyMerged = this._mergedNearbyHeroes(now);
if (nearbyMerged.length > 0) {
this.renderer.drawNearbyHeroes(nearbyMerged, now);
this.renderer.drawNearbyHeroes(nearbyMerged);
} else {
this.renderer.clearNearbyHeroes();
}
const meetOv = this._heroMeetOverlay;
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 {
this.renderer.clearMeetPartner();
}

@ -8,7 +8,6 @@ import { GameSpriteRegistry } from './assets/gameSpriteRegistry';
import {
buildingTypeToTextureKey,
heroTextureKey,
meetPartnerTextureKey,
npcTypeToTextureKey,
objectToTextureKey,
restCampTextureKeys,
@ -155,6 +154,8 @@ export class GameRenderer {
private _nearbyHeroGfx: Graphics | null = null;
private _nearbyHeroLabels: Text[] = [];
private _nearbyHeroLabelPool: Text[] = [];
private _nearbyHeroSpritePool = new Map<string, SpritePoolEntry>();
private _usedNearbyHeroSprites = new Set<string>();
private _lastEntitySortMs = 0;
private _entitySortIntervalMs = 120;
@ -894,11 +895,11 @@ export class GameRenderer {
/**
* 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;
if (!gfx) return;
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;
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.
*/
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 lbl = this._meetPartnerLabel;
if (!gfx || !lbl) return;
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;
let cy = iso.y;
@ -969,7 +970,7 @@ export class GameRenderer {
}
lbl.text = `${name} Lv.${level}`;
lbl.x = iso.x;
lbl.y = iso.y - 42;
lbl.y = iso.y + 10;
lbl.visible = true;
lbl.zIndex = cy + 199;
}
@ -1790,10 +1791,8 @@ export class GameRenderer {
Math.abs(iso.y - camY) > halfH
) 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 cy = iso.y + swayY;
const cy = iso.y;
// Shadow
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.
*/
drawNearbyHeroes(
heroes: ReadonlyArray<{ name: string; level: number; positionX: number; positionY: number }>,
now: number,
heroes: ReadonlyArray<{
id: number;
name: string;
level: number;
modelVariant: number;
positionX: number;
positionY: number;
}>,
): void {
const gfx = this._nearbyHeroGfx;
if (!gfx) return;
@ -2032,34 +2037,37 @@ export class GameRenderer {
}
let labelIdx = 0;
const usedNearbySprites = this._usedNearbyHeroSprites;
usedNearbySprites.clear();
for (const hero of heroes) {
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 cy = iso.y + swayY;
const cy = iso.y;
// Shadow
gfx.ellipse(cx, cy + 8, 10, 3);
gfx.fill({ color: 0x000000, alpha: 0.2 });
// Green diamond body (smaller than hero)
const s = 0.7;
gfx.poly([
cx, cy - 16 * s,
cx + 10 * s, cy,
cx, cy + 8 * s,
cx - 10 * s, cy,
]);
gfx.fill({ color: 0x44aa55, alpha: 0.55 });
gfx.stroke({ color: 0x2d7a3a, width: 1.2, alpha: 0.6 });
// Small head circle
gfx.circle(cx, cy - 14 * s, 4 * s);
gfx.fill({ color: 0x66cc77, alpha: 0.5 });
const textureKey = this._spritesReady ? heroTextureKey(hero.modelVariant ?? 0, 'south') : null;
const texture = textureKey ? this._spriteRegistry.getTexture(textureKey) : null;
if (textureKey && texture) {
const poolKey = `nearby_hero:${hero.id}`;
usedNearbySprites.add(poolKey);
const entry = this._ensureSprite(
this._nearbyHeroSpritePool,
poolKey,
textureKey,
texture,
this.entityLayer,
);
entry.sprite.x = cx;
entry.sprite.y = cy;
entry.sprite.scale.set(0.85);
entry.sprite.zIndex = cy + 100;
entry.sprite.visible = true;
}
// Label: "Name Lv.X"
let label: Text;
@ -2087,7 +2095,7 @@ export class GameRenderer {
label.text = `${hero.name} Lv.${hero.level}`;
label.x = cx;
label.y = cy - 22;
label.y = cy - 90;
label.visible = true;
label.zIndex = cy + 101;
labelIdx++;
@ -2095,6 +2103,8 @@ export class GameRenderer {
// Set gfx z-index for depth sorting
gfx.zIndex = cy + 100;
}
this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites);
}
/**
@ -2168,6 +2178,7 @@ export class GameRenderer {
for (const lbl of this._nearbyHeroLabels) {
lbl.visible = false;
}
this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet);
}
/** Sort entity layer by y-position for correct isometric depth */

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

@ -94,6 +94,7 @@ export interface HeroResponse {
id: number;
telegramId: number;
name: string;
modelVariant?: number;
hp: number;
maxHp: number;
attack: number;
@ -823,6 +824,7 @@ export interface NearbyHero {
id: number;
name: string;
level: number;
modelVariant?: number;
positionX: number;
positionY: number;
}
@ -830,7 +832,9 @@ export interface NearbyHero {
/** Fetch heroes near the player's current position */
export async function getNearbyHeroes(telegramId?: number): Promise<NearbyHero[]> {
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) ----

Loading…
Cancel
Save