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