服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
480 lines
15 KiB
Go
480 lines
15 KiB
Go
package preview
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// IssueSeverity describes whether a preview issue prevents the document from
|
|
// being used as a new playback document.
|
|
type IssueSeverity string
|
|
|
|
const (
|
|
IssueWarning IssueSeverity = "warning"
|
|
IssueError IssueSeverity = "error"
|
|
)
|
|
|
|
// Issue is a recoverable ANI parsing problem. Line is one-based and points to
|
|
// the source line that introduced the problem when possible.
|
|
type Issue struct {
|
|
Severity IssueSeverity
|
|
Line int
|
|
Section string
|
|
Message string
|
|
}
|
|
|
|
// Layer is one image layer in an ANI frame.
|
|
type Layer struct {
|
|
Path string
|
|
Index int32
|
|
X int32
|
|
Y int32
|
|
}
|
|
|
|
// Frame is one decoded ANI frame.
|
|
type Frame struct {
|
|
Index int32
|
|
Delay int32
|
|
Layers []Layer
|
|
}
|
|
|
|
// Document is the structured preview model produced from ANI text.
|
|
type Document struct {
|
|
Valid bool
|
|
Loop bool
|
|
Shadow bool
|
|
FrameMax int32
|
|
Frames []Frame
|
|
Issues []Issue
|
|
}
|
|
|
|
type token struct {
|
|
value string
|
|
line int
|
|
}
|
|
|
|
type section struct {
|
|
indented bool
|
|
name string
|
|
line int
|
|
values []token
|
|
}
|
|
|
|
type frameBlock struct {
|
|
section section
|
|
parts []section
|
|
}
|
|
|
|
// ParseANI parses the decompiled TypeScript text used by an .ani file. The
|
|
// parser intentionally works on text rather than archive tokens so preview
|
|
// results can follow unsaved editor content.
|
|
func ParseANI(text string) Document {
|
|
sections := parseSections(text)
|
|
document := Document{FrameMax: -1, Frames: make([]Frame, 0)}
|
|
issues := make([]Issue, 0)
|
|
|
|
global := make(map[string][]section)
|
|
blocks := make([]frameBlock, 0)
|
|
var current *frameBlock
|
|
for _, item := range sections {
|
|
if _, ok := parseFrameName(item.name); ok {
|
|
block := frameBlock{section: item, parts: make([]section, 0)}
|
|
block.section.name = item.name
|
|
blocks = append(blocks, block)
|
|
current = &blocks[len(blocks)-1]
|
|
continue
|
|
}
|
|
if strings.HasPrefix(item.name, "frame") && item.name != "frame max" {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: item.line, Section: item.name, Message: "帧 section 名称必须是 FRAME 加数字"})
|
|
current = nil
|
|
continue
|
|
}
|
|
if current != nil {
|
|
if isFramePart(item.name) || (item.indented && (item.name == "loop" || item.name == "shadow")) {
|
|
current.parts = append(current.parts, item)
|
|
continue
|
|
}
|
|
// A new top-level section ends the current frame. Keep known
|
|
// global sections in the global map for files that place them
|
|
// after the frame list.
|
|
if isGlobalSection(item.name) {
|
|
global[item.name] = append(global[item.name], item)
|
|
current = nil
|
|
continue
|
|
}
|
|
current.parts = append(current.parts, item)
|
|
continue
|
|
}
|
|
if isGlobalSection(item.name) {
|
|
global[item.name] = append(global[item.name], item)
|
|
}
|
|
}
|
|
|
|
parseBooleanField(&document.Loop, global["loop"], "LOOP", &issues)
|
|
parseBooleanField(&document.Shadow, global["shadow"], "SHADOW", &issues)
|
|
if values := global["frame max"]; len(values) == 0 {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: 1, Section: "FRAME MAX", Message: "缺少 [FRAME MAX]"})
|
|
} else {
|
|
document.FrameMax = parseNonNegativeInt(values[len(values)-1], "FRAME MAX", &issues, -1)
|
|
if len(values) > 1 {
|
|
issues = append(issues, Issue{Severity: IssueWarning, Line: values[len(values)-1].line, Section: "FRAME MAX", Message: "重复的 [FRAME MAX],使用最后一个值"})
|
|
}
|
|
}
|
|
|
|
var imagePaths []token
|
|
for _, paths := range global["image path"] {
|
|
imagePaths = append(imagePaths, paths.values...)
|
|
}
|
|
|
|
frames := make([]Frame, 0, len(blocks))
|
|
seenFrames := make(map[int32]struct{}, len(blocks))
|
|
for _, block := range blocks {
|
|
frameIndex, _ := parseFrameName(block.section.name)
|
|
if _, exists := seenFrames[frameIndex]; exists {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: block.section.line, Section: block.section.name, Message: fmt.Sprintf("重复的帧编号 %d", frameIndex)})
|
|
continue
|
|
}
|
|
seenFrames[frameIndex] = struct{}{}
|
|
frame := Frame{Index: frameIndex, Delay: 100, Layers: make([]Layer, 0)}
|
|
frameLine := block.section.line
|
|
var pendingLayer *Layer
|
|
posSet := false
|
|
delaySeen := false
|
|
|
|
flushLayerPosition := func(line int) {
|
|
if pendingLayer != nil && !posSet {
|
|
issues = append(issues, Issue{Severity: IssueWarning, Line: line, Section: block.section.name, Message: "图片缺少 [IMAGE POS],使用坐标 0,0"})
|
|
}
|
|
}
|
|
for _, part := range block.parts {
|
|
switch part.name {
|
|
case "image", "image ex":
|
|
flushLayerPosition(part.line)
|
|
var layer Layer
|
|
var ok bool
|
|
if part.name == "image ex" {
|
|
layer, ok = parseImageEx(part, imagePaths, &issues)
|
|
} else {
|
|
layer, ok = parseImage(part, &issues, block.section.name)
|
|
}
|
|
if !ok {
|
|
pendingLayer = nil
|
|
posSet = false
|
|
continue
|
|
}
|
|
frame.Layers = append(frame.Layers, layer)
|
|
pendingLayer = &frame.Layers[len(frame.Layers)-1]
|
|
posSet = false
|
|
case "image pos":
|
|
if pendingLayer == nil {
|
|
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: block.section.name, Message: "[IMAGE POS] 没有对应的 [IMAGE] 或 [IMAGE EX],已忽略"})
|
|
continue
|
|
}
|
|
if len(part.values) < 2 {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: part.line, Section: "IMAGE POS", Message: "[IMAGE POS] 需要两个整数"})
|
|
continue
|
|
}
|
|
x, xOK := parseInt(part.values[0])
|
|
y, yOK := parseInt(part.values[1])
|
|
if !xOK || !yOK {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: part.line, Section: "IMAGE POS", Message: "[IMAGE POS] 坐标必须是整数"})
|
|
continue
|
|
}
|
|
pendingLayer.X, pendingLayer.Y = x, y
|
|
posSet = true
|
|
case "delay":
|
|
if delaySeen {
|
|
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: block.section.name, Message: "重复的 [DELAY],使用最后一个值"})
|
|
}
|
|
delaySeen = true
|
|
if len(part.values) == 0 {
|
|
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: "DELAY", Message: "[DELAY] 缺少数值,使用 100ms"})
|
|
frame.Delay = 100
|
|
continue
|
|
}
|
|
delay, ok := parseInt(part.values[0])
|
|
if !ok || delay <= 0 {
|
|
issues = append(issues, Issue{Severity: IssueWarning, Line: part.line, Section: "DELAY", Message: "[DELAY] 必须是正整数,使用 100ms"})
|
|
frame.Delay = 100
|
|
continue
|
|
}
|
|
frame.Delay = delay
|
|
}
|
|
}
|
|
flushLayerPosition(frameLine)
|
|
if !delaySeen {
|
|
issues = append(issues, Issue{Severity: IssueWarning, Line: frameLine, Section: block.section.name, Message: "帧缺少 [DELAY],使用 100ms"})
|
|
}
|
|
if len(frame.Layers) == 0 {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: frameLine, Section: block.section.name, Message: "帧没有有效的 [IMAGE] 或 [IMAGE EX]"})
|
|
}
|
|
frames = append(frames, frame)
|
|
}
|
|
|
|
sort.SliceStable(frames, func(i, j int) bool { return frames[i].Index < frames[j].Index })
|
|
document.Frames = frames
|
|
if document.FrameMax < 0 {
|
|
// Keep the value useful for an invalid document without allowing a
|
|
// consumer to mistake it for a valid declared count.
|
|
document.FrameMax = int32(len(frames))
|
|
}
|
|
if document.FrameMax == 0 && len(frames) == 0 {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: 1, Section: "FRAME MAX", Message: "动画没有帧"})
|
|
}
|
|
if document.FrameMax != int32(len(frames)) {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: frameMaxLine(global["frame max"]), Section: "FRAME MAX", Message: fmt.Sprintf("声明帧数为 %d,实际解析到 %d 帧", document.FrameMax, len(frames))})
|
|
}
|
|
for index, frame := range frames {
|
|
if frame.Index != int32(index) {
|
|
issues = append(issues, Issue{Severity: IssueError, Line: frameLine(blocks, frame.Index), Section: fmt.Sprintf("FRAME%03d", frame.Index), Message: fmt.Sprintf("帧编号不连续,期望 %d,实际 %d", index, frame.Index)})
|
|
}
|
|
}
|
|
|
|
document.Issues = issues
|
|
document.Valid = !hasErrors(issues) && len(frames) > 0
|
|
return document
|
|
}
|
|
|
|
func parseSections(text string) []section {
|
|
lines := strings.Split(text, "\n")
|
|
sections := make([]section, 0)
|
|
var current *section
|
|
for lineNumber, raw := range lines {
|
|
line := stripComment(strings.TrimSuffix(raw, "\r"))
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "[") {
|
|
end := strings.IndexByte(trimmed, ']')
|
|
if end >= 0 {
|
|
name := normalizeSection(trimmed[1:end])
|
|
if strings.HasPrefix(name, "/") {
|
|
current = nil
|
|
continue
|
|
}
|
|
sections = append(sections, section{indented: len(line) > 0 && (line[0] == ' ' || line[0] == '\t'), name: name, line: lineNumber + 1, values: make([]token, 0)})
|
|
current = §ions[len(sections)-1]
|
|
rest := strings.TrimSpace(trimmed[end+1:])
|
|
if rest != "" {
|
|
current.values = append(current.values, tokenize(rest, lineNumber+1)...)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
if current != nil {
|
|
current.values = append(current.values, tokenize(trimmed, lineNumber+1)...)
|
|
}
|
|
}
|
|
return sections
|
|
}
|
|
|
|
func tokenize(value string, line int) []token {
|
|
result := make([]token, 0, 2)
|
|
for index := 0; index < len(value); {
|
|
for index < len(value) && isSpace(value[index]) {
|
|
index++
|
|
}
|
|
if index >= len(value) {
|
|
break
|
|
}
|
|
if value[index] == '`' {
|
|
index++
|
|
var builder strings.Builder
|
|
for index < len(value) {
|
|
if value[index] != '`' {
|
|
builder.WriteByte(value[index])
|
|
index++
|
|
continue
|
|
}
|
|
if index+1 < len(value) && value[index+1] == '`' {
|
|
builder.WriteByte('`')
|
|
index += 2
|
|
continue
|
|
}
|
|
index++
|
|
break
|
|
}
|
|
result = append(result, token{value: builder.String(), line: line})
|
|
continue
|
|
}
|
|
start := index
|
|
for index < len(value) && !isSpace(value[index]) {
|
|
index++
|
|
}
|
|
result = append(result, token{value: value[start:index], line: line})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func stripComment(value string) string {
|
|
inBacktick := false
|
|
for index := 0; index < len(value); index++ {
|
|
switch value[index] {
|
|
case '`':
|
|
if inBacktick && index+1 < len(value) && value[index+1] == '`' {
|
|
index++
|
|
continue
|
|
}
|
|
inBacktick = !inBacktick
|
|
case '#':
|
|
if !inBacktick {
|
|
return value[:index]
|
|
}
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeSection(value string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
|
|
}
|
|
|
|
func isGlobalSection(name string) bool {
|
|
switch name {
|
|
case "loop", "shadow", "frame max", "image path":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isFramePart(name string) bool {
|
|
switch name {
|
|
case "image", "image pos", "delay", "image ex":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseFrameName(name string) (int32, bool) {
|
|
if !strings.HasPrefix(name, "frame") || name == "frame max" {
|
|
return 0, false
|
|
}
|
|
number := strings.TrimPrefix(name, "frame")
|
|
if number == "" {
|
|
return 0, false
|
|
}
|
|
value, err := strconv.ParseInt(number, 10, 32)
|
|
return int32(value), err == nil && value >= 0
|
|
}
|
|
|
|
func parseBooleanField(target *bool, values []section, name string, issues *[]Issue) {
|
|
if len(values) == 0 {
|
|
return
|
|
}
|
|
field := values[len(values)-1]
|
|
if len(field.values) == 0 {
|
|
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.line, Section: name, Message: fmt.Sprintf("[%s] 缺少数值,使用 0", name)})
|
|
return
|
|
}
|
|
value, ok := parseInt(field.values[0])
|
|
if !ok {
|
|
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.values[0].line, Section: name, Message: fmt.Sprintf("[%s] 必须是整数,使用 0", name)})
|
|
return
|
|
}
|
|
*target = value != 0
|
|
if value != 0 && value != 1 {
|
|
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.values[0].line, Section: name, Message: fmt.Sprintf("[%s] 只建议使用 0 或 1", name)})
|
|
}
|
|
if len(values) > 1 {
|
|
*issues = append(*issues, Issue{Severity: IssueWarning, Line: field.line, Section: name, Message: fmt.Sprintf("重复的 [%s],使用最后一个值", name)})
|
|
}
|
|
}
|
|
|
|
func parseNonNegativeInt(value section, name string, issues *[]Issue, fallback int32) int32 {
|
|
if len(value.values) == 0 {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: value.line, Section: name, Message: fmt.Sprintf("[%s] 缺少数值", name)})
|
|
return fallback
|
|
}
|
|
parsed, ok := parseInt(value.values[0])
|
|
if !ok || parsed < 0 {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[0].line, Section: name, Message: fmt.Sprintf("[%s] 必须是非负整数", name)})
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseImage(value section, issues *[]Issue, frameName string) (Layer, bool) {
|
|
if len(value.values) == 0 || strings.TrimSpace(value.values[0].value) == "" {
|
|
*issues = append(*issues, Issue{Severity: IssueWarning, Line: value.line, Section: frameName, Message: "[IMAGE] 图片引用为空,此帧按空图片处理"})
|
|
return Layer{Index: -1}, true
|
|
}
|
|
if len(value.values) < 2 {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: value.line, Section: frameName, Message: "[IMAGE] 需要图片路径和索引"})
|
|
return Layer{}, false
|
|
}
|
|
index, ok := parseInt(value.values[1])
|
|
if !ok || index < 0 {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[1].line, Section: "IMAGE", Message: "图片索引必须是非负整数"})
|
|
return Layer{}, false
|
|
}
|
|
return Layer{Path: value.values[0].value, Index: index}, true
|
|
}
|
|
|
|
func parseImageEx(value section, paths []token, issues *[]Issue) (Layer, bool) {
|
|
if len(value.values) < 2 {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: value.line, Section: "IMAGE EX", Message: "[IMAGE EX] 需要路径索引和图片索引"})
|
|
return Layer{}, false
|
|
}
|
|
pathIndex, ok := parseInt(value.values[0])
|
|
// -1 marks a frame with no image, matching the binary ANI decoder.
|
|
// Keep a layer so IMAGE POS and DELAY still apply to this frame.
|
|
if ok && pathIndex == -1 {
|
|
return Layer{Index: -1}, true
|
|
}
|
|
if !ok || pathIndex < 0 || int64(pathIndex) >= int64(len(paths)) {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[0].line, Section: "IMAGE EX", Message: "[IMAGE EX] 路径索引必须为 -1(空图片)或 [IMAGE PATH] 范围内的非负整数"})
|
|
return Layer{}, false
|
|
}
|
|
path := paths[pathIndex].value
|
|
if strings.TrimSpace(path) == "" {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: paths[pathIndex].line, Section: "IMAGE PATH", Message: "[IMAGE PATH] 图片路径不能为空"})
|
|
return Layer{}, false
|
|
}
|
|
index, ok := parseInt(value.values[1])
|
|
if !ok || index < 0 {
|
|
*issues = append(*issues, Issue{Severity: IssueError, Line: value.values[1].line, Section: "IMAGE EX", Message: "图片索引必须是非负整数"})
|
|
return Layer{}, false
|
|
}
|
|
return Layer{Path: path, Index: index}, true
|
|
}
|
|
|
|
func parseInt(value token) (int32, bool) {
|
|
parsed, err := strconv.ParseInt(strings.TrimSpace(value.value), 10, 32)
|
|
return int32(parsed), err == nil
|
|
}
|
|
|
|
func frameMaxLine(values []section) int {
|
|
if len(values) == 0 {
|
|
return 1
|
|
}
|
|
return values[len(values)-1].line
|
|
}
|
|
|
|
func frameLine(blocks []frameBlock, index int32) int {
|
|
for _, block := range blocks {
|
|
frameIndex, ok := parseFrameName(block.section.name)
|
|
if ok && frameIndex == index {
|
|
return block.section.line
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func hasErrors(issues []Issue) bool {
|
|
for _, issue := range issues {
|
|
if issue.Severity == IssueError {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isSpace(value byte) bool {
|
|
return value == ' ' || value == '\t' || value == '\r' || value == '\n' || value == '\v' || value == '\f'
|
|
}
|