new admin rest methods + art bible

master
Denis Ranneft 1 month ago
parent d2d7cc88ab
commit b5544c04f4

@ -0,0 +1,89 @@
# =========================
# Node / TypeScript
# =========================
node_modules/
.pnpm-store/
.npm/
.yarn/
.yarn-cache/
.yarn/unplugged/
# Build outputs
dist/
build/
out/
.next/
.nuxt/
.svelte-kit/
coverage/
*.tsbuildinfo
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
# Env files
.env
.env.*
!.env.example
# =========================
# Go
# =========================
# Binaries
bin/
*.exe
*.out
*.test
# Build artifacts
build/
dist/
# Go workspace / modules
go.work.sum
# Vendor (optional — include if large)
vendor/
# Coverage
coverage.out
*.coverprofile
# =========================
# General / Shared
# =========================
# OS
.DS_Store
Thumbs.db
# Editors / IDE
.vscode/
.idea/
*.swp
*.swo
# Cache / temp
.cache/
tmp/
temp/
*.tmp
*.temp
# Docker
*.pid
*.seed
# Archives / large files
*.zip
*.tar
*.gz
*.rar
*.7z
# Misc
*.bak
*.log

@ -0,0 +1,537 @@
package game
import (
"testing"
"time"
"github.com/denisovdennis/autohero/internal/model"
"github.com/denisovdennis/autohero/internal/tuning"
)
// testGraph builds a minimal two-town road graph for movement tests.
func testGraph() *RoadGraph {
townA := &model.Town{ID: 1, Name: "TownA", WorldX: 0, WorldY: 0, Radius: 0.5}
townB := &model.Town{ID: 2, Name: "TownB", WorldX: 100, WorldY: 0, Radius: 0.5}
road := &Road{
ID: 1, FromTownID: 1, ToTownID: 2,
Waypoints: []Point{{0, 0}, {50, 0}, {100, 0}},
Distance: 100,
}
roadBack := &Road{
ID: 2, FromTownID: 2, ToTownID: 1,
Waypoints: []Point{{100, 0}, {50, 0}, {0, 0}},
Distance: 100,
}
return &RoadGraph{
Roads: map[int64]*Road{1: road, 2: roadBack},
TownRoads: map[int64][]*Road{1: {road}, 2: {roadBack}},
Towns: map[int64]*model.Town{1: townA, 2: townB},
TownOrder: []int64{1, 2},
TownNPCs: map[int64][]TownNPC{},
NPCByID: map[int64]TownNPC{},
TownBuildings: map[int64][]TownBuilding{},
}
}
func testHeroOnRoad(id int64, hp, maxHP int) *model.Hero {
townID := int64(1)
destID := int64(2)
return &model.Hero{
ID: id, Level: 5,
MaxHP: maxHP, HP: hp,
Attack: 50, Defense: 30, Speed: 1.0,
Strength: 10, Constitution: 10, Agility: 10, Luck: 5,
State: model.StateWalking,
CurrentTownID: &townID,
DestinationTownID: &destID,
PositionX: 50, PositionY: 0,
}
}
// --- Roadside rest ---
func TestRoadsideRest_TriggersOnLowHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
threshold := cfg.LowHpThreshold
maxHP := 1000
lowHP := int(float64(maxHP)*threshold) - 1
hero := testHeroOnRoad(1, lowHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {
t.Fatalf("expected StateResting, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected RestKindRoadside, got %s", hm.ActiveRestKind)
}
if hm.RestUntil.IsZero() {
t.Fatal("expected RestUntil to be set for roadside rest")
}
}
func TestRoadsideRest_DoesNotTriggerAboveThreshold(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
safeHP := int(float64(maxHP)*cfg.LowHpThreshold) + 10
hero := testHeroOnRoad(1, safeHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.LastEncounterAt = now
ProcessSingleHeroMovementTick(hero.ID, hm, graph, now.Add(time.Second), nil, nil, nil, nil, nil)
if hm.State == model.StateResting && hm.ActiveRestKind == model.RestKindRoadside {
t.Fatal("should not trigger roadside rest above threshold")
}
}
func TestRoadsideRest_HealsHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 10000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
if hm.Hero.HP <= hpBefore {
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
}
expectedGain := int(float64(maxHP) * cfg.RoadsideRestHpPerS * 10)
actualGain := hm.Hero.HP - hpBefore
if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
}
}
func TestRoadsideRest_ExitsByTimer(t *testing.T) {
graph := testGraph()
maxHP := 10000
hero := testHeroOnRoad(1, 1, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
pastTimer := hm.RestUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastTimer, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after timer, got %s (rest kind: %s)", hm.State, hm.ActiveRestKind)
}
}
func TestRoadsideRest_ExitsByHPThreshold(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
hero := testHeroOnRoad(1, int(float64(maxHP)*cfg.RoadsideRestExitHp), maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after HP threshold, got %s", hm.State)
}
}
func TestRoadsideRest_DisplayOffset(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
ox, oy := hm.displayOffset(now)
if ox == 0 && oy == 0 {
t.Fatal("expected non-zero display offset during roadside rest")
}
}
// --- Adventure inline rest ---
func TestAdventureInlineRest_TriggersOnLowHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
lowHP := int(float64(maxHP)*cfg.LowHpThreshold) - 1
hero := testHeroOnRoad(1, lowHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.State = model.StateWalking
hm.Hero.State = model.StateWalking
hm.beginExcursion(now)
tick := now.Add(time.Duration(cfg.AdventureOutDurationMs+1000) * time.Millisecond)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
if hm.State != model.StateResting {
t.Fatalf("expected StateResting, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindAdventureInline {
t.Fatalf("expected RestKindAdventureInline, got %s", hm.ActiveRestKind)
}
if !hm.Excursion.Active() {
t.Fatal("excursion should remain active during inline rest")
}
}
func TestAdventureInlineRest_HealsHP(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 10000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
hpBefore := hm.Hero.HP
tick := now.Add(10 * time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
if hm.Hero.HP <= hpBefore {
t.Fatalf("expected HP to increase from %d, got %d", hpBefore, hm.Hero.HP)
}
expectedGain := int(float64(maxHP) * cfg.AdventureRestHpPerS * 10)
actualGain := hm.Hero.HP - hpBefore
if actualGain < expectedGain/2 || actualGain > expectedGain*2 {
t.Fatalf("heal gain %d outside expected range (around %d)", actualGain, expectedGain)
}
}
func TestAdventureInlineRest_ExitsByHPTarget(t *testing.T) {
graph := testGraph()
cfg := tuning.Get()
maxHP := 1000
targetHP := int(float64(maxHP) * cfg.AdventureRestTargetHp)
hero := testHeroOnRoad(1, targetHP, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
tick := now.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, tick, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after HP target, got %s", hm.State)
}
if !hm.Excursion.Active() {
t.Fatal("excursion should still be active after inline rest exits by HP")
}
}
func TestAdventureInlineRest_ExitsByExcursionEnd(t *testing.T) {
graph := testGraph()
maxHP := 10000
hero := testHeroOnRoad(1, 1, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
pastReturn := hm.Excursion.ReturnUntil.Add(time.Second)
ProcessSingleHeroMovementTick(hero.ID, hm, graph, pastReturn, nil, nil, nil, nil, nil)
if hm.State != model.StateWalking {
t.Fatalf("expected StateWalking after excursion end, got %s", hm.State)
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after return phase ended")
}
}
func TestAdventureInlineRest_NoTimerFieldSet(t *testing.T) {
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
graph := testGraph()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
if !hm.RestUntil.IsZero() {
t.Fatal("adventure inline rest should not set RestUntil (HP-based only)")
}
}
// --- Persistence ---
func TestRoadsideRest_Persistence(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
hm.SyncToHero()
if hero.RestKind != model.RestKindRoadside {
t.Fatalf("expected hero.RestKind = roadside, got %s", hero.RestKind)
}
if hero.TownPause == nil {
t.Fatal("expected TownPause to be set")
}
if hero.TownPause.RestKind != model.RestKindRoadside {
t.Fatalf("expected TownPause.RestKind = roadside, got %s", hero.TownPause.RestKind)
}
if hero.TownPause.RestUntil == nil || hero.TownPause.RestUntil.IsZero() {
t.Fatal("expected TownPause.RestUntil to be set for roadside rest")
}
hero2 := *hero
hm2 := NewHeroMovement(&hero2, graph, now)
if hm2.State != model.StateResting {
t.Fatalf("expected restored state = resting, got %s", hm2.State)
}
if hm2.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected restored rest kind = roadside, got %s", hm2.ActiveRestKind)
}
if hm2.RestUntil.IsZero() {
t.Fatal("expected restored RestUntil to be non-zero")
}
if hm2.Road == nil {
t.Fatal("expected road to be assigned for roadside rest reconnect")
}
}
func TestAdventureInlineRest_Persistence(t *testing.T) {
graph := testGraph()
maxHP := 1000
hero := testHeroOnRoad(1, 100, maxHP)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.Excursion.Phase = model.ExcursionWild
hm.beginAdventureInlineRest(now)
hm.SyncToHero()
if hero.RestKind != model.RestKindAdventureInline {
t.Fatalf("expected hero.RestKind = adventure_inline, got %s", hero.RestKind)
}
if hero.TownPause == nil {
t.Fatal("expected TownPause to be set")
}
if hero.TownPause.RestKind != model.RestKindAdventureInline {
t.Fatalf("expected TownPause.RestKind = adventure_inline, got %s", hero.TownPause.RestKind)
}
if hero.TownPause.Excursion == nil {
t.Fatal("expected TownPause.Excursion to be set")
}
hero2 := *hero
hm2 := NewHeroMovement(&hero2, graph, now)
if hm2.State != model.StateResting {
t.Fatalf("expected restored state = resting, got %s", hm2.State)
}
if hm2.ActiveRestKind != model.RestKindAdventureInline {
t.Fatalf("expected restored rest kind = adventure_inline, got %s", hm2.ActiveRestKind)
}
if !hm2.Excursion.Active() {
t.Fatal("expected excursion to be restored")
}
if hm2.Road == nil {
t.Fatal("expected road to be assigned for adventure inline rest reconnect")
}
}
// --- Admin ---
func TestAdminStartRoadsideRest(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if !hm.AdminStartRoadsideRest(now) {
t.Fatal("AdminStartRoadsideRest should succeed")
}
if hm.State != model.StateResting {
t.Fatalf("expected resting, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindRoadside {
t.Fatalf("expected roadside, got %s", hm.ActiveRestKind)
}
}
func TestAdminStartRoadsideRest_RejectsDead(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 0, 1000)
hero.State = model.StateDead
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.AdminStartRoadsideRest(now) {
t.Fatal("AdminStartRoadsideRest should reject dead hero")
}
}
func TestAdminStopRest_Roadside(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
if !hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should succeed for roadside rest")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
if hm.ActiveRestKind != model.RestKindNone {
t.Fatalf("expected rest kind none, got %s", hm.ActiveRestKind)
}
}
func TestAdminStopRest_AdventureInline(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
if !hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should succeed for adventure inline rest")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared after admin stop rest")
}
}
func TestAdminStopRest_RejectsTownRest(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.AdminStartRest(now, graph)
if hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should reject town rest")
}
}
func TestAdminStopRest_RejectsWalking(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.AdminStopRest(now) {
t.Fatal("AdminStopRest should reject walking hero")
}
}
func TestAdminStartExcursion(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if !hm.AdminStartExcursion(now) {
t.Fatal("AdminStartExcursion should succeed")
}
if !hm.Excursion.Active() {
t.Fatal("excursion should be active")
}
if hm.Excursion.Phase != model.ExcursionOut {
t.Fatalf("expected phase out, got %s", hm.Excursion.Phase)
}
}
func TestAdminStartExcursion_RejectsWhenAlreadyActive(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
if hm.AdminStartExcursion(now) {
t.Fatal("AdminStartExcursion should reject when excursion already active")
}
}
func TestAdminStartExcursion_RejectsNotWalking(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginRoadsideRest(now)
if hm.AdminStartExcursion(now) {
t.Fatal("AdminStartExcursion should reject when not walking")
}
}
func TestAdminStopExcursion_WhileWalking(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed")
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
}
func TestAdminStopExcursion_FromAdventureInlineRest(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 100, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
hm.beginExcursion(now)
hm.beginAdventureInlineRest(now)
if !hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should succeed from inline rest")
}
if hm.Excursion.Active() {
t.Fatal("excursion should be cleared")
}
if hm.State != model.StateWalking {
t.Fatalf("expected walking, got %s", hm.State)
}
}
func TestAdminStopExcursion_RejectsNone(t *testing.T) {
graph := testGraph()
hero := testHeroOnRoad(1, 500, 1000)
now := time.Now()
hm := NewHeroMovement(hero, graph, now)
if hm.AdminStopExcursion(now) {
t.Fatal("AdminStopExcursion should reject when no excursion")
}
}

