@cs2dak/maps 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +33 -2
  3. package/callout-grid/de_ancient.json +1 -0
  4. package/callout-grid/de_anubis.json +1 -0
  5. package/callout-grid/de_dust2.json +1 -0
  6. package/callout-grid/de_inferno.json +1 -0
  7. package/callout-grid/de_mirage.json +1 -0
  8. package/callout-grid/de_nuke.json +1 -0
  9. package/callout-grid/de_overpass.json +1 -0
  10. package/map-nav/de_ancient.nav.json +1 -0
  11. package/map-nav/de_anubis.nav.json +1 -0
  12. package/map-nav/de_dust2.nav.json +1 -0
  13. package/map-nav/de_inferno.nav.json +1 -0
  14. package/map-nav/de_mirage.nav.json +1 -0
  15. package/map-nav/de_nuke.nav.json +1 -0
  16. package/map-nav/de_overpass.nav.json +1 -0
  17. package/map-routes/de_ancient.json +231 -0
  18. package/map-routes/de_anubis.json +204 -0
  19. package/map-routes/de_dust2.json +153 -0
  20. package/map-routes/de_inferno.json +207 -0
  21. package/map-routes/de_mirage.json +156 -0
  22. package/map-routes/de_nuke.json +63 -0
  23. package/map-routes/de_overpass.json +87 -0
  24. package/package.json +9 -3
  25. package/src/callout-grid-browser.test.ts +36 -0
  26. package/src/callout-grid-browser.ts +31 -0
  27. package/src/callout-grid-node.test.ts +27 -0
  28. package/src/callout-grid-node.ts +28 -0
  29. package/src/callout-grid.test.ts +73 -0
  30. package/src/callout-grid.ts +168 -0
  31. package/src/callout-names.ts +238 -0
  32. package/src/default-positions.test.ts +109 -0
  33. package/src/default-positions.ts +266 -0
  34. package/src/geometry-assets.test.ts +22 -0
  35. package/src/geometry-assets.ts +57 -0
  36. package/src/index.ts +41 -0
  37. package/src/lineups.test.ts +95 -0
  38. package/src/lineups.ts +336 -0
  39. package/src/nav.test.ts +68 -0
  40. package/src/nav.ts +228 -0
  41. package/src/position-candidates.test.ts +62 -0
  42. package/src/position-candidates.ts +183 -0
  43. package/src/radar-grid.ts +66 -0
  44. package/src/route-assets.test.ts +80 -0
  45. package/src/route-assets.ts +23 -0
  46. package/src/routes.test.ts +46 -0
  47. package/src/routes.ts +93 -0
  48. package/src/site-entry-chokes.ts +234 -0
  49. package/src/site-entry-lexicon.ts +131 -0
  50. package/src/tri-assets.ts +72 -0
  51. package/src/visibility.test.ts +36 -0
  52. package/src/visibility.ts +237 -0
  53. package/src/zone-assets.ts +23 -0
  54. package/src/zones.ts +7 -7
