服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
926 lines
31 KiB
Go
926 lines
31 KiB
Go
package worldmap
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
)
|
|
|
|
func ParseMap(id int64, mapPath string, doc *dnfpvf.Document) Map {
|
|
result := Map{ID: id, Path: mapPath}
|
|
sections, diagnostics := rawSections(doc)
|
|
result.SourceSections = sections
|
|
result.Diagnostics = append(result.Diagnostics, diagnostics...)
|
|
|
|
inBackground := false
|
|
backgroundIndex := -1
|
|
for _, section := range sections {
|
|
key := sectionKey(section.Name)
|
|
if isClosingSection(key) {
|
|
if key == "/background animation" {
|
|
inBackground = false
|
|
backgroundIndex = -1
|
|
}
|
|
continue
|
|
}
|
|
if inBackground {
|
|
switch key {
|
|
case "ani info":
|
|
result.BackgroundAnimations = append(result.BackgroundAnimations, BackgroundAnimation{})
|
|
backgroundIndex = len(result.BackgroundAnimations) - 1
|
|
case "filename", "layer", "order":
|
|
if backgroundIndex < 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "background field appears outside ani info"))
|
|
continue
|
|
}
|
|
value, ok := firstText(section)
|
|
if !ok {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected text value"))
|
|
continue
|
|
}
|
|
switch key {
|
|
case "filename":
|
|
result.BackgroundAnimations[backgroundIndex].Filename = value
|
|
case "layer":
|
|
result.BackgroundAnimations[backgroundIndex].Layer = value
|
|
case "order":
|
|
result.BackgroundAnimations[backgroundIndex].Order = value
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if scopeContains(section.Scope, "summon") || (key == "summon" && section.Block) {
|
|
continue
|
|
}
|
|
|
|
if _, ok := knownMapSections[key]; !ok {
|
|
result.UnknownSections = append(result.UnknownSections, cloneRawSection(section))
|
|
result.Extensions = append(result.Extensions, structureExtension(section))
|
|
continue
|
|
}
|
|
if key == "tournament enemies" {
|
|
if !parseTournamentEnemies(&result, section) {
|
|
result.TournamentMalformed = true
|
|
}
|
|
continue
|
|
}
|
|
if key == "tournament start area" {
|
|
if !parseTournamentStartAreas(&result, section) {
|
|
result.TournamentMalformed = true
|
|
}
|
|
continue
|
|
}
|
|
if isBloodAltarMapSection(key) {
|
|
// These four mutually-referencing tables are parsed together after
|
|
// all sections have been collected, so phase indexes can be checked
|
|
// against the complete corresponding monster table.
|
|
continue
|
|
}
|
|
if _, ok := opaqueMapSections[key]; ok {
|
|
result.OpaqueSections = append(result.OpaqueSections, cloneRawSection(section))
|
|
result.Extensions = append(result.Extensions, structureExtension(section))
|
|
continue
|
|
}
|
|
|
|
switch key {
|
|
case "map name":
|
|
setText(&result.Name, section, &result.Diagnostics)
|
|
case "player number":
|
|
result.PlayerNumber = appendInts(result.PlayerNumber, section, &result.Diagnostics)
|
|
case "pvp start area":
|
|
result.PVPStartArea = appendInts(result.PVPStartArea, section, &result.Diagnostics)
|
|
case "dungeon":
|
|
result.DungeonIDs = appendInts(result.DungeonIDs, section, &result.Diagnostics)
|
|
if len(result.DungeonIDs) > 0 && !result.DungeonID.Set {
|
|
result.DungeonID = OptionalInt{Value: result.DungeonIDs[0], Set: true}
|
|
}
|
|
case "type":
|
|
setText(&result.Type, section, &result.Diagnostics)
|
|
case "greed":
|
|
setText(&result.Greed, section, &result.Diagnostics)
|
|
case "tile":
|
|
result.Tiles = append(result.Tiles, texts(section)...)
|
|
case "tile files":
|
|
result.TileFiles = append(result.TileFiles, parseTileFiles(section, &result.Diagnostics)...)
|
|
case "tile map":
|
|
row, valid := ints(section)
|
|
if !valid {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected integer tile-map row"))
|
|
}
|
|
result.TileMapRows = append(result.TileMapRows, row)
|
|
case "far sight scroll":
|
|
setOptionalInt(&result.FarSightScroll, section, &result.Diagnostics)
|
|
case "middle sight scroll":
|
|
setOptionalInt(&result.MiddleSightScroll, section, &result.Diagnostics)
|
|
case "near sight scroll":
|
|
setOptionalInt(&result.NearSightScroll, section, &result.Diagnostics)
|
|
case "background animation":
|
|
inBackground = true
|
|
backgroundIndex = -1
|
|
case "pathgate pos":
|
|
result.PathgatePositions = append(result.PathgatePositions, parsePoints(section, &result.Diagnostics)...)
|
|
case "pathgate object":
|
|
result.PathgateObjects = appendInts(result.PathgateObjects, section, &result.Diagnostics)
|
|
case "sound":
|
|
result.Sounds = append(result.Sounds, texts(section)...)
|
|
case "animation":
|
|
result.AnimationObjects = append(result.AnimationObjects, parseAnimationObjects(section, &result.Diagnostics)...)
|
|
case "passive object":
|
|
result.PassiveObjects = append(result.PassiveObjects, parsePassiveObjects(section, &result.Diagnostics)...)
|
|
case "special passive object":
|
|
result.SpecialPassiveObjects = append(result.SpecialPassiveObjects, parseSpecialPassiveObjects(section, &result.Diagnostics)...)
|
|
case "monster":
|
|
result.Monsters = append(result.Monsters, parseMonsters(section, &result.Diagnostics)...)
|
|
case "event monster position":
|
|
result.EventMonsterPositions = append(result.EventMonsterPositions, parsePoints3(section, &result.Diagnostics)...)
|
|
case "npc":
|
|
result.NPCs = append(result.NPCs, parseNPCs(section, &result.Diagnostics)...)
|
|
case "ai character":
|
|
result.AICharacters = append(result.AICharacters, parseAICharacters(section, &result.Diagnostics)...)
|
|
case "town movable area":
|
|
result.TownMovableAreas = append(result.TownMovableAreas, parseMovableAreas(section, &result.Diagnostics)...)
|
|
case "dungeon movable area":
|
|
result.DungeonMovableAreas = append(result.DungeonMovableAreas, parseDungeonMovableAreas(section, &result.Diagnostics)...)
|
|
case "fix champion":
|
|
setOptionalInt(&result.FixChampion, section, &result.Diagnostics)
|
|
case "heroes mode map index":
|
|
setOptionalInt(&result.HeroesModeMapIndex, section, &result.Diagnostics)
|
|
case "background correction":
|
|
setOptionalInt(&result.BackgroundCorrection, section, &result.Diagnostics)
|
|
case "background pos":
|
|
setOptionalInt(&result.BackgroundPos, section, &result.Diagnostics)
|
|
case "foreground pattern alpha":
|
|
setOptionalInt(&result.ForegroundPatternAlpha, section, &result.Diagnostics)
|
|
case "apc random point":
|
|
setOptionalInt(&result.APCRandomPoint, section, &result.Diagnostics)
|
|
case "monster lock":
|
|
setOptionalInt(&result.MonsterLock, section, &result.Diagnostics)
|
|
case "draw monster count":
|
|
setOptionalInt(&result.DrawMonsterCount, section, &result.Diagnostics)
|
|
case "sort bottom":
|
|
setOptionalInt(&result.SortBottom, section, &result.Diagnostics)
|
|
case "add gravity":
|
|
setOptionalInt(&result.AddGravity, section, &result.Diagnostics)
|
|
case "jump power rate":
|
|
setOptionalInt(&result.JumpPowerRate, section, &result.Diagnostics)
|
|
case "dungeon start area":
|
|
result.DungeonStartArea = appendInts(result.DungeonStartArea, section, &result.Diagnostics)
|
|
case "screen pos":
|
|
result.ScreenPosition = appendInts(result.ScreenPosition, section, &result.Diagnostics)
|
|
case "monster team":
|
|
result.MonsterTeam = appendInts(result.MonsterTeam, section, &result.Diagnostics)
|
|
case "pvp practice start area":
|
|
result.PVPPracticeStartArea = appendInts(result.PVPPracticeStartArea, section, &result.Diagnostics)
|
|
case "virtual movable area":
|
|
result.VirtualMovableArea = appendInts(result.VirtualMovableArea, section, &result.Diagnostics)
|
|
case "opening bgm":
|
|
result.OpeningBGM = append(result.OpeningBGM, texts(section)...)
|
|
case "map loading image path":
|
|
setText(&result.MapLoadingImagePath, section, &result.Diagnostics)
|
|
case "basic action":
|
|
setText(&result.BasicAction, section, &result.Diagnostics)
|
|
case "absolute start path":
|
|
result.AbsoluteStartPath = append(result.AbsoluteStartPath, texts(section)...)
|
|
case "random start map":
|
|
setOptionalInt(&result.RandomStartMap, section, &result.Diagnostics)
|
|
case "pathgate recognize range":
|
|
setOptionalInt(&result.PathgateRecognizeRange, section, &result.Diagnostics)
|
|
default:
|
|
setMapFlag(&result.Flags, key)
|
|
}
|
|
}
|
|
parseBloodAltarMapSections(&result, sections)
|
|
result.Summons = parseMapSummons(sections, &result.Diagnostics)
|
|
attachHellParty(sections, &result)
|
|
result.Portals = buildPortals(result.PathgatePositions, result.PathgateObjects)
|
|
return result
|
|
}
|
|
|
|
func isBloodAltarMapSection(key string) bool {
|
|
switch sectionKey(key) {
|
|
case "blood monster", "blood phase time", "ultimate monster", "ultimate phase time":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseBloodAltarMapSections(result *Map, sections []RawSection) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
result.BloodMonsters = parseBloodAltarMonsterSections(result, sections, "blood monster")
|
|
result.UltimateMonsters = parseBloodAltarMonsterSections(result, sections, "ultimate monster")
|
|
result.BloodPhases = parseBloodAltarPhaseSections(
|
|
result, sections, "blood phase time", result.BloodMonsters, false,
|
|
)
|
|
result.UltimatePhases = parseBloodAltarPhaseSections(
|
|
result, sections, "ultimate phase time", result.UltimateMonsters, true,
|
|
)
|
|
}
|
|
|
|
func parseBloodAltarMonsterSections(result *Map, sections []RawSection, name string) []BloodAltarMonster {
|
|
var parsed []BloodAltarMonster
|
|
valid := true
|
|
for _, section := range sections {
|
|
if sectionKey(section.Name) != name {
|
|
continue
|
|
}
|
|
rows, err := parseBloodAltarMonsterTable(section)
|
|
if err != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, err.Error()))
|
|
valid = false
|
|
continue
|
|
}
|
|
parsed = append(parsed, rows...)
|
|
}
|
|
if !valid {
|
|
return nil
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseBloodAltarMonsterTable(section RawSection) ([]BloodAltarMonster, error) {
|
|
const width = 10
|
|
tokens := values(section)
|
|
if len(tokens) == 0 || len(tokens)%width != 0 {
|
|
return nil, fmt.Errorf("blood altar monster table must contain complete 10-field rows; got %d fields", len(tokens))
|
|
}
|
|
parsed := make([]BloodAltarMonster, 0, len(tokens)/width)
|
|
for offset := 0; offset < len(tokens); offset += width {
|
|
row := tokens[offset : offset+width]
|
|
for field, token := range row {
|
|
if !bloodAltarInt32Token(token) {
|
|
return nil, fmt.Errorf("blood altar monster row %d field %d must be an int32", offset/width, field)
|
|
}
|
|
}
|
|
if row[0].Int <= 0 ||
|
|
!bloodAltarCoordinate(row[2].Int) ||
|
|
!bloodAltarCoordinate(row[3].Int) ||
|
|
!bloodAltarCoordinate(row[4].Int) ||
|
|
row[5].Int < 0 || row[6].Int < 0 || row[7].Int < 0 || row[9].Int < 0 {
|
|
return nil, fmt.Errorf("blood altar monster row %d contains an invalid code, coordinate, duration or count", offset/width)
|
|
}
|
|
parsed = append(parsed, BloodAltarMonster{
|
|
MonsterCode: row[0].Int,
|
|
TemplateType: row[1].Int,
|
|
Position: Point3{X: row[2].Int, Y: row[3].Int, Z: row[4].Int},
|
|
DurationMilliseconds: row[5].Int,
|
|
SpawnIntervalMilliseconds: row[6].Int,
|
|
BaseSpawnCount: row[7].Int,
|
|
SpawnCountIncrement: row[8].Int,
|
|
BatchCount: row[9].Int,
|
|
})
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func parseBloodAltarPhaseSections(
|
|
result *Map,
|
|
sections []RawSection,
|
|
name string,
|
|
monsters []BloodAltarMonster,
|
|
ultimate bool,
|
|
) []BloodAltarPhase {
|
|
var matching []RawSection
|
|
for _, section := range sections {
|
|
if sectionKey(section.Name) == name {
|
|
matching = append(matching, section)
|
|
}
|
|
}
|
|
if len(matching) == 0 {
|
|
return nil
|
|
}
|
|
if len(monsters) == 0 {
|
|
for _, section := range matching {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "blood altar phase table requires a valid matching monster table"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var parsed []BloodAltarPhase
|
|
valid := true
|
|
for _, section := range matching {
|
|
rows, err := parseBloodAltarPhaseTable(section, len(monsters), ultimate)
|
|
if err != nil {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, err.Error()))
|
|
valid = false
|
|
continue
|
|
}
|
|
parsed = append(parsed, rows...)
|
|
}
|
|
if !valid {
|
|
return nil
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseBloodAltarPhaseTable(section RawSection, monsterCount int, ultimate bool) ([]BloodAltarPhase, error) {
|
|
width := 6
|
|
if ultimate {
|
|
width = 7
|
|
}
|
|
tokens := values(section)
|
|
if len(tokens) == 0 || len(tokens)%width != 0 {
|
|
return nil, fmt.Errorf("blood altar phase table must contain complete %d-field rows; got %d fields", width, len(tokens))
|
|
}
|
|
parsed := make([]BloodAltarPhase, 0, len(tokens)/width)
|
|
for offset := 0; offset < len(tokens); offset += width {
|
|
row := tokens[offset : offset+width]
|
|
for _, field := range []int{0, 1, 2, 4, 5} {
|
|
if !bloodAltarInt32Token(row[field]) {
|
|
return nil, fmt.Errorf("blood altar phase row %d field %d must be an int32", offset/width, field)
|
|
}
|
|
}
|
|
scale, ok := bloodAltarScale(row[3])
|
|
if !ok {
|
|
return nil, fmt.Errorf("blood altar phase row %d scale must be a finite positive number", offset/width)
|
|
}
|
|
if row[0].Int < 0 || row[1].Int < 0 || row[1].Int >= int64(monsterCount) ||
|
|
row[2].Int < 0 || row[5].Int < 0 || row[5].Int > 10 {
|
|
return nil, fmt.Errorf("blood altar phase row %d contains an invalid round, monster index, delay or concurrency", offset/width)
|
|
}
|
|
phase := BloodAltarPhase{
|
|
Round: row[0].Int,
|
|
MonsterTemplateIndex: row[1].Int,
|
|
DelayMilliseconds: row[2].Int,
|
|
Scale: scale,
|
|
Flag: row[4].Int,
|
|
ConcurrentPhaseCount: row[5].Int,
|
|
}
|
|
if ultimate {
|
|
if !isText(row[6]) {
|
|
return nil, fmt.Errorf("blood altar phase row %d difficulty must be A or B", offset/width)
|
|
}
|
|
switch strings.ToUpper(strings.TrimSpace(row[6].Value)) {
|
|
case string(BloodAltarDifficultyA):
|
|
phase.Difficulty = BloodAltarDifficultyA
|
|
case string(BloodAltarDifficultyB):
|
|
phase.Difficulty = BloodAltarDifficultyB
|
|
default:
|
|
return nil, fmt.Errorf("blood altar phase row %d difficulty must be A or B", offset/width)
|
|
}
|
|
}
|
|
parsed = append(parsed, phase)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func bloodAltarInt32Token(token dnfpvf.Token) bool {
|
|
const (
|
|
minInt32 = int64(-1 << 31)
|
|
maxInt32 = int64(1<<31 - 1)
|
|
)
|
|
return token.Kind == dnfpvf.TokenInt && token.Int >= minInt32 && token.Int <= maxInt32
|
|
}
|
|
|
|
func bloodAltarCoordinate(value int64) bool {
|
|
return value >= 0 && value <= 1<<16-1
|
|
}
|
|
|
|
func bloodAltarScale(token dnfpvf.Token) (float64, bool) {
|
|
var value float64
|
|
switch token.Kind {
|
|
case dnfpvf.TokenInt:
|
|
value = float64(token.Int)
|
|
case dnfpvf.TokenFloat:
|
|
value = token.Float
|
|
default:
|
|
return 0, false
|
|
}
|
|
return value, value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
|
|
}
|
|
|
|
func parseTournamentEnemies(result *Map, section RawSection) bool {
|
|
if result == nil || len(section.Tokens) < 5 {
|
|
return false
|
|
}
|
|
values := make([]dnfpvf.Token, 0, len(section.Tokens))
|
|
for _, token := range section.Tokens {
|
|
if token.Kind != dnfpvf.TokenSymbol {
|
|
values = append(values, token)
|
|
}
|
|
}
|
|
if len(values) < 5 || values[0].Kind != dnfpvf.TokenInt ||
|
|
(values[1].Kind != dnfpvf.TokenIdent && values[1].Kind != dnfpvf.TokenString) || (len(values)-2)%3 != 0 {
|
|
return false
|
|
}
|
|
partyCount := values[0].Int
|
|
kind := strings.TrimSpace(values[1].Value)
|
|
if partyCount <= 0 || (!strings.EqualFold(kind, "[monster]") && !strings.EqualFold(kind, "[apc]")) {
|
|
return false
|
|
}
|
|
for i := 2; i < len(values); i += 3 {
|
|
if values[i].Kind != dnfpvf.TokenInt || values[i+1].Kind != dnfpvf.TokenInt {
|
|
return false
|
|
}
|
|
name := strings.TrimSpace(values[i+2].Value)
|
|
if name == "" || (values[i+2].Kind != dnfpvf.TokenIdent && values[i+2].Kind != dnfpvf.TokenString) {
|
|
return false
|
|
}
|
|
result.TournamentEnemies = append(result.TournamentEnemies, TournamentEnemyCandidate{
|
|
PartyCount: partyCount, Kind: kind, Code: values[i].Int, Strength: values[i+1].Int, Name: name,
|
|
})
|
|
}
|
|
return len(result.TournamentEnemies) > 0
|
|
}
|
|
|
|
func parseTournamentStartAreas(result *Map, section RawSection) bool {
|
|
if result == nil {
|
|
return false
|
|
}
|
|
values := make([]int64, 0, len(section.Tokens))
|
|
for _, token := range section.Tokens {
|
|
if token.Kind == dnfpvf.TokenInt {
|
|
values = append(values, token.Int)
|
|
}
|
|
}
|
|
if len(values) == 0 || len(values)%4 != 0 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(values); i += 4 {
|
|
if values[i] <= 0 || values[i+1] < 0 || values[i+2] < 0 || values[i+3] < 0 {
|
|
return false
|
|
}
|
|
result.TournamentStartAreas = append(result.TournamentStartAreas, TournamentStartArea{
|
|
PartyCount: values[i], X: values[i+1], Y: values[i+2], Direction: values[i+3],
|
|
})
|
|
}
|
|
return len(result.TournamentStartAreas) > 0
|
|
}
|
|
|
|
func parseTileFiles(section RawSection, diagnostics *[]Diagnostic) []TileFile {
|
|
tokens := values(section)
|
|
var out []TileFile
|
|
for index := 0; index < len(tokens); {
|
|
if index+1 >= len(tokens) || tokens[index].Kind != dnfpvf.TokenInt || !isText(tokens[index+1]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed tile-file pair at token %d", index)))
|
|
index++
|
|
continue
|
|
}
|
|
out = append(out, TileFile{Index: tokens[index].Int, Path: tokens[index+1].Value})
|
|
index += 2
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseMapSummons(sections []RawSection, diagnostics *[]Diagnostic) []MapSummon {
|
|
var out []MapSummon
|
|
current := -1
|
|
for _, section := range sections {
|
|
key := sectionKey(section.Name)
|
|
if key == "summon" && section.Block {
|
|
out = append(out, MapSummon{})
|
|
current = len(out) - 1
|
|
out[current].SourceSections = append(out[current].SourceSections, cloneRawSection(section))
|
|
continue
|
|
}
|
|
if current < 0 {
|
|
continue
|
|
}
|
|
if key == "/summon" {
|
|
out[current].SourceSections = append(out[current].SourceSections, cloneRawSection(section))
|
|
current = -1
|
|
continue
|
|
}
|
|
if isClosingSection(key) {
|
|
out[current].SourceSections = append(out[current].SourceSections, cloneRawSection(section))
|
|
continue
|
|
}
|
|
if !scopeContains(section.Scope, "summon") {
|
|
continue
|
|
}
|
|
summon := &out[current]
|
|
summon.SourceSections = append(summon.SourceSections, cloneRawSection(section))
|
|
extension := structureExtension(section)
|
|
summon.Extensions = append(summon.Extensions, extension)
|
|
if scopeContains(section.Scope, "info") || key == "info" {
|
|
summon.Info = append(summon.Info, extension)
|
|
}
|
|
switch key {
|
|
case "summon key":
|
|
setOptionalInt(&summon.Key, section, diagnostics)
|
|
case "position":
|
|
summon.Position = appendInts(summon.Position, section, diagnostics)
|
|
case "type":
|
|
setText(&summon.Type, section, diagnostics)
|
|
case "itemcount":
|
|
setOptionalInt(&summon.ItemCount, section, diagnostics)
|
|
case "index":
|
|
setOptionalInt(&summon.Index, section, diagnostics)
|
|
case "life time":
|
|
setOptionalInt(&summon.LifeTime, section, diagnostics)
|
|
case "info", "distance":
|
|
default:
|
|
summon.UnknownSections = append(summon.UnknownSections, cloneRawSection(section))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scopeContains(scope []string, name string) bool {
|
|
want := sectionKey(name)
|
|
for _, value := range scope {
|
|
if sectionKey(value) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setOptionalInt(target *OptionalInt, section RawSection, diagnostics *[]Diagnostic) {
|
|
value, ok := firstInt(section)
|
|
if !ok {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected integer value"))
|
|
return
|
|
}
|
|
target.Value = value
|
|
target.Set = true
|
|
}
|
|
|
|
func setText(target *string, section RawSection, diagnostics *[]Diagnostic) {
|
|
value, ok := firstText(section)
|
|
if !ok {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected text value"))
|
|
return
|
|
}
|
|
*target = value
|
|
}
|
|
|
|
func appendInts(target []int64, section RawSection, diagnostics *[]Diagnostic) []int64 {
|
|
parsed, valid := ints(section)
|
|
if !valid {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "contains non-integer values"))
|
|
}
|
|
return append(target, parsed...)
|
|
}
|
|
|
|
func parsePoints(section RawSection, diagnostics *[]Diagnostic) []Point {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed)%2 != 0 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected x/y integer pairs"))
|
|
}
|
|
out := make([]Point, 0, len(parsed)/2)
|
|
for i := 0; i+1 < len(parsed); i += 2 {
|
|
out = append(out, Point{X: parsed[i], Y: parsed[i+1]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parsePoints3(section RawSection, diagnostics *[]Diagnostic) []Point3 {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed)%3 != 0 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected x/y/z integer records"))
|
|
}
|
|
out := make([]Point3, 0, len(parsed)/3)
|
|
for i := 0; i+2 < len(parsed); i += 3 {
|
|
out = append(out, Point3{X: parsed[i], Y: parsed[i+1], Z: parsed[i+2]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseAnimationObjects(section RawSection, diagnostics *[]Diagnostic) []AnimationObject {
|
|
tokens := values(section)
|
|
var out []AnimationObject
|
|
for i := 0; i+4 < len(tokens); i += 5 {
|
|
if !isText(tokens[i]) || !isText(tokens[i+1]) || !areInts(tokens[i+2:i+5]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed animation record at token %d", i)))
|
|
break
|
|
}
|
|
out = append(out, AnimationObject{
|
|
Filename: tokens[i].Value, Layer: tokens[i+1].Value,
|
|
Position: Point3{X: tokens[i+2].Int, Y: tokens[i+3].Int, Z: tokens[i+4].Int},
|
|
})
|
|
}
|
|
if len(tokens)%5 != 0 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "animation section has trailing tokens"))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parsePassiveObjects(section RawSection, diagnostics *[]Diagnostic) []PassiveObject {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed)%4 != 0 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected object_id/x/y/flags integer records"))
|
|
}
|
|
out := make([]PassiveObject, 0, len(parsed)/4)
|
|
for i := 0; i+3 < len(parsed); i += 4 {
|
|
out = append(out, PassiveObject{ObjectID: parsed[i], X: parsed[i+1], Y: parsed[i+2], Flags: parsed[i+3]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseSpecialPassiveObjects(section RawSection, diagnostics *[]Diagnostic) []SpecialPassiveObject {
|
|
tokens := values(section)
|
|
if out, ok := parseZeroSpawnSpecialPassiveObjectRows(tokens); ok {
|
|
return out
|
|
}
|
|
if len(tokens)%4 == 0 && areInts(tokens) {
|
|
out := make([]SpecialPassiveObject, 0, len(tokens)/4)
|
|
for i := 0; i+3 < len(tokens); i += 4 {
|
|
out = append(out, SpecialPassiveObject{PassiveObject: PassiveObject{
|
|
ObjectID: tokens[i].Int, X: tokens[i+1].Int, Y: tokens[i+2].Int, Flags: tokens[i+3].Int,
|
|
}})
|
|
}
|
|
return out
|
|
}
|
|
var out []SpecialPassiveObject
|
|
for i := 0; i < len(tokens); {
|
|
if i+3 >= len(tokens) || !areInts(tokens[i:i+4]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed special object at token %d", i)))
|
|
break
|
|
}
|
|
obj := SpecialPassiveObject{PassiveObject: PassiveObject{
|
|
ObjectID: tokens[i].Int, X: tokens[i+1].Int, Y: tokens[i+2].Int, Flags: tokens[i+3].Int,
|
|
}}
|
|
i += 4
|
|
if i >= len(tokens) || tokens[i].Kind != dnfpvf.TokenInt {
|
|
out = append(out, obj)
|
|
continue
|
|
}
|
|
spawnCount := tokens[i].Int
|
|
if spawnCount < 0 || spawnCount > int64((len(tokens)-i-1)/6) {
|
|
out = append(out, obj)
|
|
continue
|
|
}
|
|
i++
|
|
for n := int64(0); n < spawnCount; n++ {
|
|
if i < len(tokens) && isHellPartyOpen(tokens[i]) {
|
|
i++
|
|
for {
|
|
if i >= len(tokens) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "hellparty block is not closed"))
|
|
return out
|
|
}
|
|
if isHellPartyClose(tokens[i]) {
|
|
i++
|
|
break
|
|
}
|
|
if i+2 >= len(tokens) || !areInts(tokens[i:i+3]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "malformed hellparty entry"))
|
|
return out
|
|
}
|
|
obj.HellParty = append(obj.HellParty, HellPartyEntry{
|
|
GroupID: tokens[i].Int,
|
|
Rate: tokens[i+1].Int,
|
|
Order: tokens[i+2].Int,
|
|
})
|
|
i += 3
|
|
}
|
|
continue
|
|
}
|
|
if i+5 >= len(tokens) || !isText(tokens[i]) || !areInts(tokens[i+1:i+6]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "malformed special object spawn"))
|
|
return out
|
|
}
|
|
obj.Spawns = append(obj.Spawns, SpecialObjectSpawn{
|
|
Kind: tokens[i].Value, Code: tokens[i+1].Int, Level: tokens[i+2].Int,
|
|
Params: [3]int64{tokens[i+3].Int, tokens[i+4].Int, tokens[i+5].Int},
|
|
})
|
|
i += 6
|
|
}
|
|
out = append(out, obj)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isHellPartyOpen(token dnfpvf.Token) bool {
|
|
return isText(token) && strings.EqualFold(strings.TrimSpace(token.Value), "[hellparty]")
|
|
}
|
|
|
|
func isHellPartyClose(token dnfpvf.Token) bool {
|
|
return isText(token) && strings.EqualFold(strings.TrimSpace(token.Value), "[/hellparty]")
|
|
}
|
|
|
|
// Some story maps encode every special-object header as five integers on its
|
|
// own line, with the fifth integer being a zero child count. When four such
|
|
// rows are flattened, the token count is also divisible by four; line-aware
|
|
// recognition must therefore run before the legacy four-field flat grammar.
|
|
func parseZeroSpawnSpecialPassiveObjectRows(tokens []dnfpvf.Token) ([]SpecialPassiveObject, bool) {
|
|
if len(tokens) == 0 {
|
|
return nil, false
|
|
}
|
|
var out []SpecialPassiveObject
|
|
for start := 0; start < len(tokens); {
|
|
line := tokens[start].Line
|
|
end := start + 1
|
|
for end < len(tokens) && tokens[end].Line == line {
|
|
end++
|
|
}
|
|
row := tokens[start:end]
|
|
if len(row) != 5 || !areInts(row) || row[4].Int != 0 {
|
|
return nil, false
|
|
}
|
|
out = append(out, SpecialPassiveObject{PassiveObject: PassiveObject{
|
|
ObjectID: row[0].Int,
|
|
X: row[1].Int,
|
|
Y: row[2].Int,
|
|
Flags: row[3].Int,
|
|
}})
|
|
start = end
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func parseMonsters(section RawSection, diagnostics *[]Diagnostic) []MonsterSpawn {
|
|
tokens := values(section)
|
|
var out []MonsterSpawn
|
|
for i := 0; i < len(tokens); {
|
|
if len(tokens)-i < 10 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("monster section has %d trailing tokens at token %d", len(tokens)-i, i)))
|
|
break
|
|
}
|
|
if !areInts(tokens[i:i+8]) || !isText(tokens[i+8]) || !isText(tokens[i+9]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed monster record at token %d", i)))
|
|
break
|
|
}
|
|
spawn := MonsterSpawn{
|
|
MonsterID: tokens[i].Int, Level: tokens[i+1].Int, AutoLevel: tokens[i+2].Int,
|
|
Position: Point3{X: tokens[i+3].Int, Y: tokens[i+4].Int, Z: tokens[i+5].Int},
|
|
RandomDropCount: tokens[i+6].Int, FixedDropCount: tokens[i+7].Int,
|
|
Placement: tokens[i+8].Value, Rank: tokens[i+9].Value,
|
|
}
|
|
i += 10
|
|
if i < len(tokens) && isMonsterSuffixMarker(tokens[i]) {
|
|
spawn.SuffixMarker = tokens[i].Value
|
|
i++
|
|
}
|
|
out = append(out, spawn)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isMonsterSuffixMarker(token dnfpvf.Token) bool {
|
|
return token.Kind == dnfpvf.TokenString && token.Value == "[boss]"
|
|
}
|
|
|
|
func parseNPCs(section RawSection, diagnostics *[]Diagnostic) []NPCSpawn {
|
|
tokens := values(section)
|
|
var out []NPCSpawn
|
|
for i := 0; i < len(tokens); {
|
|
if i+4 < len(tokens) && tokens[i].Kind == dnfpvf.TokenInt && isText(tokens[i+1]) && areInts(tokens[i+2:i+5]) {
|
|
out = append(out, NPCSpawn{
|
|
NPCID: tokens[i].Int, Direction: tokens[i+1].Value,
|
|
Position: Point3{X: tokens[i+2].Int, Y: tokens[i+3].Int, Z: tokens[i+4].Int},
|
|
})
|
|
i += 5
|
|
continue
|
|
}
|
|
if i+3 < len(tokens) && areInts(tokens[i:i+4]) {
|
|
out = append(out, NPCSpawn{NPCID: tokens[i].Int, Position: Point3{X: tokens[i+1].Int, Y: tokens[i+2].Int, Z: tokens[i+3].Int}})
|
|
i += 4
|
|
continue
|
|
}
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed npc record at token %d", i)))
|
|
break
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseAICharacters(section RawSection, diagnostics *[]Diagnostic) []AICharacter {
|
|
tokens := values(section)
|
|
var out []AICharacter
|
|
for i := 0; i < len(tokens); {
|
|
if i+5 >= len(tokens) || !areInts(tokens[i:i+4]) || !isText(tokens[i+4]) || !isText(tokens[i+5]) {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, fmt.Sprintf("malformed ai character at token %d", i)))
|
|
break
|
|
}
|
|
entry := AICharacter{
|
|
Code: tokens[i].Int, Position: Point{X: tokens[i+1].Int, Y: tokens[i+2].Int},
|
|
Direction: tokens[i+3].Int, Faction: tokens[i+4].Value, AIType: tokens[i+5].Value,
|
|
}
|
|
i += 6
|
|
for len(entry.Params) < 2 && i < len(tokens) && tokens[i].Kind == dnfpvf.TokenInt {
|
|
entry.Params = append(entry.Params, tokens[i].Int)
|
|
i++
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseMovableAreas(section RawSection, diagnostics *[]Diagnostic) []MovableArea {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed)%6 != 0 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected six-integer movable-area records"))
|
|
}
|
|
out := make([]MovableArea, 0, len(parsed)/6)
|
|
for i := 0; i+5 < len(parsed); i += 6 {
|
|
out = append(out, MovableArea{
|
|
X: parsed[i], Y: parsed[i+1], Width: parsed[i+2], Height: parsed[i+3],
|
|
Params: [2]int64{parsed[i+4], parsed[i+5]},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseDungeonMovableAreas(section RawSection, diagnostics *[]Diagnostic) []MovableArea {
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed)%4 != 0 {
|
|
*diagnostics = append(*diagnostics, diagnostic(section, "expected four-integer dungeon movable-area records"))
|
|
}
|
|
out := make([]MovableArea, 0, len(parsed)/4)
|
|
for i := 0; i+3 < len(parsed); i += 4 {
|
|
out = append(out, MovableArea{X: parsed[i], Y: parsed[i+1], Width: parsed[i+2], Height: parsed[i+3]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func attachHellParty(sections []RawSection, result *Map) {
|
|
for _, section := range sections {
|
|
if sectionKey(section.Name) != "hellparty" || len(result.SpecialPassiveObjects) == 0 {
|
|
continue
|
|
}
|
|
parsed, valid := ints(section)
|
|
if !valid || len(parsed)%3 != 0 {
|
|
result.Diagnostics = append(result.Diagnostics, diagnostic(section, "expected group/rate/order records"))
|
|
}
|
|
last := &result.SpecialPassiveObjects[len(result.SpecialPassiveObjects)-1]
|
|
for i := 0; i+2 < len(parsed); i += 3 {
|
|
last.HellParty = append(last.HellParty, HellPartyEntry{GroupID: parsed[i], Rate: parsed[i+1], Order: parsed[i+2]})
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildPortals(positions []Point, objects []int64) []Portal {
|
|
count := max(len(positions), len(objects))
|
|
out := make([]Portal, count)
|
|
for i := 0; i < count; i++ {
|
|
out[i].Slot = i
|
|
if i < len(positions) {
|
|
position := positions[i]
|
|
out[i].Position = &position
|
|
}
|
|
if i < len(objects) {
|
|
out[i].ObjectID = OptionalInt{Value: objects[i], Set: true}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func setMapFlag(flags *MapFlags, key string) {
|
|
switch key {
|
|
case "block use stackable item":
|
|
flags.BlockUseStackableItem = true
|
|
case "block use active skill":
|
|
flags.BlockUseActiveSkill = true
|
|
case "visible on dungeon clear":
|
|
flags.VisibleOnDungeonClear = true
|
|
case "loop y axis":
|
|
flags.LoopYAxis = true
|
|
case "all dead case passable":
|
|
flags.AllDeadCasePassable = true
|
|
case "disable item escape stuck":
|
|
flags.DisableItemEscapeStuck = true
|
|
case "disable character escape stuck":
|
|
flags.DisableCharacterEscapeStuck = true
|
|
case "cannot use coin map":
|
|
flags.CannotUseCoinMap = true
|
|
case "no revival timer limit":
|
|
flags.NoRevivalTimerLimit = true
|
|
case "ignore diehard":
|
|
flags.IgnoreDiehard = true
|
|
case "disable rebirth":
|
|
flags.DisableRebirth = true
|
|
case "preserve player corpse":
|
|
flags.PreservePlayerCorpse = true
|
|
case "cannot use resolution change zoom":
|
|
flags.CannotUseResolutionChangeZoom = true
|
|
case "center fixed camera":
|
|
flags.CenterFixedCamera = true
|
|
case "force draw pattern":
|
|
flags.ForceDrawPattern = true
|
|
case "is revival":
|
|
flags.IsRevival = true
|
|
case "is moive end", "is movie end":
|
|
flags.IsMovieEnd = true
|
|
case "quest start map":
|
|
flags.QuestStartMap = true
|
|
case "hide monster":
|
|
flags.HideMonster = true
|
|
case "show dust":
|
|
flags.ShowDust = true
|
|
}
|
|
}
|
|
|
|
func isText(token dnfpvf.Token) bool {
|
|
return token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent
|
|
}
|
|
|
|
func areInts(tokens []dnfpvf.Token) bool {
|
|
for _, token := range tokens {
|
|
if token.Kind != dnfpvf.TokenInt {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func cloneRawSection(section RawSection) RawSection {
|
|
section.Tokens = append([]dnfpvf.Token(nil), section.Tokens...)
|
|
section.Scope = append([]string(nil), section.Scope...)
|
|
return section
|
|
}
|