@ -84,7 +84,7 @@ type adminHeroDetailResponse struct {
AdminLiveMovement *adminLiveMovementJSON `json:"adminLiveMovement,omitempty"`
}
func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLiveMovementJSON {
func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
if hm == nil {
return nil
}

@ -0,0 +1,61 @@
package model
import "time"
// ExcursionPhase tracks where the hero is within a mini-adventure session.
// The lifecycle is: Out → Wild → Return → (back to road, phase cleared).
type ExcursionPhase string
const (
ExcursionNone ExcursionPhase = ""
ExcursionOut ExcursionPhase = "out" // moving off-road into the forest
ExcursionWild ExcursionPhase = "wild" // in the wilderness (encounters happen here)
ExcursionReturn ExcursionPhase = "return" // returning to the road (encounters still possible)
)
// RestKind discriminates the context of a StateResting period.
type RestKind string
const (
RestKindNone RestKind = ""
RestKindTown RestKind = "town"
RestKindRoadside RestKind = "roadside"
RestKindAdventureInline RestKind = "adventure_inline"
)
// ExcursionSession holds the live state of an active mini-adventure (off-road excursion).
// When Phase == ExcursionNone the session is inactive and all other fields are zero-valued.
type ExcursionSession struct {
Phase ExcursionPhase
StartedAt time.Time
// WildUntil marks the end of the wild phase; once reached the hero begins returning.
WildUntil time.Time
// ReturnUntil marks the deadline for the return phase; once reached the hero is back on road.
ReturnUntil time.Time
// DepthWorldUnits is the max perpendicular distance from the road spine for this session.
DepthWorldUnits float64
// RoadFreezeWaypoint / RoadFreezeFraction capture road progress at the moment the hero
// left the road, so it can be restored exactly when the excursion ends.
RoadFreezeWaypoint int
RoadFreezeFraction float64
}
// Active reports whether an excursion session is in progress.
func (s *ExcursionSession) Active() bool {
return s.Phase != ExcursionNone
}
// ExcursionPersisted is the JSON-serialisable subset of ExcursionSession stored in the
// heroes.town_pause JSONB column so that reconnect / offline catch-up can resume mid-adventure.
type ExcursionPersisted struct {
Phase string `json:"phase,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
WildUntil *time.Time `json:"wildUntil,omitempty"`
ReturnUntil *time.Time `json:"returnUntil,omitempty"`
DepthWorldUnits float64 `json:"depthWorldUnits,omitempty"`
RoadFreezeWaypoint int `json:"roadFreezeWaypoint,omitempty"`
RoadFreezeFraction float64 `json:"roadFreezeFraction,omitempty"`
}

@ -0,0 +1,23 @@
-- Seed excursion / roadside-rest tuning into runtime_config.payload (merged with existing keys).
UPDATE runtime_config
SET
payload = payload || '{
"adventureStartChance": 0.0001,
"adventureCooldownMs": 300000,
"adventureOutDurationMs": 20000,
"adventureWildMinMs": 560000,
"adventureWildMaxMs": 2960000,
"adventureReturnDurationMs": 20000,
"adventureDepthWorldUnits": 20,
"adventureEncounterCooldownMs": 6000,
"adventureReturnEncounterEnabled": true,
"lowHpThreshold": 0.25,
"roadsideRestExitHp": 0.7,
"adventureRestTargetHp": 0.7,
"roadsideRestMinMs": 240000,
"roadsideRestMaxMs": 600000,
"roadsideRestHpPerSecond": 0.003,
"adventureRestHpPerSecond": 0.004
}'::jsonb,
updated_at = now()
WHERE id = TRUE;

@ -0,0 +1,128 @@
# AutoHero — Art Bible (Sprite Pipeline)
**Purpose:** Single source of truth for mood, stylization, palette, and perspective so terrain tiles, props, buildings, heroes, NPCs, and enemies read as **one game**, not mixed asset packs. Use this for hand-off to artists, AI prompts, and internal reviews.
**Scope:** Static sprite art (phase 1). Animation adds frames later; this bible still governs silhouette, lighting, and palette on every frame.
**Related:** Content IDs remain defined in [`specification-content-catalog.md`](specification-content-catalog.md). Technical export sizes and keys for generation live in the sprite migration plan (repository plans; do not treat this file as the prompt bank).
---
## 1. Creative direction
| Axis | Direction |
|------|-----------|
| **Genre & mood** | Dark fantasy: dangerous, heavy, lived-in. Not a bright fairy tale. Hope exists as small sparks (lanterns, embers), not candy colors. |
| **Tone** | Brutal but readable: heroes and monsters feel physically convincing and threatening. Environment supports hardness (ruins, metal, harsh nature) without cute simplification. |
| **Stylization** | **Arcane-like:** painterly treatment, **strong silhouettes**, cinematic light and shadow, volume from large forms and careful shading—not from noisy micro-detail on tiny sprites. |
| **Clarity** | Icons and combat readability first: silhouette and value separation beat texture noise at small sizes. |
**English prompt prefix (for consistency across generation briefs):**
```text
Dark fantasy, brutal mood, stylized painterly look inspired by Arcane series,
cinematic lighting, strong readable silhouette, muted desaturated colors with rare accent highlights,
single game asset, transparent background, no text, no watermark, no border.
Isometric 45° or three-quarter view, consistent with tile-based RPG ground plane.
```
**Краткий бриф (RU):** мрачное фэнтези, брутально, стилизация в духе Arcane, приглушённая палитра, один объект, прозрачный фон, изометрия или три четверти, единый ракурс со сценой.
---
## 2. Visual pillars (non-negotiables)
1. **Silhouette first** — Every unit and large prop must read clearly in grayscale; interior line detail is secondary.
2. **One lighting story** — Key light feels directional (often top-left or front-top); rim or bounce sparingly. Avoid flat ambient fill that kills depth.
3. **Painterly, not noisy** — Broad strokes, controlled edges. No high-frequency speckle that turns to mush when scaled down.
4. **Unified detail density** — Tile, prop, and character art share the same “resolution of idea”: no hyper-detailed hero next to blob ground.
5. **Restrained VFX** — Magic, fire, blood, lightning are **accents** (see palette), not full-neon coverage.
---
## 3. Color & palette
### 3.1 Base (majority of each asset)
- **Neutrals:** Deep desaturated browns, cold grays, blue-grays, muted olive and pine greens.
- **Value range:** Favor mid-to-dark; highlights are **narrow** and purposeful (metal edge, wet surface, spell core).
- **Saturation:** Globally **low**; increase saturation only for intentional focal points.
### 3.2 Accents (use sparingly)
Deploy accents to guide the eye: magic (teal, violet, cold blue), fire and embers (warm orange-red, not neon), blood (deep crimson, not bright cherry), metal (cool specular), bioluminescence (subtle cyan-green in swamps).
**Rule:** If the whole sprite feels “colorful,” reduce saturation until the piece matches the base row, then add **one** accent zone.
### 3.3 Avoid
- Acid or toy primaries across large areas.
- Rainbow gradients on materials.
- Pure white (#FFFFFF) or pure black (#000000) except tiny specular hits or deep creases.
---
## 4. Camera, perspective, and ground contact
### 4.1 World alignment
- The game world uses an isometric diamond grid: **96×48 px** per cell (`TILE_WIDTH` × `TILE_HEIGHT` in `frontend/src/shared/constants.ts`).
- Ground tiles are **2:1 rhombus** fills; props and buildings sit on that ground plane.
### 4.2 Single viewpoint
- **Units and buildings:** **Three-quarter / isometric-style 3/4** view, consistent “camera-right” or three-quarter toward the camera (match existing renderer facing conventions; do not mix side-scroller flat with isometric props in the same set).
- **Tiles:** Top-down isometric diamond; texture fills the rhombus; corners may be transparent on a slightly larger canvas if needed.
- **UI weapon icons (HUD):** Not world-isometric; **side or slight top** for readability at 4864 px (see migration plan).
### 4.3 Anchor
- **Bottom-center** anchor for standing entities and vertical props: feet, trunk base, or building foundation centered on the contact point. Keeps `zIndex` sorting by screen `y` meaningful.
---
## 5. Scale cues (relative to grid)
These are **art targets**, not engine constraints:
| Category | Note |
|----------|------|
| **Tiles** | Art fits the 2:1 rhombus; target canvas per prompt bank (often 96×48 export). |
| **Tall props (trees, ruins)** | Extend above the tile; anchor at ground. |
| **Buildings** | Footprint roughly **~2 tiles wide**; height typically **220280 px** range in doc prompts. |
| **Player / NPC** | ~**128×160** canvas class; consistent with each other. |
| **Base enemies** | Slightly larger than a human (~1020%); **elites** ~**+15%** height and stronger silhouette/VFX read. |
Adjust only within the migration plans size tables; do not invent new keys without updating the content catalog.
---
## 6. Do / dont (review checklist)
| Do | Dont |
|----|--------|
| Match silhouette and value to Arcane-like painterly discipline | Mix unrelated styles (anime flat + gritty realistic) in one asset set |
| Keep ground tiles seamless at edges (where applicable) | Add readable text, logos, or watermarks on textures |
| Use muted palette with rare accents | Cover surfaces with loud saturated gradients |
| Align perspective with §4 | Rotate the same prop to contradictory viewpoints across variants |
| Export **PNG with alpha**, one object per file as per pipeline | Embed borders or frames in the texture |
---
## 7. References (workflow)
- **Primary:** *Arcane* (Netflix)—lighting, silhouette, painterly surfaces, restrained saturation.
- **Use references for:** mood boards and lighting—not for copying identifiable characters or logos.
- **Internal:** Place approved reference stills or links in team channels / design wiki; this repo file stays text-only so it stays easy to diff and version.
---
## 8. Sign-off
Art direction is **locked** for phase-1 sprite replacement when:
- All new sprites are reviewed against §§16,
- Palette and perspective exceptions are documented per asset only when technically required (e.g. HUD icons),
- Placeholders (if used) follow the same muted palette and anchor rules until final art lands.
**Document owner:** Art / creative direction (update this file when palette or perspective rules change).
Loading…
Cancel
Save