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,

@ -389,6 +389,7 @@ func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase
ID: ph.Hero.ID,
Name: ph.Hero.Name,
Level: ph.Hero.Level,
ModelVariant: ph.Hero.ModelVariant,
PositionX: px,
PositionY: py,
}

@ -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{

@ -16,6 +16,7 @@ type Hero struct {
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"`
@ -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++

@ -289,6 +289,7 @@ type HeroMeetPartnerSnapshot struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
ModelVariant int `json:"modelVariant"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
}

@ -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)
@ -959,6 +971,7 @@ type HeroSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
ModelVariant int `json:"modelVariant"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
}
@ -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,
}))))
@ -1085,6 +1092,7 @@ export function App() {
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,
};

@ -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