You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

279 lines
6.3 KiB
Go

package world
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"hash/fnv"
"math/rand"
"strings"
)
const mapVersion = "v1"
// MapRef is a lightweight map descriptor returned by hero init.
type MapRef struct {
MapID string `json:"mapId"`
MapVersion string `json:"mapVersion"`
ETag string `json:"etag"`
Biome string `json:"biome"`
RecommendedLevelMin int `json:"recommendedLevelMin"`
RecommendedLevelMax int `json:"recommendedLevelMax"`
}
// Tile is a single ground cell in the map grid.
type Tile struct {
X int `json:"x"`
Y int `json:"y"`
Terrain string `json:"terrain"`
}
// Object is a world object rendered on top of tiles.
type Object struct {
ID string `json:"id"`
Type string `json:"type"`
X int `json:"x"`
Y int `json:"y"`
}
// SpawnPoint defines where entities can be spawned.
type SpawnPoint struct {
ID string `json:"id"`
Kind string `json:"kind"`
X int `json:"x"`
Y int `json:"y"`
}
// ServerMap is the full map payload returned by /maps/{mapId}.
type ServerMap struct {
MapID string `json:"mapId"`
MapVersion string `json:"mapVersion"`
Biome string `json:"biome"`
RecommendedLevelMin int `json:"recommendedLevelMin"`
RecommendedLevelMax int `json:"recommendedLevelMax"`
Width int `json:"width"`
Height int `json:"height"`
Tiles []Tile `json:"tiles"`
Objects []Object `json:"objects"`
SpawnPoints []SpawnPoint `json:"spawnPoints"`
}
type levelBand struct {
min int
max int
biome string
}
var levelBands = []levelBand{
{min: 1, max: 10, biome: "meadow"},
{min: 11, max: 20, biome: "forest"},
{min: 21, max: 35, biome: "ruins"},
{min: 36, max: 50, biome: "canyon"},
{min: 51, max: 70, biome: "swamp"},
{min: 71, max: 100, biome: "volcanic"},
{min: 101, max: 999, biome: "astral"},
}
type mapData struct {
ref MapRef
data ServerMap
}
// Service provides deterministic map refs and payloads for MVP.
type Service struct {
byID map[string]mapData
}
// NewService creates a map service with precomputed deterministic maps.
func NewService() *Service {
s := &Service{
byID: make(map[string]mapData, len(levelBands)),
}
for _, band := range levelBands {
mapID := fmt.Sprintf("%s-%d-%d", band.biome, band.min, band.max)
serverMap := generateMap(mapID, band)
etag := computeETag(serverMap)
s.byID[mapID] = mapData{
ref: MapRef{
MapID: mapID,
MapVersion: mapVersion,
ETag: etag,
Biome: band.biome,
RecommendedLevelMin: band.min,
RecommendedLevelMax: band.max,
},
data: serverMap,
}
}
return s
}
// RefForLevel returns a deterministic map reference for hero level.
func (s *Service) RefForLevel(level int) MapRef {
band := bandForLevel(level)
mapID := fmt.Sprintf("%s-%d-%d", band.biome, band.min, band.max)
if entry, ok := s.byID[mapID]; ok {
return entry.ref
}
// Fallback should not happen in normal flow.
return MapRef{
MapID: mapID,
MapVersion: mapVersion,
Biome: band.biome,
RecommendedLevelMin: band.min,
RecommendedLevelMax: band.max,
}
}
// GetMap returns map payload and ETag by map ID.
func (s *Service) GetMap(mapID string) (*ServerMap, string, bool) {
entry, ok := s.byID[strings.TrimSpace(mapID)]
if !ok {
return nil, "", false
}
m := entry.data
return &m, entry.ref.ETag, true
}
func bandForLevel(level int) levelBand {
if level < 1 {
level = 1
}
for _, b := range levelBands {
if level >= b.min && level <= b.max {
return b
}
}
return levelBands[len(levelBands)-1]
}
func generateMap(mapID string, band levelBand) ServerMap {
const (
width = 24
height = 24
)
seed := hashSeed(mapID, band.biome, band.min, band.max)
rng := rand.New(rand.NewSource(seed))
tiles := make([]Tile, 0, width*height)
isRoad := make(map[[2]int]struct{}, width*2)
roadY := clamp(9+rng.Intn(6), 2, height-3)
currentY := roadY
for x := 0; x < width; x++ {
if x > 0 && x%4 == 0 {
currentY = clamp(currentY+rng.Intn(3)-1, 2, height-3)
}
isRoad[[2]int{x, currentY}] = struct{}{}
isRoad[[2]int{x, currentY + 1}] = struct{}{}
}
baseTerrain := biomeBaseTerrain(band.biome)
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
terrain := baseTerrain
if _, ok := isRoad[[2]int{x, y}]; ok {
terrain = "road"
}
tiles = append(tiles, Tile{
X: x,
Y: y,
Terrain: terrain,
})
}
}
objects := make([]Object, 0, 96)
objID := 1
for y := 1; y < height-1; y++ {
for x := 1; x < width-1; x++ {
if _, ok := isRoad[[2]int{x, y}]; ok {
continue
}
roll := rng.Intn(1000)
switch {
case roll < 70:
objects = append(objects, Object{
ID: fmt.Sprintf("obj-%d", objID),
Type: "tree",
X: x,
Y: y,
})
objID++
case roll < 120:
objects = append(objects, Object{
ID: fmt.Sprintf("obj-%d", objID),
Type: "bush",
X: x,
Y: y,
})
objID++
}
}
}
spawnPoints := []SpawnPoint{
{ID: "hero-start", Kind: "hero", X: 1, Y: roadY},
{ID: "enemy-main", Kind: "enemy", X: width - 3, Y: roadY},
{ID: "enemy-alt", Kind: "enemy", X: width - 5, Y: clamp(roadY+1, 1, height-2)},
}
return ServerMap{
MapID: mapID,
MapVersion: mapVersion,
Biome: band.biome,
RecommendedLevelMin: band.min,
RecommendedLevelMax: band.max,
Width: width,
Height: height,
Tiles: tiles,
Objects: objects,
SpawnPoints: spawnPoints,
}
}
func biomeBaseTerrain(biome string) string {
switch biome {
case "forest", "meadow":
return "grass"
case "canyon":
return "dirt"
case "ruins":
return "stone"
case "swamp":
return "mud"
case "volcanic":
return "ash"
case "astral":
return "ether"
default:
return "grass"
}
}
func computeETag(m ServerMap) string {
payload, _ := json.Marshal(m)
sum := sha1.Sum(payload)
return `"` + hex.EncodeToString(sum[:]) + `"`
}
func hashSeed(parts ...any) int64 {
h := fnv.New64a()
for _, p := range parts {
fmt.Fprint(h, p, "|")
}
return int64(h.Sum64() & 0x7fffffffffffffff)
}
func clamp(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}