@@ -0,0 +1,234 @@
1
+ import { getPrimaryCalloutRegion } from "./callout-names.js";
2
+
3
+ export type SiteTarget = "a" | "b";
4
+ export type SiteEntryKind = "canonical" | "split" | "deep_wrap";
5
+
6
+ export interface SiteEntryFamilyDefinition {
7
+ id: string;
8
+ target: SiteTarget;
9
+ /** 首次进入包点前可作为物理入口边界的 callout。 */
10
+ entryCallouts: readonly string[];
11
+ kind: SiteEntryKind;
12
+ }
13
+
14
+ export interface SiteRouteMarkerDefinition {
15
+ id: string;
16
+ target: SiteTarget;
17
+ /** 比物理进包边界更早、用于区分宏观路线方向的节点。 */
18
+ callouts: readonly string[];
19
+ kind: SiteEntryKind;
20
+ }
21
+
22
+ export interface MapSiteSemantics {
23
+ entries: readonly SiteEntryFamilyDefinition[];
24
+ routeMarkers?: readonly SiteRouteMarkerDefinition[];
25
+ }
26
+
27
+ export interface ResolvedSiteEntry {
28
+ target: SiteTarget;
29
+ /** 真实进包边界;未命中人工入口表时保持 null。 */
30
+ entryChokeId: string | null;
31
+ entryCallout: string | null;
32
+ /** 宏观路线方向;没有独立 marker 时与 entryChokeId 相同。 */
33
+ routeFamilyId: string | null;
34
+ routeMarkerCallout: string | null;
35
+ kind: SiteEntryKind | null;
36
+ }
37
+
38
+ /**
39
+ * 七图人工核实的包点入口语义。
40
+ *
41
+ * 这里只固化稳定地图知识,不保存 demo 频率、完整动线或战术结论。
42
+ * Dust2/Overpass 的 routeMarkers 用于表达“路线方向早于真实进包边界”。
43
+ */
44
+ export const SITE_ENTRY_SEMANTICS: Record<string, MapSiteSemantics> = {
45
+ de_ancient: {
46
+ entries: [
47
+ entry("a_main", "a", ["MainHall"]),
48
+ entry("a_side_hall", "a", ["SideHall"], "split"),
49
+ entry("a_ct_spawn", "a", ["CTSpawn"], "deep_wrap"),
50
+ entry("b_ramp", "b", ["Ramp"]),
51
+ entry("b_side_entrance", "b", ["SideEntrance"], "split"),
52
+ entry("b_alley", "b", ["Alley"], "deep_wrap"),
53
+ ],
54
+ },
55
+ de_anubis: {
56
+ entries: [
57
+ entry("a_main", "a", ["Main", "Fountain"]),
58
+ entry("a_walkway", "a", ["Walkway"], "split"),
59
+ entry("a_heaven", "a", ["Heaven"], "deep_wrap"),
60
+ entry("b_outside", "b", ["OutsideLong"]),
61
+ entry("b_connector", "b", ["Connector"], "split"),
62
+ entry("b_bricks", "b", ["Bricks"], "split"),
63
+ entry("b_alley", "b", ["Alley"], "deep_wrap"),
64
+ ],
65
+ },
66
+ de_dust2: {
67
+ entries: [
68
+ entry("a_short_entry", "a", ["ExtendedA"]),
69
+ entry("a_long_entry", "a", ["ARamp"]),
70
+ entry("b_upper_tunnel", "b", ["UpperTunnel"]),
71
+ entry("b_mid_doors", "b", ["BDoors", "Hole"], "split"),
72
+ ],
73
+ routeMarkers: [
74
+ marker("a_short_route", "a", ["ShortStairs"]),
75
+ marker("a_long_route", "a", ["LongA"]),
76
+ ],
77
+ },
78
+ de_inferno: {
79
+ entries: [
80
+ entry("a1", "a", ["TopofMid", "Quad"]),
81
+ entry("a2", "a", ["Balcony"]),
82
+ entry("a_connector", "a", ["Arch", "Library"], "split"),
83
+ entry("b_banana", "b", ["Banana"]),
84
+ entry("b_ruins", "b", ["Ruins"], "deep_wrap"),
85
+ ],
86
+ },
87
+ de_mirage: {
88
+ entries: [
89
+ entry("a_ramp", "a", ["TRamp"]),
90
+ entry("a_palace", "a", ["PalaceInterior", "Scaffolding"]),
91
+ entry("a_connector", "a", ["Connector"], "split"),
92
+ entry("a_jungle", "a", ["Jungle"], "deep_wrap"),
93
+ entry("b_apartments", "b", ["Apartments", "Truck"]),
94
+ entry("b_short", "b", ["Catwalk"], "split"),
95
+ entry("b_market", "b", ["Shop"], "deep_wrap"),
96
+ ],
97
+ },
98
+ de_nuke: {
99
+ entries: [
100
+ entry("a_hut", "a", ["Hut"]),
101
+ entry("a_mini", "a", ["Mini"]),
102
+ entry("a_squeaky", "a", ["Squeaky"]),
103
+ entry("a_vents", "a", ["Vents"], "deep_wrap"),
104
+ entry("a_heaven", "a", ["Heaven", "Rafters"], "deep_wrap"),
105
+ entry("b_ramp", "b", ["Ramp"]),
106
+ entry("b_decon", "b", ["Decon"]),
107
+ entry("b_tunnels", "b", ["Tunnels", "Observation"], "deep_wrap"),
108
+ ],
109
+ },
110
+ de_overpass: {
111
+ entries: [
112
+ entry("a_lower_park", "a", ["LowerPark"]),
113
+ entry("a_upper_park", "a", ["UpperPark"]),
114
+ entry("a_bank", "a", ["Lobby", "StorageRoom", "Stairs", "BackofA"], "deep_wrap"),
115
+ entry("b_monster", "b", ["Canal"]),
116
+ entry("b_short", "b", ["Construction", "Bridge", "Water"]),
117
+ entry("b_snipers_walkway", "b", ["SnipersNest", "Walkway"], "deep_wrap"),
118
+ ],
119
+ routeMarkers: [
120
+ marker("b_snipers_walkway_route", "b", ["SnipersNest", "Walkway"], "deep_wrap"),
121
+ ],
122
+ },
123
+ };
124
+
125
+ function entry(
126
+ id: string,
127
+ target: SiteTarget,
128
+ entryCallouts: readonly string[],
129
+ kind: SiteEntryKind = "canonical",
130
+ ): SiteEntryFamilyDefinition {
131
+ return { id, target, entryCallouts, kind };
132
+ }
133
+
134
+ function marker(
135
+ id: string,
136
+ target: SiteTarget,
137
+ callouts: readonly string[],
138
+ kind: SiteEntryKind = "canonical",
139
+ ): SiteRouteMarkerDefinition {
140
+ return { id, target, callouts, kind };
141
+ }
142
+
143
+ function lastMatch<T extends { callouts?: readonly string[]; entryCallouts?: readonly string[] }>(
144
+ definitions: readonly T[],
145
+ path: readonly string[],
146
+ ): { definition: T; callout: string; index: number } | null {
147
+ let best: { definition: T; callout: string; index: number } | null = null;
148
+ for (const definition of definitions) {
149
+ const aliases = definition.entryCallouts ?? definition.callouts ?? [];
150
+ for (let index = path.length - 1; index >= 0; index -= 1) {
151
+ const callout = path[index];
152
+ if (callout && aliases.includes(callout) && (!best || index > best.index)) {
153
+ best = { definition, callout, index };
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ return best;
159
+ }
160
+
161
+ /** 沿路径正序,第一个命中任一 definition 标志 callout 的位置(最早途经点)。 */
162
+ function firstMatch<T extends { callouts?: readonly string[]; entryCallouts?: readonly string[] }>(
163
+ definitions: readonly T[],
164
+ path: readonly string[],
165
+ ): { definition: T; callout: string; index: number } | null {
166
+ for (let index = 0; index < path.length; index += 1) {
167
+ const callout = path[index];
168
+ for (const definition of definitions) {
169
+ const aliases = definition.entryCallouts ?? definition.callouts ?? [];
170
+ if (callout && aliases.includes(callout)) return { definition, callout, index };
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * 进包前「最后一段连续朝该站推进」的轨迹:从进包点往回收,
178
+ * 只要还在该站区域(或命中进点标志)就并入,一旦折返到异区即截断。
179
+ * 用于剔除开局在某口子架枪/控图后折返、最终从别处进包造成的误判。
180
+ */
181
+ function approachSegment(
182
+ mapName: string,
183
+ target: SiteTarget,
184
+ prefix: readonly string[],
185
+ entries: readonly SiteEntryFamilyDefinition[],
186
+ ): string[] {
187
+ const segment: string[] = [];
188
+ for (let index = prefix.length - 1; index >= 0; index -= 1) {
189
+ const callout = prefix[index]!;
190
+ const onApproach =
191
+ getPrimaryCalloutRegion(mapName, callout) === target ||
192
+ entries.some((definition) => definition.entryCallouts.includes(callout));
193
+ if (!onApproach) break;
194
+ segment.unshift(callout);
195
+ }
196
+ return segment;
197
+ }
198
+
199
+ export function resolveSiteEntry(
200
+ mapName: string,
201
+ target: SiteTarget,
202
+ callouts: readonly string[],
203
+ ): ResolvedSiteEntry {
204
+ const semantics = SITE_ENTRY_SEMANTICS[mapName];
205
+ const siteCallout = target === "a" ? "BombsiteA" : "BombsiteB";
206
+ const siteIndex = callouts.indexOf(siteCallout);
207
+ const prefix = siteIndex >= 0 ? callouts.slice(0, siteIndex) : callouts;
208
+ const entries = semantics?.entries.filter((definition) => definition.target === target) ?? [];
209
+ // 判定点 = 进包前最终冲包段里「最早」命中的进点口子:经过更早分叉(如 TRamp=A1)的
210
+ // 优先于进包边界(殿=A2),且折返到异区前的开局停留不计入。
211
+ const entryMatch = firstMatch(entries, approachSegment(mapName, target, prefix, entries));
212
+ const routeMatch = lastMatch(
213
+ semantics?.routeMarkers?.filter((definition) => definition.target === target) ?? [],
214
+ prefix,
215
+ );
216
+ const entryDefinition = entryMatch?.definition;
217
+ const routeDefinition = routeMatch?.definition;
218
+ return {
219
+ target,
220
+ entryChokeId: entryDefinition?.id ?? null,
221
+ entryCallout: entryMatch?.callout ?? null,
222
+ routeFamilyId: routeDefinition?.id ?? entryDefinition?.id ?? null,
223
+ routeMarkerCallout: routeMatch?.callout ?? entryMatch?.callout ?? null,
224
+ kind: routeDefinition?.kind ?? entryDefinition?.kind ?? null,
225
+ };
226
+ }
227
+
228
+ export function siteEntryChokeId(
229
+ mapName: string,
230
+ target: SiteTarget,
231
+ callouts: readonly string[],
232
+ ): string | null {
233
+ return resolveSiteEntry(mapName, target, callouts).entryChokeId;
234
+ }
@@ -0,0 +1,131 @@
1
+ // 进点路线「地道叫法」词典(手填真相源)。
2
+ //
3
+ // 定位:把 (地图, 包点, 进点 chokeId 组合) 映射到队内/解说常用的中文证据描述,
4
+ // 只供进点 evidence 展示,不得用于主聚类身份或主战术名。combo 的 chokeId 取自 site-entry-chokes.ts 的 entry id,
5
+ // 字母序、以 + 连接(与 SiteEntryFact.distinctEntryChokeIds 排序一致)。
6
+ // 来源:158 场真实 demo(科隆 Major Stage1 + pro + NJU)统计出的反复出现组合(≥3 次)。
7
+ // 填法:只填每条的 cn(地道叫法,如「走A大」「甜甜圈夹A」「交道打一波」),格式无需改动。
8
+ // cn 留空的组合,展示层回退为入口 callout 组合,不会自动拍成战术名称。
9
+
10
+ export interface EntryRouteName {
11
+ /** 进点 chokeId 组合(字母序 + 连接)。 */
12
+ combo: string;
13
+ /** 地道叫法,留空待填。 */
14
+ cn: string;
15
+ }
16
+
17
+ /** 取某组合的 evidence 文案;未收录或未填返回空串(由展示层兜底)。 */
18
+ export function entryRouteCn(mapName: string, site: "a" | "b", combo: string): string {
19
+ return SITE_ENTRY_LEXICON[mapName]?.[site]?.find((row) => row.combo === combo)?.cn ?? "";
20
+ }
21
+
22
+ export const SITE_ENTRY_LEXICON: Record<string, Partial<Record<"a" | "b", EntryRouteName[]>>> = {
23
+ de_ancient: {
24
+ a: [
25
+ { combo: "a_main", cn: "A厅爆A" }, // 163次 · A厅
26
+ { combo: "a_main+a_side_hall", cn: "甜甜圈夹A" }, // 50次 · A厅 + 甜甜圈
27
+ { combo: "a_side_hall", cn: "甜甜圈进A" }, // 46次 · 甜甜圈
28
+ { combo: "a_ct_spawn", cn: "警家入侵A包" }, // 12次 · 警家
29
+ { combo: "a_ct_spawn+a_main", cn: "警家夹A" }, // 9次 · 警家 + A厅
30
+ { combo: "a_ct_spawn+a_side_hall", cn: "警家甜甜圈夹A" }, // 6次 · 警家 + 甜甜圈
31
+ ],
32
+ b: [
33
+ { combo: "b_ramp", cn: "B坡爆B" }, // 192次 · B坡
34
+ { combo: "b_ramp+b_side_entrance", cn: "黑屋夹B" }, // 79次 · B坡 + 黑屋
35
+ { combo: "b_side_entrance", cn: "黑屋打B" }, // 24次 · 黑屋
36
+ { combo: "b_alley+b_ramp", cn: "底线夹B" }, // 10次 · 底线 + B坡
37
+ { combo: "b_alley", cn: "底线转B" }, // 8次 · 底线
38
+ ],
39
+ },
40
+ de_anubis: {
41
+ a: [
42
+ { combo: "a_main", cn: "A厅爆A" }, // 35次 · A厅/喷泉
43
+ { combo: "a_main+a_walkway", cn: "中路夹A" }, // 12次 · A厅/喷泉 + A连
44
+ { combo: "a_walkway", cn: "中路进A" }, // 11次 · A连
45
+ ],
46
+ b: [
47
+ { combo: "b_connector+b_outside", cn: "黑屋夹B" }, // 42次 · 黑屋 + B外
48
+ { combo: "b_outside", cn: "B外爆B" }, // 32次 · B外
49
+ { combo: "b_connector", cn: "黑屋打B" }, // 8次 · 黑屋
50
+ { combo: "b_bricks+b_outside", cn: "中路夹B" }, // 7次 · B连阳光房 + B外
51
+ { combo: "b_bricks", cn: "中路进B" }, // 4次 · B连阳光房
52
+ ],
53
+ },
54
+ de_dust2: {
55
+ a: [
56
+ { combo: "a_short_entry", cn: "A小爆弹" }, // 135次 · A小过点
57
+ { combo: "a_long_entry", cn: "打A大" }, // 93次 · A斜坡
58
+ { combo: "a_long_entry+a_short_entry", cn: "夹A" }, // 16次 · A斜坡 + A小过点
59
+ ],
60
+ b: [
61
+ { combo: "b_upper_tunnel", cn: "爆B" }, // 178次 · B洞
62
+ { combo: "b_mid_doors+b_upper_tunnel", cn: "夹B" }, // 50次 · B门/狗洞 + B洞
63
+ { combo: "b_mid_doors", cn: "中路打B" }, // 21次 · B门/狗洞
64
+ ],
65
+ },
66
+ de_inferno: {
67
+ a: [
68
+ { combo: "a1", cn: "打A1" }, // 118次 · 中路/马棚
69
+ { combo: "a1+a_connector", cn: "链接夹A" }, // 59次 · 中路/马棚 + 拱门/书房
70
+ { combo: "a1+a2", cn: "A1A2协同" }, // 53次 · 中路/马棚 + 阳台
71
+ { combo: "a2", cn: "飞二楼" }, // 22次 · 阳台
72
+ { combo: "a_connector", cn: "链接打A" }, // 14次 · 拱门/书房
73
+ { combo: "a2+a_connector", cn: "A2链接协同" }, // 10次 · 阳台 + 拱门/书房
74
+ { combo: "a1+a2+a_connector", cn: "链接夹A" }, // 9次 · 中路/马棚 + 阳台 + 拱门/书房
75
+ ],
76
+ b: [
77
+ { combo: "b_banana", cn: "蕉道一波" }, // 256次 · 香蕉道
78
+ { combo: "b_banana+b_ruins", cn: "链接夹B" }, // 15次 · 香蕉道 + 警家教堂
79
+ { combo: "b_ruins", cn: "链接转B" }, // 3次 · 警家教堂
80
+ ],
81
+ },
82
+ de_mirage: {
83
+ a: [
84
+ { combo: "a_palace", cn: "A2展开" }, // 182次 · A二楼/A2上下
85
+ { combo: "a_connector+a_palace", cn: "拱门夹A" }, // 77次 · 拱门 + A二楼/A2上下
86
+ { combo: "a_connector", cn: "拱门进A" }, // 36次 · 拱门
87
+ { combo: "a_connector+a_jungle+a_palace", cn: "拱门jungle夹A" }, // 10次 · 拱门 + Jungle + A二楼/A2上下
88
+ { combo: "a_connector+a_jungle", cn: "拱门Jungle展开" }, // 5次 · 拱门 + Jungle
89
+ ],
90
+ b: [
91
+ { combo: "b_apartments", cn: "B2爆弹" }, // 107次 · B二楼/白车
92
+ { combo: "b_apartments+b_short", cn: "B小夹B" }, // 55次 · B二楼/白车 + B小
93
+ { combo: "b_short", cn: "B小进B" }, // 20次 · B小
94
+ { combo: "b_market", cn: "超市夹B" }, // 3次 · 超市
95
+ ],
96
+ },
97
+ de_nuke: {
98
+ a: [
99
+ { combo: "a_squeaky", cn: "铁门展开进A" }, // 56次 · 铁门房
100
+ { combo: "a_hut+a_squeaky", cn: "爆A" }, // 55次 · 黄房 + 铁门房
101
+ { combo: "a_hut", cn: "黄房进A" }, // 30次 · 黄房
102
+ { combo: "a_mini+a_squeaky", cn: "正门夹A" }, // 20次 · 正门 + 铁门房
103
+ { combo: "a_mini", cn: "正门进A" }, // 19次 · 正门
104
+ { combo: "a_hut+a_mini", cn: "正门夹A" }, // 15次 · 黄房 + 正门
105
+ { combo: "a_hut+a_mini+a_squeaky", cn: "正门夹A" }, // 9次 · 黄房 + 正门 + 铁门房
106
+ { combo: "a_heaven", cn: "三楼进A" }, // 5次 · 三楼/三楼横梁
107
+ { combo: "a_heaven+a_mini", cn: "正门三楼同步" }, // 3次 · 三楼/三楼横梁 + 正门
108
+ ],
109
+ b: [
110
+ { combo: "b_ramp", cn: "铁板进B" }, // 51次 · 铁板
111
+ { combo: "b_decon+b_tunnels", cn: "K1进B" }, // 28次 · 死门 + K1地下/控制室
112
+ { combo: "b_decon", cn: "死门进B" }, // 21次 · 死门
113
+ { combo: "b_tunnels", cn: "活门控制室进B" }, // 19次 · K1地下/控制室
114
+ { combo: "b_decon+b_ramp", cn: "铁板死门夹B" }, // 8次 · 死门 + 铁板
115
+ { combo: "b_ramp+b_tunnels", cn: "铁板活门夹B" }, // 6次 · 铁板 + K1地下/控制室
116
+ { combo: "b_decon+b_ramp+b_tunnels", cn: "三点同步夹B" }, // 3次 · 死门 + 铁板 + K1地下/控制室
117
+ ],
118
+ },
119
+ de_overpass: {
120
+ a: [
121
+ { combo: "a_lower_park", cn: "A小爆弹" }, // 23次 · A小厕所
122
+ { combo: "a_upper_park", cn: "A大爆弹" }, // 21次 · A大厕所
123
+ { combo: "a_lower_park+a_upper_park", cn: "夹A" }, // 18次 · A小爆弹 + A大爆弹
124
+ ],
125
+ b: [
126
+ { combo: "b_monster+b_short", cn: "工地长管夹B" }, // 31次 · 长管 + B小/桥
127
+ { combo: "b_monster", cn: "长管爆B" }, // 22次 · 长管
128
+ { combo: "b_short", cn: "B小打B" }, // 14次 · B小/桥
129
+ ],
130
+ },
131
+ };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * tri-assets — runtime .tri loader(Node.js only)
3
+ *
4
+ * .tri 文件是 awpy 从 CS2 .vphys 导出的三角形流(每三角形 9×f32=36B),
5
+ * 7 图约 207MB,太大不进 git。通过 `awpy get tris` 下载到本地目录后,
6
+ * 本模块按需读取、解析、构建 BVH 并缓存。
7
+ *
8
+ * DAK Studio 浏览器端不经过此 Node 路径;开发/桌面端通过 HTTP 按需加载 `.tri`。
9
+ */
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { buildTriangleBvh, parseAwpyTri, type TriangleBvh } from "./visibility.js";
14
+
15
+ /** 可通过环境变量 AWPY_TRIS_DIR 覆盖默认路径。 */
16
+ const DEFAULT_TRIS_DIR = join(
17
+ process.env.HOME ?? process.env.USERPROFILE ?? "~",
18
+ ".awpy",
19
+ "tris",
20
+ );
21
+
22
+ const triBvhCache = new Map<string, TriangleBvh | null>();
23
+
24
+ function resolveTriPath(mapName: string): string {
25
+ const dir = process.env.AWPY_TRIS_DIR ?? DEFAULT_TRIS_DIR;
26
+ return join(dir, `${mapName}.tri`);
27
+ }
28
+
29
+ /**
30
+ * 按需加载指定地图的 .tri 文件并构建 BVH。
31
+ * 结果缓存在内存中;多次调用只解析一次。
32
+ * 文件不存在时返回 null。
33
+ */
34
+ export function getMapTri(mapName: string): TriangleBvh | null {
35
+ const cached = triBvhCache.get(mapName);
36
+ if (cached !== undefined) return cached;
37
+
38
+ const triPath = resolveTriPath(mapName);
39
+ let bytes: Buffer;
40
+ try {
41
+ bytes = readFileSync(triPath);
42
+ } catch {
43
+ triBvhCache.set(mapName, null);
44
+ return null;
45
+ }
46
+
47
+ const triangles = parseAwpyTri(bytes);
48
+ const bvh = buildTriangleBvh(triangles);
49
+ triBvhCache.set(mapName, bvh);
50
+ return bvh;
51
+ }
52
+
53
+ /**
54
+ * 检查指定地图的 .tri 文件是否可用(不做解析,只看文件是否存在)。
55
+ */
56
+ export function hasMapTri(mapName: string): boolean {
57
+ if (triBvhCache.has(mapName)) return triBvhCache.get(mapName) !== null;
58
+ try {
59
+ const { statSync } = require("node:fs") as typeof import("node:fs");
60
+ statSync(resolveTriPath(mapName));
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 清空 BVH 缓存(测试/重新加载用)。
69
+ */
70
+ export function clearTriCache(): void {
71
+ triBvhCache.clear();
72
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildTriangleBvh,
4
+ parseAwpyTri,
5
+ staticLineOfSight,
6
+ type Triangle,
7
+ } from "./visibility.js";
8
+
9
+ const wall: Triangle = {
10
+ a: { x: 0, y: -10, z: -10 },
11
+ b: { x: 0, y: 10, z: -10 },
12
+ c: { x: 0, y: 0, z: 10 },
13
+ };
14
+
15
+ describe("static line of sight", () => {
16
+ it("reads awpy little-endian triangle streams", () => {
17
+ const values = [-1, -2, -3, 1, 2, 3, 4, 5, 6];
18
+ const buffer = new ArrayBuffer(values.length * 4);
19
+ const view = new DataView(buffer);
20
+ values.forEach((value, index) => view.setFloat32(index * 4, value, true));
21
+
22
+ expect(parseAwpyTri(buffer)).toEqual([
23
+ {
24
+ a: { x: -1, y: -2, z: -3 },
25
+ b: { x: 1, y: 2, z: 3 },
26
+ c: { x: 4, y: 5, z: 6 },
27
+ },
28
+ ]);
29
+ });
30
+
31
+ it("reports whether static collision geometry blocks a segment", () => {
32
+ const bvh = buildTriangleBvh([wall]);
33
+ expect(staticLineOfSight(bvh, { x: -5, y: 0, z: 0 }, { x: 5, y: 0, z: 0 })).toBe(false);
34
+ expect(staticLineOfSight(bvh, { x: -5, y: 20, z: 0 }, { x: 5, y: 20, z: 0 })).toBe(true);
35
+ });
36
+ });