DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,370 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
// SearchEffectResources applies the resource scope before pagination. Particle
|
||||
// paths are discovered across the archive, including monster/ and equipment/.
|
||||
func (s *ArchiveService) SearchEffectResources(kind, query string, cursor, limit int) (*SearchResult, error) {
|
||||
scopes := map[string]string{"object": "passiveobject", "appendage": "appendage", "monster": "monster"}
|
||||
if scope, ok := scopes[kind]; ok {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
query = "*"
|
||||
}
|
||||
return s.searchScopedRecords(query, cursor, limit, false, false, "", scope, true)
|
||||
}
|
||||
if kind == "apc" {
|
||||
return s.searchEffectAPCs(query, cursor, limit)
|
||||
}
|
||||
if kind != "particle" {
|
||||
return nil, fmt.Errorf("不支持的效果资源类型")
|
||||
}
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
if s.c.archive == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
result := &SearchResult{Hits: []*SearchHit{}, NextCursor: -1}
|
||||
query = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(query, "\\", "/")))
|
||||
for i := cursor; i < int(s.c.archive.FileCount()); i++ {
|
||||
p := s.c.archive.Path(int32(i))
|
||||
if _, ok := s.c.archive.Find(p); !ok || !strings.HasSuffix(strings.ToLower(p), ".ptl") || !strings.Contains(strings.ToLower(p), query) {
|
||||
continue
|
||||
}
|
||||
if len(result.Hits) == limit {
|
||||
result.NextCursor = int(i)
|
||||
break
|
||||
}
|
||||
result.Hits = append(result.Hits, &SearchHit{FileIndex: int32(i), Path: p, Name: path.Base(p)})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type EffectParticle struct {
|
||||
Life float64 `json:"life"`
|
||||
Frequency float64 `json:"frequency"`
|
||||
Maximum int `json:"maximum"`
|
||||
MaximumTime float64 `json:"maximumTime"`
|
||||
Gravity float64 `json:"gravity"`
|
||||
Position []float64 `json:"position"`
|
||||
Area []float64 `json:"area"`
|
||||
Alpha []float64 `json:"alpha"`
|
||||
Velocity []float64 `json:"velocity"`
|
||||
Acceleration []float64 `json:"acceleration"`
|
||||
}
|
||||
type ItemEffectLayer struct {
|
||||
Path string `json:"path"`
|
||||
Animation *AniPreviewDocument `json:"animation"`
|
||||
Particle *EffectParticle `json:"particle,omitempty"`
|
||||
}
|
||||
type ItemEffectPreview struct {
|
||||
ObjectPath string `json:"objectPath"`
|
||||
Layers []ItemEffectLayer `json:"layers"`
|
||||
Issues []string `json:"issues"`
|
||||
}
|
||||
|
||||
// ReadItemEffectPreview resolves live LST references, relative paths and editor
|
||||
// drafts. It reads only PVF resources; sprite decoding remains in ImageService.
|
||||
func (s *FileGUIService) ReadItemEffectPreview(fileIndex int32, objectID, particle string, drafts []ShopDraft) (*ItemEffectPreview, error) {
|
||||
s.c.mu.Lock()
|
||||
defer s.c.mu.Unlock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
if fileIndex < 0 || fileIndex >= a.FileCount() {
|
||||
return nil, fmt.Errorf("物品文件已变化")
|
||||
}
|
||||
pending := map[string]string{}
|
||||
for _, draft := range drafts {
|
||||
if draft.FileIndex >= 0 && draft.FileIndex < a.FileCount() && normalizeAnnotationPath(a.Path(draft.FileIndex)) == normalizeAnnotationPath(draft.Path) {
|
||||
pending[normalizeAnnotationPath(draft.Path)] = draft.Text
|
||||
}
|
||||
}
|
||||
read := func(p string) (string, bool) {
|
||||
if v, ok := pending[normalizeAnnotationPath(p)]; ok {
|
||||
return v, true
|
||||
}
|
||||
i, ok := a.Find(p)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
v, err := a.Text(i)
|
||||
return v, err == nil
|
||||
}
|
||||
result := &ItemEffectPreview{Layers: []ItemEffectLayer{}, Issues: []string{}}
|
||||
resolve := func(base, raw string) string {
|
||||
raw = strings.ReplaceAll(strings.TrimSpace(raw), "\\", "/")
|
||||
candidates := []string{path.Join(path.Dir(base), raw), strings.TrimLeft(raw, "/")}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
candidates[0], candidates[1] = candidates[1], candidates[0]
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, ok := a.Find(candidate); ok {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
animation := func(p string, particle *EffectParticle) {
|
||||
if len(result.Layers) >= 16 {
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(p), ".img") {
|
||||
result.Layers = append(result.Layers, ItemEffectLayer{Path: p, Particle: particle, Animation: &AniPreviewDocument{Valid: true, Loop: true, Frames: []AniPreviewFrame{{Delay: 100, Layers: []AniPreviewLayer{{Image: ImageReference{Path: p, Index: 0}}}}}}})
|
||||
return
|
||||
}
|
||||
text, ok := read(p)
|
||||
if !ok {
|
||||
result.Issues = append(result.Issues, "无法读取动画:"+p)
|
||||
return
|
||||
}
|
||||
doc, err := NewPreviewService().ParseANI(text)
|
||||
if err != nil || doc == nil || !doc.Valid {
|
||||
result.Issues = append(result.Issues, "动画格式无法预览:"+p)
|
||||
return
|
||||
}
|
||||
result.Layers = append(result.Layers, ItemEffectLayer{Path: p, Animation: doc, Particle: particle})
|
||||
}
|
||||
seenParticles := map[string]bool{}
|
||||
addParticle := func(p string) {
|
||||
if seenParticles[normalizeAnnotationPath(p)] || len(seenParticles) >= 12 {
|
||||
return
|
||||
}
|
||||
seenParticles[normalizeAnnotationPath(p)] = true
|
||||
text, ok := read(p)
|
||||
if !ok {
|
||||
result.Issues = append(result.Issues, "无法读取粒子:"+p)
|
||||
return
|
||||
}
|
||||
view := effectScriptView(text)
|
||||
meta := parseEffectParticle(view)
|
||||
found := false
|
||||
for _, e := range view.Elements {
|
||||
if e.Kind != pvf.ScriptElementToken || e.Section != "object" {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(strings.ReplaceAll(e.Value, "\\", "/")))
|
||||
if ext == ".ani" || ext == ".img" {
|
||||
found = true
|
||||
animation(resolve(p, e.Value), meta)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
result.Issues = append(result.Issues, "粒子没有可绘制的 [object],仅提供运动参数:"+p)
|
||||
}
|
||||
}
|
||||
if objectID != "" {
|
||||
id, err := strconv.ParseUint(objectID, 10, 31)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("对象 ID 无效")
|
||||
}
|
||||
objectID = strconv.FormatUint(id, 10)
|
||||
index, ok := a.FindList("passiveobject/passiveobject.lst")
|
||||
if !ok {
|
||||
result.Issues = append(result.Issues, "当前归档没有被动对象列表")
|
||||
} else {
|
||||
listPath := a.Path(index)
|
||||
listText, _ := read(listPath)
|
||||
tokens := []pvf.ScriptElement{}
|
||||
for _, e := range effectScriptView(listText).Elements {
|
||||
if e.Kind == pvf.ScriptElementToken {
|
||||
tokens = append(tokens, e)
|
||||
}
|
||||
}
|
||||
for i := 0; i+1 < len(tokens); i += 2 {
|
||||
if tokens[i].Value != objectID {
|
||||
continue
|
||||
}
|
||||
p, _, exists := findListTargetInArchive(a, listPath, tokens[i+1].Value)
|
||||
if exists {
|
||||
result.ObjectPath = p
|
||||
}
|
||||
break
|
||||
}
|
||||
if result.ObjectPath == "" {
|
||||
result.Issues = append(result.Issues, "对象 ID 未登记或目标文件不存在:"+objectID)
|
||||
}
|
||||
}
|
||||
if result.ObjectPath != "" {
|
||||
text, ok := read(result.ObjectPath)
|
||||
if !ok {
|
||||
result.Issues = append(result.Issues, "无法读取对象:"+result.ObjectPath)
|
||||
}
|
||||
view := effectScriptView(text)
|
||||
for _, e := range view.Elements {
|
||||
if e.Kind != pvf.ScriptElementToken {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(strings.ReplaceAll(e.Value, "\\", "/")))
|
||||
switch {
|
||||
case e.Section == "basic motion" && ext == ".ani":
|
||||
animation(resolve(result.ObjectPath, e.Value), nil)
|
||||
case e.Section == "basic action" && (ext == ".act" || ext == ".h"):
|
||||
act := resolve(result.ObjectPath, e.Value)
|
||||
actText, ok := read(act)
|
||||
if !ok {
|
||||
result.Issues = append(result.Issues, "无法读取动作:"+act)
|
||||
continue
|
||||
}
|
||||
for _, value := range effectScriptView(actText).Elements {
|
||||
if value.Kind == pvf.ScriptElementToken && value.Section == "base ani" && strings.HasSuffix(strings.ToLower(value.Value), ".ani") {
|
||||
aniPath := resolve(act, value.Value)
|
||||
if _, ok := a.Find(aniPath); !ok {
|
||||
aniPath = resolve(result.ObjectPath, value.Value)
|
||||
}
|
||||
animation(aniPath, nil)
|
||||
}
|
||||
}
|
||||
case ext == ".ptl" && (e.Section == "string data" || e.Section == "file"):
|
||||
addParticle(resolve(result.ObjectPath, e.Value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if particle != "" {
|
||||
raw := strings.ReplaceAll(particle, "\\", "/")
|
||||
p := resolve(a.Path(fileIndex), raw)
|
||||
if !strings.Contains(raw, "/") {
|
||||
if _, ok := a.Find("equipment/character/particle/" + raw); ok {
|
||||
p = "equipment/character/particle/" + raw
|
||||
}
|
||||
}
|
||||
addParticle(p)
|
||||
}
|
||||
if len(result.Layers) == 0 && len(result.Issues) == 0 {
|
||||
result.Issues = append(result.Issues, "对象未声明可预览的基础动画或粒子")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseEffectParticle(view pvf.ScriptView) *EffectParticle {
|
||||
values := map[string][]float64{}
|
||||
axis := ""
|
||||
for _, e := range view.Elements {
|
||||
if e.Kind == pvf.ScriptElementSection && (strings.EqualFold(e.Section, "x axis") || strings.EqualFold(e.Section, "y axis") || strings.EqualFold(e.Section, "z axis")) {
|
||||
axis = strings.ToLower(e.Section)
|
||||
}
|
||||
if e.Kind != pvf.ScriptElementToken {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(e.Value, 64)
|
||||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(e.Section)
|
||||
if key == "move variable 1" {
|
||||
key = axis + ":" + key
|
||||
}
|
||||
values[key] = append(values[key], value)
|
||||
}
|
||||
first := func(key string, fallback float64) float64 {
|
||||
if v := values[key]; len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
vector := func(key string, size int, fallback float64) []float64 {
|
||||
out := make([]float64, size)
|
||||
for i := range out {
|
||||
out[i] = fallback
|
||||
if i < len(values[key]) {
|
||||
out[i] = values[key][i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
life := math.Max(50, math.Min(first("life time", 1000), 60000))
|
||||
frequency := first("creation frequency", 100)
|
||||
maximum := first("maximum create number", 0)
|
||||
if maximum <= 0 {
|
||||
maximum = 8
|
||||
if frequency > 0 {
|
||||
maximum = life/frequency + 1
|
||||
}
|
||||
}
|
||||
alpha := vector("transparent value", 2, 100)
|
||||
if len(values["transparent value"]) == 1 {
|
||||
alpha[1] = alpha[0]
|
||||
}
|
||||
meta := &EffectParticle{Life: life, Frequency: frequency, Maximum: int(math.Max(1, math.Min(maximum, 64))), MaximumTime: first("maximum create time", 0), Gravity: first("gravity", 0), Position: vector("creation pos", 3, 0), Area: vector("create area size", 3, 0), Alpha: alpha, Velocity: make([]float64, 3), Acceleration: make([]float64, 3)}
|
||||
for i, axis := range []string{"x axis", "y axis", "z axis"} {
|
||||
v := values[axis+":move variable 1"]
|
||||
if len(v) > 0 {
|
||||
meta.Velocity[i] = v[0]
|
||||
}
|
||||
if len(v) > 1 {
|
||||
meta.Acceleration[i] = v[1]
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func effectScriptView(text string) pvf.ScriptView {
|
||||
view := pvf.ParseScriptView(text)
|
||||
for i := range view.Elements {
|
||||
view.Elements[i].Section = strings.ToLower(view.Elements[i].Section)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// APC lists are not part of the general semantic index on all clients.
|
||||
func (s *ArchiveService) searchEffectAPCs(query string, cursor, limit int) (*SearchResult, error) {
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
a := s.c.archive
|
||||
if a == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
result := &SearchResult{Hits: []*SearchHit{}, NextCursor: -1}
|
||||
index, ok := a.FindList("aicharacter/aicharacter.lst")
|
||||
if !ok {
|
||||
return result, nil
|
||||
}
|
||||
pairs, err := a.ListPairs(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
matcher := newSearchMatcher(q, false)
|
||||
for i := cursor; i < len(pairs); i++ {
|
||||
pair := pairs[i]
|
||||
p, fileIndex, ok := findListTargetInArchive(a, a.Path(index), pair.Path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
metadata, err := a.ScriptMetadata(fileIndex)
|
||||
name := ""
|
||||
if err == nil {
|
||||
name = resolvePreviewText(a, metadata.Name)
|
||||
}
|
||||
if q != "" && !matcher.match(strings.ToLower(pair.ID)) && !matcher.match(strings.ToLower(p)) && !matcher.match(strings.ToLower(name)) {
|
||||
continue
|
||||
}
|
||||
if len(result.Hits) == limit {
|
||||
result.NextCursor = i
|
||||
break
|
||||
}
|
||||
result.Hits = append(result.Hits, &SearchHit{ID: pair.ID, Path: p, FileIndex: fileIndex, Name: name})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in new issue
Block a user