diff --git a/data/area/asgarnia/port_sarim/port_sarim.nav-edges.toml b/data/area/asgarnia/port_sarim/port_sarim.nav-edges.toml
index 7785d6bff7..1aa6fde860 100644
--- a/data/area/asgarnia/port_sarim/port_sarim.nav-edges.toml
+++ b/data/area/asgarnia/port_sarim/port_sarim.nav-edges.toml
@@ -133,4 +133,42 @@ edges = [
} },
{ tile = { x = 3013, y = 3219, radius = 1 } }
] },
+ { from = { x = 3071, y = 3276 }, to = { x = 3080, y = 3287 } },
+ { from = { x = 3080, y = 3287 }, to = { x = 3092, y = 3290 } },
+ { from = { x = 3092, y = 3290 }, to = { x = 3100, y = 3294 } },
+ { from = { x = 3038, y = 3276 }, to = { x = 3015, y = 3277 } },
+ { from = { x = 3015, y = 3277 }, to = { x = 3019, y = 3263 } },
+ { from = { x = 3015, y = 3277 }, to = { x = 3008, y = 3279 } },
+ { from = { x = 3008, y = 3279 }, to = { x = 3007, y = 3295 } },
+ { from = { x = 3007, y = 3295 }, to = { x = 3015, y = 3277 } },
+ { from = { x = 3008, y = 3279 }, to = { x = 3019, y = 3263 } },
+ { from = { x = 3019, y = 3263 }, to = { x = 3017, y = 3258 } },
+ { from = { x = 3017, y = 3258 }, to = { x = 3019, y = 3247 } },
+ { from = { x = 3013, y = 3219 }, to = { x = 3019, y = 3224 } },
+ { from = { x = 3016, y = 3217 }, to = { x = 3027, y = 3217 } },
+ { from = { x = 3027, y = 3217 }, to = { x = 3028, y = 3235 } },
+ { from = { x = 3028, y = 3235 }, to = { x = 3041, y = 3237 } },
+ { from = { x = 3041, y = 3237 }, to = { x = 3042, y = 3247 } },
+ { from = { x = 3026, y = 3242 }, to = { x = 3028, y = 3235 } },
+ { from = { x = 3027, y = 3217 }, to = { x = 3027, y = 3204 } },
+ { from = { x = 3027, y = 3204 }, to = { x = 3037, y = 3203 } },
+ { from = { x = 3027, y = 3204 }, to = { x = 3036, y = 3193 } },
+ { from = { x = 3036, y = 3193 }, to = { x = 3037, y = 3203 } },
+ { from = { x = 3027, y = 3204 }, to = { x = 3017, y = 3206 } },
+ { from = { x = 3017, y = 3206 }, to = { x = 3016, y = 3217 } },
+ { from = { x = 3017, y = 3206 }, to = { x = 3016, y = 3206 }, cost = 1, actions = [{ object = { option = "Open", id = "door_668_closed", x = 3017, y = 3206, success = { object = { id = "door_668_opened", x = 3016, y = 3206 } } } }, { tile = { x = 3017, y = 3206, radius = 1 } }] },
+ { from = { x = 3016, y = 3206 }, to = { x = 3017, y = 3206 }, cost = 1, actions = [{ object = { option = "Open", id = "door_668_closed", x = 3017, y = 3206, success = { object = { id = "door_668_opened", x = 3016, y = 3206 } } } }, { tile = { x = 3016, y = 3206, radius = 1 } }] },
+ { from = { x = 3013, y = 3219 }, to = { x = 3007, y = 3208 } },
+ { from = { x = 3007, y = 3208 }, to = { x = 3007, y = 3192 } },
+ { from = { x = 3007, y = 3192 }, to = { x = 3007, y = 3176 } },
+ { from = { x = 3007, y = 3176 }, to = { x = 3007, y = 3160 } },
+ { from = { x = 3007, y = 3160 }, to = { x = 3008, y = 3151 } },
+ { from = { x = 3007, y = 3160 }, to = { x = 2998, y = 3148 } },
+ { from = { x = 2998, y = 3148 }, to = { x = 3000, y = 3162 } },
+ { from = { x = 3000, y = 3162 }, to = { x = 3007, y = 3176 } },
+ { from = { x = 3000, y = 3162 }, to = { x = 3007, y = 3160 } },
+ { from = { x = 3007, y = 3192 }, to = { x = 3013, y = 3200 } },
+ { from = { x = 3013, y = 3200 }, to = { x = 3017, y = 3206 } },
+ { from = { x = 3013, y = 3200 }, to = { x = 3027, y = 3204 } },
+ { from = { x = 3016, y = 3217 }, to = { x = 3007, y = 3208 } },
]
\ No newline at end of file
diff --git a/data/bot/al_kharid.nav-edges.toml b/data/bot/al_kharid.nav-edges.toml
index 88d8b8aa61..1255b33d0c 100644
--- a/data/bot/al_kharid.nav-edges.toml
+++ b/data/bot/al_kharid.nav-edges.toml
@@ -27,4 +27,29 @@ edges = [
{ from = { x = 3278, y = 3177 }, to = { x = 3276, y = 3168 } }, # al_kharid_bank_crossroad_to_entrance
{ from = { x = 3278, y = 3177 }, to = { x = 3274, y = 3180 } }, # al_kharid_bank_crossroad_to_kebab_shop
{ from = { x = 3276, y = 3168 }, to = { x = 3270, y = 3167 } }, # al_kharid_bank_entrance_to_bank
+ { from = { x = 3282, y = 3185 }, to = { x = 3278, y = 3186 } },
+ { from = { x = 3282, y = 3185 }, to = { x = 3280, y = 3191 } },
+ { from = { x = 3280, y = 3191 }, to = { x = 3276, y = 3191 } },
+ { from = { x = 3280, y = 3191 }, to = { x = 3280, y = 3200 } },
+ { from = { x = 3282, y = 3185 }, to = { x = 3292, y = 3182 } },
+ { from = { x = 3292, y = 3182 }, to = { x = 3308, y = 3182 } },
+ { from = { x = 3308, y = 3182 }, to = { x = 3313, y = 3183 } },
+ { from = { x = 3308, y = 3182 }, to = { x = 3310, y = 3192 } },
+ { from = { x = 3310, y = 3192 }, to = { x = 3319, y = 3193 } },
+ { from = { x = 3292, y = 3182 }, to = { x = 3288, y = 3189 } },
+ { from = { x = 3292, y = 3182 }, to = { x = 3294, y = 3194 } },
+ { from = { x = 3294, y = 3194 }, to = { x = 3300, y = 3198 } },
+ { from = { x = 3294, y = 3194 }, to = { x = 3280, y = 3200 } },
+ { from = { x = 3278, y = 3177 }, to = { x = 3266, y = 3176 } },
+ { from = { x = 3266, y = 3176 }, to = { x = 3276, y = 3168 } },
+ { from = { x = 3266, y = 3176 }, to = { x = 3255, y = 3173 } },
+ { from = { x = 3294, y = 3242 }, to = { x = 3310, y = 3234 } },
+ { from = { x = 3310, y = 3234 }, to = { x = 3302, y = 3222 } },
+ { from = { x = 3302, y = 3222 }, to = { x = 3299, y = 3215 } },
+ { from = { x = 3299, y = 3215 }, to = { x = 3292, y = 3215 } },
+ { from = { x = 3299, y = 3215 }, to = { x = 3300, y = 3198 } },
+ { from = { x = 3310, y = 3234 }, to = { x = 3317, y = 3235 } },
+ { from = { x = 3278, y = 3228 }, to = { x = 3293, y = 3231 } },
+ { from = { x = 3293, y = 3231 }, to = { x = 3310, y = 3234 } },
+ { from = { x = 3293, y = 3231 }, to = { x = 3294, y = 3242 } },
]
\ No newline at end of file
diff --git a/data/bot/draynor.nav-edges.toml b/data/bot/draynor.nav-edges.toml
index 56a78a2ccd..a3cf99e023 100644
--- a/data/bot/draynor.nav-edges.toml
+++ b/data/bot/draynor.nav-edges.toml
@@ -17,4 +17,46 @@ edges = [
{ from = { x = 3105, y = 3238 }, to = { x = 3097, y = 3235 } }, # draynor_jail_path_west_to_south
{ from = { x = 3138, y = 3227 }, to = { x = 3153, y = 3216 } }, # draynor_east_path_to_swamp_north_wall
{ from = { x = 3268, y = 3331 }, to = { x = 3283, y = 3331 } }, # varrock_al_kharid_crossroad_to_north_entrance
+ { from = { x = 3104, y = 3248 }, to = { x = 3103, y = 3258 } },
+ { from = { x = 3103, y = 3258 }, to = { x = 3102, y = 3258 } },
+ { from = { x = 3103, y = 3258 }, to = { x = 3104, y = 3263 } },
+ { from = { x = 3104, y = 3263 }, to = { x = 3104, y = 3272 } },
+ { from = { x = 3104, y = 3272 }, to = { x = 3109, y = 3283 } },
+ { from = { x = 3109, y = 3283 }, to = { x = 3110, y = 3294 } },
+ { from = { x = 3104, y = 3272 }, to = { x = 3094, y = 3272 } },
+ { from = { x = 3094, y = 3272 }, to = { x = 3079, y = 3265 } },
+ { from = { x = 3079, y = 3265 }, to = { x = 3071, y = 3276 } },
+ { from = { x = 3110, y = 3294 }, to = { x = 3100, y = 3294 } },
+ { from = { x = 3110, y = 3294 }, to = { x = 3127, y = 3296 } },
+ { from = { x = 3127, y = 3296 }, to = { x = 3143, y = 3294 } },
+ { from = { x = 3143, y = 3294 }, to = { x = 3145, y = 3300 } },
+ { from = { x = 3145, y = 3300 }, to = { x = 3152, y = 3294 } },
+ { from = { x = 3152, y = 3294 }, to = { x = 3143, y = 3294 } },
+ { from = { x = 3152, y = 3294 }, to = { x = 3162, y = 3288 } },
+ { from = { x = 3162, y = 3288 }, to = { x = 3167, y = 3291 } },
+ { from = { x = 3167, y = 3291 }, to = { x = 3172, y = 3286 } },
+ { from = { x = 3172, y = 3286 }, to = { x = 3162, y = 3288 } },
+ { from = { x = 3172, y = 3286 }, to = { x = 3181, y = 3287 } },
+ { from = { x = 3181, y = 3287 }, to = { x = 3190, y = 3283 } },
+ { from = { x = 3190, y = 3283 }, to = { x = 3188, y = 3278 } },
+ { from = { x = 3167, y = 3291 }, to = { x = 3167, y = 3302 } },
+ { from = { x = 3167, y = 3302 }, to = { x = 3176, y = 3307 } },
+ { from = { x = 3176, y = 3307 }, to = { x = 3177, y = 3315 } },
+ { from = { x = 3176, y = 3307 }, to = { x = 3186, y = 3307 } },
+ { from = { x = 3102, y = 3258 }, to = { x = 3101, y = 3258 }, cost = 1, actions = [{ object = { option = "Open", id = "door_14_closed", x = 3101, y = 3258, success = { object = { id = "door_opened", x = 3102, y = 3258} } } }, { tile = { x = 3101, y = 3258, radius = 1 } }] },
+ { from = { x = 3101, y = 3258 }, to = { x = 3102, y = 3258 }, cost = 1, actions = [{ object = { option = "Open", id = "door_14_closed", x = 3101, y = 3258, success = { object = { id = "door_opened", x = 3102, y = 3258} } } }, { tile = { x = 3102, y = 3258, radius = 1 } }] },
+ { from = { x = 3097, y = 3235 }, to = { x = 3109, y = 3230 } },
+ { from = { x = 3109, y = 3230 }, to = { x = 3119, y = 3228 } },
+ { from = { x = 3109, y = 3230 }, to = { x = 3105, y = 3238 } },
+ { from = { x = 3109, y = 3230 }, to = { x = 3116, y = 3218 } },
+ { from = { x = 3116, y = 3218 }, to = { x = 3114, y = 3206 } },
+ { from = { x = 3116, y = 3218 }, to = { x = 3119, y = 3228 } },
+ { from = { x = 3116, y = 3218 }, to = { x = 3136, y = 3219 } },
+ { from = { x = 3116, y = 3218 }, to = { x = 3130, y = 3210 } },
+ { from = { x = 3130, y = 3210 }, to = { x = 3142, y = 3203 } },
+ { from = { x = 3114, y = 3206 }, to = { x = 3114, y = 3186 } },
+ { from = { x = 3114, y = 3186 }, to = { x = 3114, y = 3172 } },
+ { from = { x = 3114, y = 3172 }, to = { x = 3109, y = 3167 } },
+ { from = { x = 3103, y = 3258 }, to = { x = 3118, y = 3262 } },
+ { from = { x = 3118, y = 3262 }, to = { x = 3104, y = 3248 } },
]
\ No newline at end of file
diff --git a/data/bot/lumbridge.nav-edges.toml b/data/bot/lumbridge.nav-edges.toml
index e46c615236..c5add511ee 100644
--- a/data/bot/lumbridge.nav-edges.toml
+++ b/data/bot/lumbridge.nav-edges.toml
@@ -110,7 +110,6 @@ edges = [
{ from = { x = 3218, y = 3255 }, to = { x = 3223, y = 3260 } },
{ from = { x = 3223, y = 3260 }, to = { x = 3235, y = 3261 } },
{ from = { x = 3218, y = 3255 }, to = { x = 3217, y = 3268 } },
- { from = { x = 3218, y = 3255 }, to = { x = 3220, y = 3258 } },
{ from = { x = 3217, y = 3268 }, to = { x = 3213, y = 3277 } },
{ from = { x = 3213, y = 3277 }, to = { x = 3199, y = 3279 } },
{ from = { x = 3199, y = 3279 }, to = { x = 3190, y = 3283 } },
@@ -139,4 +138,40 @@ edges = [
{ from = { x = 3260, y = 3228 }, to = { x = 3267, y = 3227 } }, # lumbridge_east_crossroad_to_tollgate
{ from = { x = 3260, y = 3228 }, to = { x = 3267, y = 3228 } }, # lumbridge_east_crossroad_to_tollgate_north
{ from = { x = 3260, y = 3228 }, to = { x = 3263, y = 3222 } }, # lumbridge_east_crossroad_to_trees_east
+ { from = { x = 3201, y = 3155 }, to = { x = 3201, y = 3169 } },
+ { from = { x = 3201, y = 3169 }, to = { x = 3218, y = 3173 } },
+ { from = { x = 3218, y = 3173 }, to = { x = 3231, y = 3181 } },
+ { from = { x = 3201, y = 3169 }, to = { x = 3215, y = 3162 } },
+ { from = { x = 3215, y = 3162 }, to = { x = 3201, y = 3155 } },
+ { from = { x = 3215, y = 3162 }, to = { x = 3221, y = 3156 } },
+ { from = { x = 3215, y = 3162 }, to = { x = 3218, y = 3173 } },
+ { from = { x = 3201, y = 3169 }, to = { x = 3186, y = 3167 } },
+ { from = { x = 3186, y = 3167 }, to = { x = 3176, y = 3174 } },
+ { from = { x = 3186, y = 3167 }, to = { x = 3181, y = 3153 } },
+ { from = { x = 3215, y = 3216 }, to = { x = 3215, y = 3221 } },
+ { from = { x = 3215, y = 3221 }, to = { x = 3222, y = 3219 } },
+ { from = { x = 3215, y = 3221 }, to = { x = 3214, y = 3227 } },
+ { from = { x = 3214, y = 3227 }, to = { x = 3206, y = 3228 } },
+ { from = { x = 3195, y = 3236 }, to = { x = 3204, y = 3247 } },
+ { from = { x = 3212, y = 3247 }, to = { x = 3222, y = 3241 } },
+ { from = { x = 3211, y = 3252 }, to = { x = 3204, y = 3252 } },
+ { from = { x = 3211, y = 3252 }, to = { x = 3218, y = 3255 } },
+ { from = { x = 3222, y = 3255 }, to = { x = 3223, y = 3260 } },
+ { from = { x = 3223, y = 3260 }, to = { x = 3217, y = 3268 } },
+ { from = { x = 3240, y = 3280 }, to = { x = 3235, y = 3261 } },
+ { from = { x = 3184, y = 3370 }, to = { x = 3179, y = 3381 } },
+ { from = { x = 3179, y = 3381 }, to = { x = 3171, y = 3397 } },
+ { from = { x = 3171, y = 3397 }, to = { x = 3171, y = 3415 } },
+ { from = { x = 3171, y = 3415 }, to = { x = 3171, y = 3429 } },
+ { from = { x = 3171, y = 3415 }, to = { x = 3162, y = 3420 } },
+ { from = { x = 3171, y = 3397 }, to = { x = 3159, y = 3409 } },
+ { from = { x = 3159, y = 3409 }, to = { x = 3150, y = 3416 } },
+ { from = { x = 3178, y = 3364 }, to = { x = 3179, y = 3381 } },
+ { from = { x = 3157, y = 3234 }, to = { x = 3150, y = 3248 } },
+ { from = { x = 3150, y = 3248 }, to = { x = 3136, y = 3260 } },
+ { from = { x = 3136, y = 3260 }, to = { x = 3118, y = 3262 } },
+ { from = { x = 3118, y = 3262 }, to = { x = 3104, y = 3263 } },
+ { from = { x = 3136, y = 3260 }, to = { x = 3138, y = 3245 } },
+ { from = { x = 3138, y = 3245 }, to = { x = 3138, y = 3227 } },
+ { from = { x = 3138, y = 3245 }, to = { x = 3150, y = 3248 } },
]
diff --git a/data/bot/varrock.nav-edges.toml b/data/bot/varrock.nav-edges.toml
index d96e528776..eaa94ff855 100644
--- a/data/bot/varrock.nav-edges.toml
+++ b/data/bot/varrock.nav-edges.toml
@@ -15,8 +15,6 @@ edges = [
{ from = { x = 3304, y = 3474 }, to = { x = 2655, y = 4831 }, cost = 3, actions = [{ object = { option = "Enter", id = "earth_altar_ruins", x = 3305, y = 3473, success = { tile = { x = 2655, y = 4831 } } } }] }, # varrock_east_earth_altar_ruins_to_altar
{ from = { x = 2655, y = 4831 }, to = { x = 2655, y = 4830 } }, # varrock_east_earth_altar_to_exit
{ from = { x = 2655, y = 4830 }, to = { x = 3304, y = 3474 }, cost = 3, actions = [{ object = { option = "Enter", id = "earth_altar_portal", x = 2655, y = 4829, success = { tile = { x = 3304, y = 3474 } } } }] }, # varrock_east_earth_altar_exit_to_ruins
- { from = { x = 3254, y = 3422 }, to = { x = 3265, y = 3428 } }, # varrock_east_bank_to_dirt_crossroad
- { from = { x = 3265, y = 3428 }, to = { x = 3273, y = 3428 } }, # varrock_east_dirt_crossroad_to_east_crossroad
{ from = { x = 3273, y = 3428 }, to = { x = 3274, y = 3428 }, cost = 1, actions = [
{ object = { option = "Open", id = "gate_west_varrock_2_closed", x = 3273, y = 3428, success = { tile = { x = 3274, y = 3428 } } }},
] },
@@ -39,14 +37,9 @@ edges = [
{ from = { x = 3286, y = 3442 }, to = { x = 3287, y = 3457 } }, # varrock_east_path_to_north
{ from = { x = 3287, y = 3457 }, to = { x = 3296, y = 3462 } }, # varrock_east_path_north_to_sawmill_crossroad
{ from = { x = 3296, y = 3462 }, to = { x = 3304, y = 3474 } }, # varrock_east_sawmill_crossroad_to_eath_altar_ruins
- { from = { x = 3265, y = 3428 }, to = { x = 3246, y = 3429 } }, # varrock_east_dirt_crossroad_to_bank_crossroad
{ from = { x = 3246, y = 3429 }, to = { x = 3254, y = 3422 } }, # varrock_east_bank_crossroad_to_east_bank
{ from = { x = 3246, y = 3429 }, to = { x = 3246, y = 3449 } }, # varrock_east_bank_crossroad_to_musuem_front
- { from = { x = 3246, y = 3449 }, to = { x = 3245, y = 3465 } }, # varrock_museum_front_to_castle_crossroads
- { from = { x = 3245, y = 3465 }, to = { x = 3273, y = 3428 } }, # varrock_castle_crossroads_to_sewer_entrance #"open" "manhole" east tile "climb-down" "manhole" destination 3237 9858
{ from = { x = 3246, y = 3429 }, to = { x = 3246, y = 3449 } }, # varrock_east_bank_crossroad_to_musuem_front
- { from = { x = 3246, y = 3449 }, to = { x = 3245, y = 3465 } }, # varrock_museum_front_to_castle_crossroads
- { from = { x = 3245, y = 3465 }, to = { x = 3238, y = 3458 } }, # varrock_castle_crossroads_to_sewer_entrance #"open" "manhole" east tile "climb-down" "manhole" destination 3237 9858
{ from = { x = 3238, y = 3458 }, to = { x = 3237, y = 9858 }, cost = 1, actions = [
{ object = { option = "Open", id = "varrock_manhole", x = 3237, y = 3458, success = { object = { id = "varrock_manhole_open", x = 3237, y = 3458 } } }},
{ object = { option = "Climb-down", id = "varrock_manhole_open", x = 3237, y = 3458, success = { tile = { x = 3237, y = 9858 } } }},
@@ -86,4 +79,196 @@ edges = [
{ from = { x = 3228, y = 3337 }, to = { x = 3240, y = 3336 } }, # varrock_south_fields_path_to_stile
{ from = { x = 3240, y = 3336 }, to = { x = 3254, y = 3333 } }, # varrock_south_stile_to_path
{ from = { x = 3254, y = 3333 }, to = { x = 3268, y = 3331 } }, # varrock_south_stile_to_al_kharid_crossroad
+ { from = { x = 3186, y = 3435 }, to = { x = 3187, y = 3445 } },
+ { from = { x = 3187, y = 3445 }, to = { x = 3184, y = 3450 } },
+ { from = { x = 3184, y = 3450 }, to = { x = 3176, y = 3448 } },
+ { from = { x = 3176, y = 3448 }, to = { x = 3171, y = 3437 } },
+ { from = { x = 3171, y = 3437 }, to = { x = 3171, y = 3429 } },
+ { from = { x = 3176, y = 3448 }, to = { x = 3168, y = 3458 } },
+ { from = { x = 3168, y = 3458 }, to = { x = 3164, y = 3463 } },
+ { from = { x = 3164, y = 3463 }, to = { x = 3164, y = 3478 } },
+ { from = { x = 3164, y = 3478 }, to = { x = 3164, y = 3484 } },
+ { from = { x = 3164, y = 3484 }, to = { x = 3170, y = 3488 } },
+ { from = { x = 3170, y = 3488 }, to = { x = 3170, y = 3494 } },
+ { from = { x = 3170, y = 3494 }, to = { x = 3159, y = 3494 } },
+ { from = { x = 3159, y = 3494 }, to = { x = 3159, y = 3489 } },
+ { from = { x = 3159, y = 3489 }, to = { x = 3164, y = 3484 } },
+ { from = { x = 3135, y = 3416 }, to = { x = 3126, y = 3414 } },
+ { from = { x = 3126, y = 3414 }, to = { x = 3116, y = 3420 } },
+ { from = { x = 3116, y = 3420 }, to = { x = 3101, y = 3421 } },
+ { from = { x = 3101, y = 3421 }, to = { x = 3101, y = 3433 } },
+ { from = { x = 3101, y = 3433 }, to = { x = 3108, y = 3433 } },
+ { from = { x = 3101, y = 3433 }, to = { x = 3095, y = 3443 } },
+ { from = { x = 3095, y = 3443 }, to = { x = 3095, y = 3453 } },
+ { from = { x = 3095, y = 3453 }, to = { x = 3089, y = 3457 } },
+ { from = { x = 3089, y = 3457 }, to = { x = 3087, y = 3465 } },
+ { from = { x = 3087, y = 3465 }, to = { x = 3099, y = 3465 } },
+ { from = { x = 3099, y = 3465 }, to = { x = 3099, y = 3484 } },
+ { from = { x = 3099, y = 3484 }, to = { x = 3093, y = 3484 } },
+ { from = { x = 3093, y = 3484 }, to = { x = 3087, y = 3491 } },
+ { from = { x = 3087, y = 3491 }, to = { x = 3093, y = 3491 } },
+ { from = { x = 3093, y = 3491 }, to = { x = 3093, y = 3484 } },
+ { from = { x = 3093, y = 3484 }, to = { x = 3094, y = 3476 } },
+ { from = { x = 3094, y = 3476 }, to = { x = 3094, y = 3470 } },
+ { from = { x = 3094, y = 3470 }, to = { x = 3087, y = 3471 } },
+ { from = { x = 3093, y = 3484 }, to = { x = 3080, y = 3484 } },
+ { from = { x = 3080, y = 3484 }, to = { x = 3080, y = 3476 } },
+ { from = { x = 3080, y = 3476 }, to = { x = 3087, y = 3465 } },
+ { from = { x = 3087, y = 3491 }, to = { x = 3080, y = 3484 } },
+ { from = { x = 3093, y = 3491 }, to = { x = 3093, y = 3496 } },
+ { from = { x = 3093, y = 3496 }, to = { x = 3098, y = 3497 } },
+ { from = { x = 3098, y = 3497 }, to = { x = 3108, y = 3500 } },
+ { from = { x = 3108, y = 3500 }, to = { x = 3108, y = 3506 } },
+ { from = { x = 3108, y = 3506 }, to = { x = 3100, y = 3501 } },
+ { from = { x = 3100, y = 3501 }, to = { x = 3098, y = 3497 } },
+ { from = { x = 3108, y = 3506 }, to = { x = 3115, y = 3509 } },
+ { from = { x = 3115, y = 3509 }, to = { x = 3116, y = 3516 } },
+ { from = { x = 3116, y = 3516 }, to = { x = 3128, y = 3517 } },
+ { from = { x = 3128, y = 3517 }, to = { x = 3138, y = 3516 } },
+ { from = { x = 3100, y = 3501 }, to = { x = 3093, y = 3501 } },
+ { from = { x = 3093, y = 3501 }, to = { x = 3093, y = 3496 } },
+ { from = { x = 3093, y = 3501 }, to = { x = 3100, y = 3504 } },
+ { from = { x = 3100, y = 3504 }, to = { x = 3100, y = 3501 } },
+ { from = { x = 3100, y = 3504 }, to = { x = 3108, y = 3506 } },
+ { from = { x = 3108, y = 3506 }, to = { x = 3101, y = 3509 } },
+ { from = { x = 3093, y = 3501 }, to = { x = 3087, y = 3502 } },
+ { from = { x = 3087, y = 3502 }, to = { x = 3087, y = 3491 } },
+ { from = { x = 3087, y = 3502 }, to = { x = 3080, y = 3507 } },
+ { from = { x = 3095, y = 3443 }, to = { x = 3087, y = 3440 } },
+ { from = { x = 3095, y = 3443 }, to = { x = 3090, y = 3449 } },
+ { from = { x = 3090, y = 3449 }, to = { x = 3089, y = 3457 } },
+ { from = { x = 3150, y = 3416 }, to = { x = 3145, y = 3429 } },
+ { from = { x = 3145, y = 3429 }, to = { x = 3145, y = 3438 } },
+ { from = { x = 3145, y = 3438 }, to = { x = 3151, y = 3447 } },
+ { from = { x = 3151, y = 3447 }, to = { x = 3160, y = 3453 } },
+ { from = { x = 3160, y = 3453 }, to = { x = 3168, y = 3458 } },
+ { from = { x = 3160, y = 3453 }, to = { x = 3164, y = 3463 } },
+ { from = { x = 3164, y = 3463 }, to = { x = 3154, y = 3460 } },
+ { from = { x = 3154, y = 3460 }, to = { x = 3150, y = 3454 } },
+ { from = { x = 3150, y = 3454 }, to = { x = 3151, y = 3447 } },
+ { from = { x = 3145, y = 3438 }, to = { x = 3143, y = 3443 } },
+ { from = { x = 3143, y = 3443 }, to = { x = 3151, y = 3447 } },
+ { from = { x = 3154, y = 3460 }, to = { x = 3144, y = 3466 } },
+ { from = { x = 3144, y = 3466 }, to = { x = 3136, y = 3473 } },
+ { from = { x = 3136, y = 3473 }, to = { x = 3136, y = 3484 } },
+ { from = { x = 3136, y = 3484 }, to = { x = 3136, y = 3496 } },
+ { from = { x = 3136, y = 3496 }, to = { x = 3136, y = 3508 } },
+ { from = { x = 3138, y = 3516 }, to = { x = 3136, y = 3508 } },
+ { from = { x = 3145, y = 3429 }, to = { x = 3134, y = 3432 } },
+ { from = { x = 3134, y = 3432 }, to = { x = 3125, y = 3439 } },
+ { from = { x = 3125, y = 3439 }, to = { x = 3119, y = 3446 } },
+ { from = { x = 3119, y = 3446 }, to = { x = 3115, y = 3449 } },
+ { from = { x = 3119, y = 3446 }, to = { x = 3128, y = 3451 } },
+ { from = { x = 3128, y = 3451 }, to = { x = 3133, y = 3460 } },
+ { from = { x = 3133, y = 3460 }, to = { x = 3136, y = 3473 } },
+ { from = { x = 3144, y = 3466 }, to = { x = 3133, y = 3460 } },
+ { from = { x = 3125, y = 3439 }, to = { x = 3128, y = 3451 } },
+ { from = { x = 3125, y = 3439 }, to = { x = 3122, y = 3430 } },
+ { from = { x = 3122, y = 3430 }, to = { x = 3116, y = 3420 } },
+ { from = { x = 3186, y = 3430 }, to = { x = 3181, y = 3419 } },
+ { from = { x = 3181, y = 3419 }, to = { x = 3182, y = 3408 } },
+ { from = { x = 3182, y = 3408 }, to = { x = 3187, y = 3401 } },
+ { from = { x = 3187, y = 3401 }, to = { x = 3193, y = 3403 } },
+ { from = { x = 3187, y = 3401 }, to = { x = 3199, y = 3400 } },
+ { from = { x = 3199, y = 3400 }, to = { x = 3210, y = 3407 } },
+ { from = { x = 3210, y = 3407 }, to = { x = 3216, y = 3415 } },
+ { from = { x = 3216, y = 3415 }, to = { x = 3211, y = 3420 } },
+ { from = { x = 3216, y = 3415 }, to = { x = 3217, y = 3421 } },
+ { from = { x = 3217, y = 3421 }, to = { x = 3223, y = 3429 } },
+ { from = { x = 3217, y = 3421 }, to = { x = 3213, y = 3428 } },
+ { from = { x = 3217, y = 3421 }, to = { x = 3211, y = 3420 } },
+ { from = { x = 3211, y = 3395 }, to = { x = 3217, y = 3395 } },
+ { from = { x = 3246, y = 3429 }, to = { x = 3244, y = 3418 } },
+ { from = { x = 3244, y = 3418 }, to = { x = 3243, y = 3406 } },
+ { from = { x = 3243, y = 3406 }, to = { x = 3250, y = 3397 } },
+ { from = { x = 3250, y = 3397 }, to = { x = 3253, y = 3401 } },
+ { from = { x = 3253, y = 3401 }, to = { x = 3253, y = 3397 } },
+ { from = { x = 3253, y = 3397 }, to = { x = 3258, y = 3405 } },
+ { from = { x = 3258, y = 3405 }, to = { x = 3262, y = 3415 } },
+ { from = { x = 3262, y = 3415 }, to = { x = 3261, y = 3428 } },
+ { from = { x = 3261, y = 3428 }, to = { x = 3254, y = 3422 } },
+ { from = { x = 3261, y = 3428 }, to = { x = 3253, y = 3428 } },
+ { from = { x = 3253, y = 3428 }, to = { x = 3253, y = 3430 } },
+ { from = { x = 3253, y = 3428 }, to = { x = 3246, y = 3429 } },
+ { from = { x = 3253, y = 3428 }, to = { x = 3254, y = 3422 } },
+ { from = { x = 3261, y = 3428 }, to = { x = 3273, y = 3428 } },
+ { from = { x = 3230, y = 3430 }, to = { x = 3233, y = 3426 } },
+ { from = { x = 3230, y = 3430 }, to = { x = 3230, y = 3434 } },
+ { from = { x = 3230, y = 3430 }, to = { x = 3237, y = 3429 } },
+ { from = { x = 3237, y = 3429 }, to = { x = 3246, y = 3429 } },
+ { from = { x = 3237, y = 3429 }, to = { x = 3233, y = 3426 } },
+ { from = { x = 3233, y = 3426 }, to = { x = 3223, y = 3429 } },
+ { from = { x = 3223, y = 3429 }, to = { x = 3230, y = 3434 } },
+ { from = { x = 3230, y = 3430 }, to = { x = 3239, y = 3438 } },
+ { from = { x = 3239, y = 3438 }, to = { x = 3246, y = 3449 } },
+ { from = { x = 3239, y = 3438 }, to = { x = 3246, y = 3429 } },
+ { from = { x = 3246, y = 3449 }, to = { x = 3238, y = 3458 } },
+ { from = { x = 3238, y = 3458 }, to = { x = 3245, y = 3462 } },
+ { from = { x = 3245, y = 3462 }, to = { x = 3246, y = 3449 } },
+ { from = { x = 3296, y = 3462 }, to = { x = 3298, y = 3476 } },
+ { from = { x = 3298, y = 3476 }, to = { x = 3303, y = 3486 } },
+ { from = { x = 3303, y = 3486 }, to = { x = 3304, y = 3491 } },
+ { from = { x = 3303, y = 3486 }, to = { x = 3304, y = 3474 } },
+ { from = { x = 3304, y = 3474 }, to = { x = 3298, y = 3476 } },
+ { from = { x = 3138, y = 3516 }, to = { x = 3144, y = 3514 }, cost = 8, actions = [{ object = { option = "Climb-into", id = "edgeville_underwall_tunnel", x = 3139, y = 3516, success = { tile = { x = 3144, y = 3514 } } } }] },
+ { from = { x = 3144, y = 3514 }, to = { x = 3138, y = 3516 }, cost = 8, actions = [{ object = { option = "Climb-into", id = "edgeville_underwall_tunnel", x = 3143, y = 3514, success = { tile = { x = 3148, y = 3516 } } } }] },
+ { from = { x = 3144, y = 3514 }, to = { x = 3149, y = 3504 } },
+ { from = { x = 3149, y = 3504 }, to = { x = 3159, y = 3494 } },
+ { from = { x = 3250, y = 3397 }, to = { x = 3253, y = 3397 } },
+ { from = { x = 3238, y = 3304 }, to = { x = 3230, y = 3310 } },
+ { from = { x = 3230, y = 3310 }, to = { x = 3218, y = 3325 } },
+ { from = { x = 3218, y = 3325 }, to = { x = 3206, y = 3340 } },
+ { from = { x = 3206, y = 3340 }, to = { x = 3201, y = 3355 } },
+ { from = { x = 3201, y = 3355 }, to = { x = 3197, y = 3373 } },
+ { from = { x = 3191, y = 3367 }, to = { x = 3201, y = 3355 } },
+ { from = { x = 3206, y = 3340 }, to = { x = 3191, y = 3350 } },
+ { from = { x = 3191, y = 3350 }, to = { x = 3177, y = 3357 } },
+ { from = { x = 3191, y = 3350 }, to = { x = 3178, y = 3364 } },
+ { from = { x = 3197, y = 3373 }, to = { x = 3179, y = 3381 } },
+ { from = { x = 3191, y = 3367 }, to = { x = 3178, y = 3364 } },
+ { from = { x = 3170, y = 3488 }, to = { x = 3183, y = 3488 } },
+ { from = { x = 3183, y = 3488 }, to = { x = 3195, y = 3488 } },
+ { from = { x = 3195, y = 3488 }, to = { x = 3198, y = 3499 } },
+ { from = { x = 3198, y = 3499 }, to = { x = 3206, y = 3502 } },
+ { from = { x = 3195, y = 3488 }, to = { x = 3196, y = 3474 } },
+ { from = { x = 3196, y = 3474 }, to = { x = 3197, y = 3459 } },
+ { from = { x = 3197, y = 3459 }, to = { x = 3212, y = 3457 } },
+ { from = { x = 3212, y = 3457 }, to = { x = 3212, y = 3446 } },
+ { from = { x = 3212, y = 3446 }, to = { x = 3197, y = 3459 } },
+ { from = { x = 3212, y = 3446 }, to = { x = 3212, y = 3434 } },
+ { from = { x = 3212, y = 3434 }, to = { x = 3213, y = 3428 } },
+ { from = { x = 3212, y = 3434 }, to = { x = 3223, y = 3429 } },
+ { from = { x = 3212, y = 3434 }, to = { x = 3200, y = 3429 } },
+ { from = { x = 3212, y = 3434 }, to = { x = 3203, y = 3432 } },
+ { from = { x = 3203, y = 3432 }, to = { x = 3213, y = 3428 } },
+ { from = { x = 3203, y = 3432 }, to = { x = 3200, y = 3429 } },
+ { from = { x = 3203, y = 3432 }, to = { x = 3200, y = 3442 } },
+ { from = { x = 3200, y = 3442 }, to = { x = 3212, y = 3434 } },
+ { from = { x = 3200, y = 3442 }, to = { x = 3190, y = 3448 } },
+ { from = { x = 3190, y = 3448 }, to = { x = 3187, y = 3445 } },
+ { from = { x = 3190, y = 3448 }, to = { x = 3184, y = 3450 } },
+ { from = { x = 3184, y = 3450 }, to = { x = 3168, y = 3458 } },
+ { from = { x = 3212, y = 3457 }, to = { x = 3213, y = 3464 } },
+ { from = { x = 3213, y = 3464 }, to = { x = 3226, y = 3464 } },
+ { from = { x = 3226, y = 3464 }, to = { x = 3238, y = 3464 } },
+ { from = { x = 3238, y = 3464 }, to = { x = 3245, y = 3462 } },
+ { from = { x = 3238, y = 3458 }, to = { x = 3238, y = 3464 } },
+ { from = { x = 3239, y = 3438 }, to = { x = 3230, y = 3434 } },
+ { from = { x = 3237, y = 3429 }, to = { x = 3244, y = 3418 } },
+ { from = { x = 3211, y = 3381 }, to = { x = 3215, y = 3390 } },
+ { from = { x = 3215, y = 3390 }, to = { x = 3211, y = 3395 } },
+ { from = { x = 3215, y = 3390 }, to = { x = 3227, y = 3390 } },
+ { from = { x = 3227, y = 3390 }, to = { x = 3235, y = 3390 } },
+ { from = { x = 3235, y = 3390 }, to = { x = 3250, y = 3390 } },
+ { from = { x = 3250, y = 3390 }, to = { x = 3250, y = 3397 } },
+ { from = { x = 3250, y = 3390 }, to = { x = 3253, y = 3397 } },
+ { from = { x = 3235, y = 3390 }, to = { x = 3235, y = 3396 } },
+ { from = { x = 3235, y = 3396 }, to = { x = 3241, y = 3401 } },
+ { from = { x = 3241, y = 3401 }, to = { x = 3243, y = 3406 } },
+ { from = { x = 3210, y = 3407 }, to = { x = 3208, y = 3415 } },
+ { from = { x = 3208, y = 3415 }, to = { x = 3211, y = 3420 } },
+ { from = { x = 3211, y = 3420 }, to = { x = 3208, y = 3424 } },
+ { from = { x = 3208, y = 3424 }, to = { x = 3200, y = 3429 } },
+ { from = { x = 3208, y = 3424 }, to = { x = 3213, y = 3428 } },
+ { from = { x = 3286, y = 3371 }, to = { x = 3298, y = 3359 } },
]
\ No newline at end of file
diff --git a/web/site/src/main/kotlin/world/gregs/voidps/web/site/WorldMap.kt b/web/site/src/main/kotlin/world/gregs/voidps/web/site/WorldMap.kt
index dcf88dc8d8..665f08b394 100644
--- a/web/site/src/main/kotlin/world/gregs/voidps/web/site/WorldMap.kt
+++ b/web/site/src/main/kotlin/world/gregs/voidps/web/site/WorldMap.kt
@@ -25,6 +25,12 @@ import world.gregs.voidps.web.site.components.*
* Chrome layout: the map display toggles top-left, the elevation stepper and teleport panel
* stacked top-right, the hovered-tile readout bottom-left and the console (players, search)
* bottom-centre. Only "Kick" is left behind [Site.FULL] — it's staff-only and not wired up yet.
+ *
+ * The bots' navigation graph ([navGraphPanel]) isn't baked in either: it's read from the viewer's
+ * own `*.nav-edges.toml` files, and can be edited and saved back to them — see `js/navgraph.js`.
+ * The area polygons are baked in for viewing, but can likewise be loaded from `*.areas.toml` files
+ * to edit ([areaPanel], [areaEditPanel], `js/areas.js`); both load through [mapFileLoader], and
+ * `js/mapedit.js` holds what the two editors share.
*/
object WorldMap {
@@ -190,6 +196,391 @@ object WorldMap {
}
}
+ /**
+ * The "Load folder…"/"Load files…" buttons under the display panel's toggles, shared by the two
+ * layers that can be edited from files: the nav graph (`*.nav-edges.toml`, nothing to show
+ * until loaded) and the area polygons (`*.areas.toml`, shown from the areas baked into the page
+ * until then). Shown while either is switched on with nothing loaded, and loads every layer in
+ * that state at once, each from its own files — see `kindsToLoad` in mapedit.js.
+ */
+ private fun DIV.mapFileLoader() {
+ div {
+ xShow("kindsToLoad().length")
+ div {
+ style = "display:flex;flex-direction:column;gap:6px;padding-bottom:10px"
+ div {
+ style = HINT_STYLE
+ xText("'Load ' + loadHint() + ' from disk to edit.'")
+ }
+ div {
+ attributes["title"] = "Pick a folder (the repo's data/) — every matching file under it, however deep, is loaded"
+ ui.button(
+ "Load folder…", variant = ButtonVariant.Secondary, size = ButtonSize.Small, fullWidth = true,
+ onClick = "openMapFolder()",
+ )
+ }
+ ui.button(
+ "Load files…", variant = ButtonVariant.Secondary, size = ButtonSize.Small, fullWidth = true,
+ onClick = "openMapFiles()",
+ )
+ errorText("[loadError, navLoaded ? '' : navError, areaLoaded ? '' : areaError].filter(Boolean).join('\\n')")
+ }
+ }
+ }
+
+ /**
+ * Which editor gets the pointer and keyboard, shown while either layer has files loaded and is
+ * showing — see `activeEditor` in mapedit.js. "None" by default, so loaded files are only
+ * looked at until an editor is picked; each editor is offered while its layer is showing. Only
+ * the picked editor's panels are shown, so each button carries a dot while its files are unsaved.
+ */
+ private fun DIV.editorSwitch() {
+ div {
+ xShow("anyEditable()")
+ div {
+ style = "display:flex;flex-direction:column;gap:6px;padding-bottom:10px"
+ span {
+ style = HINT_STYLE
+ +"Editing"
+ }
+ rawHtml(
+ """
+
+
+
+
+
+ """,
+ )
+ }
+ }
+ }
+
+ private const val HINT_STYLE = "font:var(--type-body-sm);font-size:var(--text-2xs);color:var(--text-faint);line-height:var(--leading-normal)"
+
+ /** A red, pre-wrapped message, shown while [expression] is non-empty. */
+ private fun FlowContent.errorText(expression: String) {
+ div {
+ xShow(expression)
+ div {
+ style = "font:var(--type-body-sm);font-size:var(--text-2xs);color:var(--feedback-danger);white-space:pre-wrap"
+ xText(expression)
+ }
+ }
+ }
+
+ /**
+ * The nav graph editor's controls, as a legend. There are no tools to switch between: what a
+ * click or drag does depends on what's under it — see `navInteractive` in navgraph.js.
+ */
+ private val navControls = listOf(
+ "Shift+click point" to "Join the selection to it",
+ "Right-click" to "Delete point or edge",
+ "Esc" to "Stop drawing",
+ )
+
+ /**
+ * The nav graph editor, under the display panel while it's the active editor (see
+ * [editorSwitch]). Drag-to-pan works
+ * everywhere except on a point, which drags the point instead (or anywhere at all with Space
+ * held). Edits stay in memory until Save writes the changed files back (or downloads them, in a
+ * browser that can't write files).
+ */
+ private fun FlowContent.navGraphPanel() {
+ val hint = HINT_STYLE
+ div {
+ xShow("activeEditor() === 'nav'")
+ ui.panel(
+ title = "Nav graph",
+ padded = false,
+ action = { collapseToggle("navPanelOpen", "Toggle nav graph editor") },
+ headerClick = "navPanelOpen = !navPanelOpen",
+ ) {
+ xShow("navPanelOpen")
+ div {
+ // Layout on a nested div, not the `x-show`n one — see the bottom-left readout's comment.
+ style = "padding:var(--space-5) var(--space-6) var(--space-6);display:flex;flex-direction:column;gap:var(--space-4)"
+ div {
+ attributes["class"] = "wm-nav-controls"
+ for ((input, action) in navControls) {
+ span { +input }
+ span { +action }
+ }
+ }
+ div {
+ xShow("navSelection")
+ div {
+ style = "font:var(--type-code);font-size:var(--text-2xs);color:var(--gold-300);" +
+ "padding:6px 8px;background:var(--surface-inset);border:1px solid var(--border-subtle);" +
+ "border-radius:var(--radius-sm);overflow-wrap:anywhere"
+ xText("navSelection")
+ }
+ }
+ div {
+ style = hint
+ +"Files — new edges go into the highlighted one."
+ }
+ rawHtml(
+ """
+
+
+
+
+
+ """,
+ )
+ div {
+ style = hint
+ xText(
+ "navStats + ' in ' + navFiles.length + (navFiles.length === 1 ? ' file' : ' files') + " +
+ "(navDirty ? ' — ' + navFiles.filter(f => f.dirty).length + ' unsaved' : '')",
+ )
+ }
+ div {
+ xShow("navError")
+ div {
+ style = "font:var(--type-body-sm);font-size:var(--text-2xs);color:var(--feedback-danger);white-space:pre-wrap"
+ xText("navError")
+ }
+ }
+ div {
+ xShow("navNotice")
+ div {
+ style = "font:var(--type-body-sm);font-size:var(--text-2xs);color:var(--text-muted);white-space:pre-wrap"
+ xText("navNotice")
+ }
+ }
+ div {
+ style = "display:grid;grid-template-columns:1fr 1fr;gap:6px"
+ div {
+ attributes["title"] = "Undo (Ctrl+Z)"
+ ui.button(
+ "Undo", variant = ButtonVariant.Secondary, size = ButtonSize.Small, fullWidth = true,
+ disabledExpression = "!navCanUndo", onClick = "navUndo()",
+ )
+ }
+ div {
+ attributes["title"] = "Write the selected file"
+ ui.button("Save", size = ButtonSize.Small, fullWidth = true, disabledExpression = "!(navFiles[navTarget] && navFiles[navTarget].dirty)", onClick = "saveNavFile()")
+ }
+ }
+ div {
+ attributes["title"] = "Write every changed file (Ctrl+S)"
+ ui.button("Save all", size = ButtonSize.Small, fullWidth = true, disabledExpression = "!navDirty", onClick = "saveNavFiles()")
+ }
+ div {
+ style = "display:flex;flex-wrap:wrap;justify-content:space-between;gap:2px var(--space-4);min-width:0"
+ ui.button("New", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "navNewFile()")
+ ui.button("Files…", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "openMapFiles(['nav'])")
+ ui.button("Folder…", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "openMapFolder(['nav'])")
+ ui.button("Close", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "closeNavFile()")
+ ui.button("Close all", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "closeNavFiles()")
+ }
+ }
+ }
+ }
+ }
+
+ /** The area editor's controls, as a legend — see `areaPointerDown` and friends in areas.js. */
+ private val areaControls = listOf(
+ "Click area" to "Select it (again: the one beneath)",
+ "Shift+click" to "Start a new area there (N: New area)",
+ "Drag corner" to "Move it",
+ "Drag midpoint" to "Add a corner there",
+ "Right-click" to "Delete corner",
+ "Arrows" to "Nudge corner or area (Shift: 8)",
+ )
+
+ /**
+ * The area editor, under the display panel while it's the active editor (see [editorSwitch]):
+ * the files, with the one new areas go into highlighted, and Undo/Save. Only the loaded files'
+ * areas are drawn while it's up, so an area in a file that wasn't loaded disappears rather than
+ * looking editable. The selected area itself is edited in [areaEditPanel].
+ */
+ private fun FlowContent.areaPanel() {
+ div {
+ xShow("activeEditor() === 'area'")
+ ui.panel(
+ title = "Areas",
+ padded = false,
+ action = { collapseToggle("areaPanelOpen", "Toggle area editor") },
+ headerClick = "areaPanelOpen = !areaPanelOpen",
+ ) {
+ xShow("areaPanelOpen")
+ div {
+ // Layout on a nested div, not the `x-show`n one — see the bottom-left readout's comment.
+ style = "padding:var(--space-5) var(--space-6) var(--space-6);display:flex;flex-direction:column;gap:var(--space-4)"
+ div {
+ attributes["class"] = "wm-nav-controls"
+ for ((input, action) in areaControls) {
+ span { +input }
+ span { +action }
+ }
+ }
+ ui.button(
+ "New area", variant = ButtonVariant.Secondary, size = ButtonSize.Small, fullWidth = true,
+ disabledExpression = "areaDrawing >= 0 || !areaActive()", onClick = "areaStartDraw()",
+ )
+ div {
+ style = HINT_STYLE
+ +"Files — new areas go into the highlighted one."
+ }
+ rawHtml(
+ """
+
+
+
+
+
+ """,
+ )
+ div {
+ style = HINT_STYLE
+ xText(
+ "areaStats + ' in ' + areaFiles.length + (areaFiles.length === 1 ? ' file' : ' files') + " +
+ "(areaDirty ? ' — ' + areaFiles.filter(f => f.dirty).length + ' unsaved' : '')",
+ )
+ }
+ // Shown in the Area panel instead while that's open, next to the field it's about.
+ errorText("!(areaActive() && (areaSel || areaDrawing >= 0)) && areaError")
+ div {
+ xShow("areaNotice")
+ div {
+ style = "font:var(--type-body-sm);font-size:var(--text-2xs);color:var(--text-muted);white-space:pre-wrap"
+ xText("areaNotice")
+ }
+ }
+ div {
+ style = "display:grid;grid-template-columns:1fr 1fr;gap:6px"
+ div {
+ attributes["title"] = "Undo (Ctrl+Z)"
+ ui.button(
+ "Undo", variant = ButtonVariant.Secondary, size = ButtonSize.Small, fullWidth = true,
+ disabledExpression = "!areaCanUndo", onClick = "areaUndo()",
+ )
+ }
+ div {
+ attributes["title"] = "Write the selected file"
+ ui.button("Save", size = ButtonSize.Small, fullWidth = true, disabledExpression = "!(areaFiles[areaTarget] && areaFiles[areaTarget].dirty)", onClick = "saveAreaFile()")
+ }
+ }
+ div {
+ attributes["title"] = "Write every changed file (Ctrl+S)"
+ ui.button("Save all", size = ButtonSize.Small, fullWidth = true, disabledExpression = "!areaDirty", onClick = "saveAreaFiles()")
+ }
+ div {
+ style = "display:flex;flex-wrap:wrap;justify-content:space-between;gap:2px var(--space-4);min-width:0"
+ ui.button("New file", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "areaNewFile()")
+ ui.button("Files…", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "openMapFiles(['area'])")
+ ui.button("Folder…", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "openMapFolder(['area'])")
+ ui.button("Close", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "closeAreaFile()")
+ ui.button("Close all", variant = ButtonVariant.Link, size = ButtonSize.Small, onClick = "closeAreaFiles()")
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * The selected area's editor, in the top-right column where the nav graph's Edges panel goes
+ * while that's the active editor: its name, level, tags and any other keys (a `hint`, params),
+ * each committing on Enter or when it loses focus. While a new area is being drawn it shows how
+ * to finish it instead.
+ */
+ private fun FlowContent.areaEditPanel() {
+ div {
+ attributes["class"] = "wm-nav-edge-wrap"
+ xShow("areaActive() && (areaSel || areaDrawing >= 0)")
+ ui.panel(
+ title = "Area",
+ padded = false,
+ action = { collapseToggle("areaEditPanelOpen", "Toggle area editor") },
+ headerClick = "areaEditPanelOpen = !areaEditPanelOpen",
+ ) {
+ xShow("areaEditPanelOpen")
+ rawHtml(
+ """
+
+
+
+
New area
+
+
Two corners make a rectangle, three or more a polygon. Enter (or clicking the first corner) finishes it, right-click takes the last corner back, Esc cancels.
+
+
+
+
+
+
+
+
+
+
+
+
Another loaded area has this name.
+
+
+
+
+
Tags
+
+
+
+
+
+
+
+
+
Other keys
+
None — a hint, or any param the content reads.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """,
+ )
+ }
+ }
+ }
+
/**
* The chevron in a corner panel's header that collapses it down to that header, bound to
* [model]. The whole header band toggles as well (see [Ui.panel]'s `headerClick`) — this stays
@@ -254,6 +645,114 @@ object WorldMap {
}
}
+ /**
+ * The nav graph's edge inspector, under the teleport panel. With a point selected it lists the
+ * point's edges (the only way to reach one of a two-way pair, which draw over each other); with
+ * an edge selected it edits that edge's cost, actions and requirements — see the "Edge editor"
+ * section of navgraph.js. Every field commits on Enter or when it loses focus.
+ */
+ private fun FlowContent.navEdgePanel() {
+ div {
+ attributes["class"] = "wm-nav-edge-wrap"
+ xShow("navActive() && (navEdge || navNodeEdges.length)")
+ ui.panel(
+ title = "Edges",
+ padded = false,
+ action = { collapseToggle("navEdgePanelOpen", "Toggle edge editor") },
+ headerClick = "navEdgePanelOpen = !navEdgePanelOpen",
+ ) {
+ xShow("navEdgePanelOpen")
+ rawHtml(
+ """
+
+
+
+
This point's edges — pick one to edit its actions.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Actions
+
None: bots just walk it, either way. Adding one makes the edge one-way.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Not a nav action type
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """,
+ )
+ }
+ }
+ }
+
/** Shared style for one row of the console's players/search lists. */
private const val ROW_STYLE = "display:flex;align-items:center;justify-content:space-between;gap:var(--space-5);" +
"padding:8px 14px;border-left:2px solid transparent;border-bottom:1px solid var(--border-subtle);cursor:pointer"
@@ -369,6 +868,9 @@ object WorldMap {
head = {
link(rel = "stylesheet", href = "style/world-map.css")
script { unsafe { raw(tileBaseScript() + areasScript() + mapLabels.script()) } }
+ script(src = "js/mapedit.js") {}
+ script(src = "js/navgraph.js") {}
+ script(src = "js/areas.js") {}
script(src = "js/worldmap.js") {}
},
) {
@@ -406,6 +908,18 @@ object WorldMap {
style = "position:absolute;inset:0;pointer-events:none;overflow:hidden"
// Filled from `window.VOID_MAP_LABELS` (see [MapLabels]) on every render.
}
+ div {
+ // Outline of the tile under the cursor, placed by `updateHoverTile`.
+ attributes["id"] = "wm-hover-tile"
+ attributes["class"] = "wm-hover-tile"
+ }
+ div {
+ // Rebuilt by `renderNavGraph` (navgraph.js) on every render while it's on, and
+ // emptied while it's off. Nothing in it takes pointer events: the editor hit-tests
+ // the graph itself from the viewport's own handlers.
+ attributes["id"] = "wm-nav-graph"
+ style = "position:absolute;inset:0;pointer-events:none;overflow:hidden"
+ }
div {
attributes["id"] = "wm-players"
xShow("showPlayerPins")
@@ -466,7 +980,8 @@ object WorldMap {
// shrunk down to just its header on a small screen without losing the toggles.
div {
attributes["class"] = "wm-display-wrap"
- style = "position:absolute;top:20px;left:20px;width:190px;z-index:25"
+ style = "position:absolute;top:20px;left:20px;width:190px;z-index:25;display:flex;" +
+ "flex-direction:column;gap:var(--space-5)"
ui.panel(
title = "Map display",
padded = false,
@@ -479,18 +994,24 @@ object WorldMap {
displayToggle("Area polygons", "showAreaPolygons")
displayToggle("Region grid", "showRegionGrid")
displayToggle("Region labels", "showRegionLabels")
- displayToggle("Player pins", "showPlayerPins", last = true)
+ displayToggle("Player pins", "showPlayerPins")
+ displayToggle("Nav graph", "showNavGraph", last = true)
+ mapFileLoader()
+ editorSwitch()
}
+ navGraphPanel()
+ areaPanel()
}
// Top-right column: the elevation stepper, with the teleport panel stacked under it.
// The column is sized by its contents rather than to a width of its own — the stepper
// pill has a minimum width, and a narrower column would simply centre it over the
- // column's edges and push it off the side of a phone screen.
+ // column's edges and push it off the side of a phone screen. Stacked a level above the
+ // bottom console, which the nav graph's edge panel can grow down beside.
div {
attributes["class"] = "wm-top-right"
style = "position:absolute;top:20px;right:20px;display:flex;flex-direction:column;" +
- "align-items:flex-end;gap:var(--space-5);z-index:25"
+ "align-items:flex-end;gap:var(--space-5);z-index:26"
div {
style = "display:flex;flex-direction:column;align-items:center;gap:7px"
div {
@@ -542,6 +1063,8 @@ object WorldMap {
}
}
teleportPanel()
+ navEdgePanel()
+ areaEditPanel()
}
// Bottom-left coordinate readout — the game tile under the cursor, plus the name(s) of
diff --git a/web/site/src/main/kotlin/world/gregs/voidps/web/site/components/Input.kt b/web/site/src/main/kotlin/world/gregs/voidps/web/site/components/Input.kt
index b171fc02e9..4ce1cda867 100644
--- a/web/site/src/main/kotlin/world/gregs/voidps/web/site/components/Input.kt
+++ b/web/site/src/main/kotlin/world/gregs/voidps/web/site/components/Input.kt
@@ -140,11 +140,15 @@ fun Ui.select(
}
}
-/** A toggle switch. [model] is a boolean Alpine expression; [small] renders the compact 32px track. */
-fun Ui.switch(text: String, model: String, small: Boolean = false) {
+/**
+ * A toggle switch. [model] is a boolean Alpine expression; [small] renders the compact 32px track.
+ * [onToggle] replaces the default flip of [model] on click, for a switch that has to do something
+ * else first (e.g. open a file before it can turn on).
+ */
+fun Ui.switch(text: String, model: String, small: Boolean = false, onToggle: String = "$model = !$model") {
receiver.label {
style = "display:inline-flex;align-items:center;gap:10px;cursor:pointer"
- onClick("$model = !$model")
+ onClick(onToggle)
val trackWidth = if (small) 32 else 40
val trackHeight = if (small) 18 else 22
val knob = if (small) 12 else 16
diff --git a/web/site/src/main/resources/static/js/areas.js b/web/site/src/main/resources/static/js/areas.js
new file mode 100644
index 0000000000..89a8859cb0
--- /dev/null
+++ b/web/site/src/main/resources/static/js/areas.js
@@ -0,0 +1,1422 @@
+// Area polygon editor for the world map (see WorldMap.kt's "Areas" panels).
+//
+// Areas live in `*.areas.toml` files under `data/` (read by the game's Areas.load): one `[name]`
+// section per area with `x` and `y` arrays of tile coordinates, an optional `level` (left out, the
+// area covers every level), optional `tags`, and any other keys (a `hint`, params) the content
+// reads. Two points make a rectangle between them, three or more a polygon, both inclusive of their
+// edges. (One point is a one-tile rectangle.)
+//
+// The page is built with every area the server loads baked in (`window.VOID_AREAS`) for the polygon
+// layer and search to draw from. Loading files swaps that for what's in them, editable, and saved
+// back to the same files (or downloaded, in a browser that can't write them) — the same way the nav
+// graph editor (navgraph.js) works, with mapedit.js holding what the two share and deciding which
+// one the pointer goes to while both are showing.
+//
+// As there, a save edits the file's own text rather than re-serialising it: `parseAreaFile` splits
+// it into one chunk per section (taking the comment lines directly above a header with it) and
+// remembers where each key's value sits, and `areaText` rewrites only the values that changed.
+// Untouched sections, comments, key order and extra keys survive a save as they were.
+(function () {
+ var T = window.VoidToml;
+ // Screen-space hit radius, in CSS pixels, for picking a vertex or edge midpoint handle.
+ var HANDLE_HIT_PX = 8;
+ // A press that travels further than this before release is a pan, not a click.
+ var CLICK_SLOP_PX = 4;
+ // Edges shorter than this on screen get no midpoint handle — it'd sit on top of the vertices.
+ var MID_MIN_PX = 28;
+ var UNDO_LIMIT = 100;
+ // The keys the editor manages itself, in the order the existing files write them. Everything
+ // else in a section is an "extra", edited as a raw key/value pair.
+ var SHAPE_KEYS = ['x', 'y', 'level', 'tags'];
+ var NAME_RE = /^[A-Za-z0-9_-]+$/;
+
+ // --- Parsing -------------------------------------------------------------------------------
+
+ // The top-level lines of an areas file: `[name]` headers, `key = value` pairs (a bracketed value
+ // may run over several lines, and a trailing comment stays with its pair), comments and blank
+ // lines — each with its span in `text`.
+ function scanLines(text) {
+ var out = [];
+ var i = 0;
+ while (i < text.length) {
+ var lineEnd = text.indexOf('\n', i);
+ lineEnd = lineEnd === -1 ? text.length : lineEnd + 1;
+ var line = text.slice(i, lineEnd);
+ var trimmed = line.trim();
+ var lead = line.length - line.replace(/^[ \t]+/, '').length;
+ if (trimmed === '' || trimmed[0] === '#') {
+ out.push({ type: trimmed === '' ? 'blank' : 'comment', start: i, end: lineEnd });
+ i = lineEnd;
+ continue;
+ }
+ if (trimmed[0] === '[') {
+ var header = /^\[[ \t]*("(?:[^"\\]|\\.)*"|[^\]\s]+)[ \t]*\]/.exec(trimmed);
+ if (!header) {
+ throw new Error('Unreadable section header: ' + trimmed.slice(0, 60));
+ }
+ var nameStart = i + lead + header[0].indexOf(header[1]);
+ out.push({ type: 'header', name: T.stringValue(header[1]), start: i, end: lineEnd, nameStart: nameStart, nameEnd: nameStart + header[1].length });
+ i = lineEnd;
+ continue;
+ }
+ var pair = /^([A-Za-z0-9_-]+|"[^"]*")[ \t]*=[ \t]*/.exec(line.slice(lead));
+ if (!pair) {
+ throw new Error('Unreadable line: ' + trimmed.slice(0, 60));
+ }
+ var valueStart = i + lead + pair[0].length;
+ var valueEnd;
+ var c = text[valueStart];
+ if (c === '[' || c === '{') {
+ valueEnd = T.matchBracket(text, valueStart);
+ } else if (c === '"' || c === "'") {
+ valueEnd = T.skipString(text, valueStart);
+ } else {
+ valueEnd = valueStart;
+ while (valueEnd < text.length && !/[\r\n#]/.test(text[valueEnd])) {
+ valueEnd++;
+ }
+ while (valueEnd > valueStart && /[ \t]/.test(text[valueEnd - 1])) {
+ valueEnd--;
+ }
+ }
+ var end = text.indexOf('\n', valueEnd);
+ end = end === -1 ? text.length : end + 1;
+ out.push({ type: 'pair', key: T.stringValue(pair[1]), start: i, end: end, valueStart: valueStart, valueEnd: valueEnd });
+ i = end;
+ }
+ return out;
+ }
+
+ function ints(raw, what) {
+ return T.splitArray(raw).map(function (v) {
+ var n = parseInt(v, 10);
+ if (isNaN(n)) {
+ throw new Error(what + ': "' + v + '" isn\'t a number');
+ }
+ return n;
+ });
+ }
+
+ // The part of an area the editor changes — also what an undo checkpoint copies.
+ function stateOf(area) {
+ return {
+ name: area.name,
+ x: area.x.slice(),
+ y: area.y.slice(),
+ level: area.level,
+ tags: area.tags.slice(),
+ extras: area.extras.map(function (e) {
+ return { key: e.key, value: e.value };
+ }),
+ };
+ }
+
+ function applyState(area, state) {
+ area.name = state.name;
+ area.x = state.x.slice();
+ area.y = state.y.slice();
+ area.level = state.level;
+ area.tags = state.tags.slice();
+ area.extras = state.extras.map(function (e) {
+ return { key: e.key, value: e.value };
+ });
+ }
+
+ // `pre` is whatever comes before the first section; each area keeps its own chunk of the text
+ // (its header up to the next area's chunk) with every span in it relative to that chunk.
+ function parseAreaFile(text) {
+ var lines = scanLines(text);
+ var eol = text.indexOf('\r\n') !== -1 ? '\r\n' : '\n';
+ var headers = [];
+ lines.forEach(function (line, i) {
+ if (line.type === 'header') {
+ headers.push(i);
+ }
+ });
+ // A comment block sitting directly on a header (no blank line between) is about that area, so
+ // it moves or goes with it.
+ var starts = headers.map(function (h) {
+ var k = h;
+ while (k > 0 && lines[k - 1].type === 'comment') {
+ k--;
+ }
+ return k;
+ });
+ var areas = [];
+ for (var n = 0; n < headers.length; n++) {
+ var base = lines[starts[n]].start;
+ var chunkEnd = n + 1 < headers.length ? lines[starts[n + 1]].start : text.length;
+ var header = lines[headers[n]];
+ var pairs = [];
+ var last = n + 1 < headers.length ? starts[n + 1] : lines.length;
+ for (var l = headers[n] + 1; l < last; l++) {
+ if (lines[l].type === 'pair') {
+ var p = lines[l];
+ pairs.push({ key: p.key, start: p.start - base, end: p.end - base, valueStart: p.valueStart - base, valueEnd: p.valueEnd - base });
+ }
+ }
+ var chunk = text.slice(base, chunkEnd);
+ var area = {
+ text: chunk,
+ headerEnd: header.end - base,
+ nameSpan: { start: header.nameStart - base, end: header.nameEnd - base },
+ pairs: pairs,
+ name: header.name,
+ x: [],
+ y: [],
+ level: null,
+ tags: [],
+ extras: [],
+ added: false,
+ };
+ pairs.forEach(function (pair) {
+ var raw = chunk.slice(pair.valueStart, pair.valueEnd);
+ if (pair.key === 'x' || pair.key === 'y') {
+ area[pair.key] = ints(raw, header.name + '.' + pair.key);
+ } else if (pair.key === 'level') {
+ area.level = parseInt(raw, 10);
+ } else if (pair.key === 'tags') {
+ area.tags = T.splitArray(raw).map(T.stringValue);
+ } else {
+ area.extras.push({ key: pair.key, value: T.displayValue(T.oneLine(raw)) });
+ }
+ });
+ // Like Areas.load: up to two x values is a rectangle over both arrays' min and max, whatever
+ // y's length; more is a polygon, which needs a y for every x.
+ if (!area.x.length || !area.y.length || (area.x.length > 2 && area.x.length !== area.y.length)) {
+ throw new Error('[' + header.name + '] needs x and y arrays of the same length');
+ }
+ area.orig = stateOf(area);
+ areas.push(area);
+ }
+ return { pre: text.slice(0, headers.length ? lines[starts[0]].start : text.length), areas: areas, eol: eol };
+ }
+
+ // --- Serialising ---------------------------------------------------------------------------
+
+ function formatName(name) {
+ return NAME_RE.test(name) ? name : JSON.stringify(name);
+ }
+
+ function formatValue(key, state) {
+ if (key === 'x' || key === 'y') {
+ return '[' + state[key].join(', ') + ']';
+ }
+ if (key === 'level') {
+ return String(state.level);
+ }
+ return '[' + state.tags.map(function (tag) {
+ return JSON.stringify(tag);
+ }).join(', ') + ']';
+ }
+
+ function hasKey(key, state) {
+ return key === 'level' ? state.level !== null : key === 'tags' ? state.tags.length > 0 : true;
+ }
+
+ function sameValue(key, a, b) {
+ return JSON.stringify(a[key]) === JSON.stringify(b[key]);
+ }
+
+ // A brand-new section, written the way the existing files are: shape keys first, then extras.
+ // `serializeAreaFile` puts the blank line before it.
+ function freshText(state, eol) {
+ var text = '[' + formatName(state.name) + ']' + eol;
+ SHAPE_KEYS.forEach(function (key) {
+ if (hasKey(key, state)) {
+ text += key + ' = ' + formatValue(key, state) + eol;
+ }
+ });
+ state.extras.forEach(function (extra) {
+ if (extra.key) {
+ text += extra.key + ' = ' + T.tomlValue(extra.value) + eol;
+ }
+ });
+ return text;
+ }
+
+ // The area's chunk with only what changed rewritten: a renamed header, a changed value replaced
+ // in place, a removed key's line dropped and an added key's line inserted where it belongs.
+ function areaText(area, eol) {
+ var now = stateOf(area);
+ if (area.added) {
+ return freshText(now, eol);
+ }
+ var orig = area.orig;
+ if (JSON.stringify(now) === JSON.stringify(orig)) {
+ return area.text;
+ }
+ var text = area.text;
+ var ops = [];
+ function insert(at, line) {
+ ops.push({ start: at, end: at, text: line + eol, insert: true });
+ }
+ if (now.name !== orig.name) {
+ ops.push({ start: area.nameSpan.start, end: area.nameSpan.end, text: formatName(now.name) });
+ }
+ function pairFor(key) {
+ return area.pairs.filter(function (p) {
+ return p.key === key;
+ })[0];
+ }
+ var at = area.headerEnd;
+ SHAPE_KEYS.forEach(function (key) {
+ var pair = pairFor(key);
+ var has = hasKey(key, now);
+ if (pair) {
+ if (!has) {
+ ops.push({ start: pair.start, end: pair.end, text: '' });
+ } else if (!sameValue(key, now, orig)) {
+ ops.push({ start: pair.valueStart, end: pair.valueEnd, text: formatValue(key, now) });
+ }
+ at = Math.max(at, pair.end);
+ } else if (has) {
+ insert(at, key + ' = ' + formatValue(key, now));
+ }
+ });
+ // Extras are matched up by key: one whose key is gone is removed, one with a new key added
+ // after the last line of the section.
+ var seen = {};
+ var lastPairEnd = area.pairs.reduce(function (end, p) {
+ return Math.max(end, p.end);
+ }, area.headerEnd);
+ area.pairs.forEach(function (pair) {
+ if (SHAPE_KEYS.indexOf(pair.key) !== -1 || seen[pair.key]) {
+ return;
+ }
+ seen[pair.key] = true;
+ var before = orig.extras.filter(function (e) {
+ return e.key === pair.key;
+ })[0];
+ var after = now.extras.filter(function (e) {
+ return e.key === pair.key;
+ })[0];
+ if (!after) {
+ ops.push({ start: pair.start, end: pair.end, text: '' });
+ } else if (!before || before.value !== after.value) {
+ ops.push({ start: pair.valueStart, end: pair.valueEnd, text: T.tomlValue(after.value) });
+ }
+ });
+ now.extras.forEach(function (extra) {
+ if (extra.key && !seen[extra.key]) {
+ seen[extra.key] = true;
+ insert(lastPairEnd, extra.key + ' = ' + T.tomlValue(extra.value));
+ }
+ });
+ // Applied back to front so earlier spans stay valid. At the same position a replacement or
+ // removal goes before an insertion (so the insertion isn't swallowed by it), and insertions at
+ // the same point keep the order they were made in.
+ ops.forEach(function (op, i) {
+ op.i = i;
+ if (op.insert) {
+ // A line inserted after one without a line break (the last line of the file) needs one of
+ // its own first — judged by the line that'll actually precede it, past any being removed.
+ var at = op.start;
+ for (var k = 0; k < ops.length; k++) {
+ if (!ops[k].insert && !ops[k].text && ops[k].end === at && ops[k].start < at) {
+ at = ops[k].start;
+ k = -1;
+ }
+ }
+ if (at > 0 && text[at - 1] !== '\n') {
+ op.text = eol + op.text;
+ }
+ }
+ });
+ ops.sort(function (a, b) {
+ return b.start - a.start || b.end - a.end || b.i - a.i;
+ });
+ ops.forEach(function (op) {
+ text = text.slice(0, op.start) + op.text + text.slice(op.end);
+ });
+ return text;
+ }
+
+ function serializeAreaFile(file) {
+ var out = file.pre;
+ file.areas.forEach(function (area) {
+ if (area.added && out) {
+ if (!/\n$/.test(out)) {
+ out += file.eol;
+ }
+ if (!/\n[ \t]*\r?\n$/.test(out)) {
+ out += file.eol;
+ }
+ }
+ out += areaText(area, file.eol);
+ });
+ return out;
+ }
+
+ // --- Geometry ------------------------------------------------------------------------------
+
+ function isRect(area) {
+ return area.x.length <= 2;
+ }
+
+ function bounds(xs, ys) {
+ return { minX: Math.min.apply(null, xs), maxX: Math.max.apply(null, xs), minY: Math.min.apply(null, ys), maxY: Math.max.apply(null, ys) };
+ }
+
+ // The points drawn and dragged: a rectangle's four corners (south-west first, anticlockwise, the
+ // same order WorldMap.kt expands the baked ones in), or a polygon's own vertices.
+ function vertices(area) {
+ if (isRect(area)) {
+ var b = bounds(area.x, area.y);
+ return [
+ { x: b.minX, y: b.minY },
+ { x: b.maxX, y: b.minY },
+ { x: b.maxX, y: b.maxY },
+ { x: b.minX, y: b.maxY },
+ ];
+ }
+ return area.x.map(function (x, i) {
+ return { x: x, y: area.y[i] };
+ });
+ }
+
+ function toPolygon(area) {
+ var v = vertices(area);
+ area.x = v.map(function (p) {
+ return p.x;
+ });
+ area.y = v.map(function (p) {
+ return p.y;
+ });
+ }
+
+ // Moves vertex `index` to `tile`, returning its index afterwards. A rectangle stays one: the
+ // dragged corner moves and the opposite one stays put, which can flip which corner it is.
+ function moveVertex(area, index, tile) {
+ if (!isRect(area)) {
+ area.x[index] = tile.x;
+ area.y[index] = tile.y;
+ return index;
+ }
+ var v = vertices(area);
+ var opposite = v[(index + 2) % 4];
+ var wasMaxX = index === 1 || index === 2;
+ var wasMaxY = index === 2 || index === 3;
+ area.x = [Math.min(tile.x, opposite.x), Math.max(tile.x, opposite.x)];
+ area.y = [Math.min(tile.y, opposite.y), Math.max(tile.y, opposite.y)];
+ var maxX = tile.x === opposite.x ? wasMaxX : tile.x > opposite.x;
+ var maxY = tile.y === opposite.y ? wasMaxY : tile.y > opposite.y;
+ return maxY ? (maxX ? 2 : 3) : maxX ? 1 : 0;
+ }
+
+ // The server's own test (Polygon.pointInPolygon), integer division and all, so the editor agrees
+ // with the game about exactly which tiles are in: inside, or anywhere on an edge.
+ function contains(xs, ys, x, y) {
+ var inside = false;
+ for (var i = 0, j = xs.length - 1; i < xs.length; j = i++) {
+ var dxl = xs[j] - xs[i];
+ var dyl = ys[j] - ys[i];
+ if (ys[i] > y !== ys[j] > y && x < Math.trunc((dxl * (y - ys[i])) / dyl) + xs[i]) {
+ inside = !inside;
+ }
+ if ((x - xs[i]) * dyl - (y - ys[i]) * dxl === 0) {
+ if (Math.abs(dxl) >= Math.abs(dyl)) {
+ if (x >= Math.min(xs[i], xs[j]) && x <= Math.max(xs[i], xs[j])) {
+ return true;
+ }
+ } else if (y >= Math.min(ys[i], ys[j]) && y <= Math.max(ys[i], ys[j])) {
+ return true;
+ }
+ }
+ }
+ return inside;
+ }
+
+ window.VoidAreas = { parse: parseAreaFile, serialize: serializeAreaFile, contains: contains };
+
+ // --- Alpine methods --------------------------------------------------------------------------
+
+ // Mixed into `worldMapApp()`'s data object. As with the nav graph, the files and undo history live
+ // out here in the closure rather than on the (Alpine-proxied) object — file handles can't be
+ // called through a Proxy, and object identity is how the selection is tracked — and the panels
+ // only read the plain summaries `syncAreaState` copies across.
+ window.areaEditorMethods = function () {
+ var ar = {
+ files: [],
+ // What the polygon layer, hover readout and search draw from once files are loaded:
+ // `{ name, minLevel, maxLevel, x, y }` like `window.VOID_AREAS`, rebuilt on every edit.
+ shapes: [],
+ selected: null,
+ // Index into the selected area's `vertices` of the selected vertex, or -1.
+ vertex: -1,
+ selSig: null,
+ undo: [],
+ press: null,
+ drag: null,
+ // Tiles of the area being drawn, or null when not drawing.
+ draft: null,
+ hover: null,
+ panKey: false,
+ };
+
+ return {
+ areaLoaded: false,
+ areaPanelOpen: true,
+ areaEditPanelOpen: true,
+ areaFiles: [],
+ // The file new areas go into. Follows the selection.
+ areaTarget: 0,
+ areaDirty: false,
+ areaCanUndo: false,
+ areaStats: '',
+ areaError: '',
+ areaNotice: '',
+ // The Area panel's copy of the selected area (null when none), and every tag in use, for the
+ // tag field's suggestions.
+ areaSel: null,
+ areaTags: [],
+ // Points placed so far while drawing a new area, or -1 when not drawing.
+ areaDrawing: -1,
+
+ areaBoot: function () {
+ var self = this;
+ window.addEventListener('keydown', function (e) {
+ self.areaKeyDown(e);
+ });
+ window.addEventListener('keyup', function (e) {
+ if (e.key === ' ' && ar.panKey) {
+ e.preventDefault();
+ ar.panKey = false;
+ self.areaUpdateCursor(null);
+ }
+ });
+ window.addEventListener('blur', function () {
+ ar.panKey = false;
+ });
+ window.addEventListener('beforeunload', function (e) {
+ if (self.areaDirty) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
+ },
+
+ // What the polygon layer draws: the loaded files' areas, or the ones baked into the page.
+ areaShapes: function () {
+ return this.areaLoaded ? ar.shapes : window.VOID_AREAS || [];
+ },
+
+ // --- Files ---
+
+ // Opening a file that's already open (by path) replaces it rather than doubling its areas.
+ areaAddFiles: function (loaded) {
+ var errors = [];
+ loaded.forEach(function (entry) {
+ var parsed;
+ try {
+ parsed = parseAreaFile(entry.text);
+ } catch (e) {
+ errors.push(entry.name + ': ' + e.message);
+ return;
+ }
+ parsed.name = entry.name;
+ parsed.path = entry.path;
+ parsed.handle = entry.handle;
+ parsed.dirty = false;
+ var existing = ar.files.findIndex(function (file) {
+ return file.path === entry.path;
+ });
+ if (existing === -1) {
+ ar.files.push(parsed);
+ } else {
+ ar.files[existing] = parsed;
+ }
+ });
+ this.areaError = errors.join('\n');
+ this.areaNotice = '';
+ ar.selected = null;
+ ar.undo = [];
+ if (ar.files.length) {
+ this.showAreaPolygons = true;
+ }
+ this.areaRebuild();
+ },
+
+ closeAreaFiles: function () {
+ if (this.areaDirty && !window.confirm('Discard unsaved area changes?')) {
+ return;
+ }
+ ar.files = [];
+ ar.selected = null;
+ ar.draft = null;
+ ar.undo = [];
+ this.showAreaPolygons = false;
+ this.areaError = '';
+ this.areaNotice = '';
+ this.areaTarget = 0;
+ this.areaRebuild();
+ },
+
+ // Closes just the selected file, leaving the others open.
+ closeAreaFile: function () {
+ var file = ar.files[this.areaTarget];
+ if (!file) {
+ return;
+ }
+ if (file.dirty && !window.confirm('Discard unsaved changes to ' + file.name + '?')) {
+ return;
+ }
+ ar.files.splice(this.areaTarget, 1);
+ ar.selected = null;
+ ar.draft = null;
+ ar.undo = [];
+ this.areaError = '';
+ this.areaNotice = '';
+ this.areaTarget = 0;
+ if (!ar.files.length) {
+ this.showAreaPolygons = false;
+ }
+ this.areaRebuild();
+ },
+
+ areaPickFile: function (index) {
+ this.areaTarget = index;
+ },
+
+ // Adds an empty `*.areas.toml` — see `VoidMapFiles.create`.
+ areaNewFile: function () {
+ var self = this;
+ VoidMapFiles.create('area', '')
+ .then(function (created) {
+ if (!created) {
+ return;
+ }
+ if (ar.files.some(function (file) {
+ return file.path === created.name;
+ })) {
+ self.areaError = created.name + ' is already open.';
+ return;
+ }
+ var parsed = parseAreaFile('');
+ parsed.name = created.name;
+ parsed.path = created.name;
+ parsed.handle = created.handle;
+ parsed.dirty = created.dirty;
+ ar.files.push(parsed);
+ ar.undo = [];
+ self.areaError = created.warning;
+ self.showAreaPolygons = true;
+ self.setMapEditor('area');
+ self.areaTarget = ar.files.length - 1;
+ self.areaRebuild();
+ })
+ .catch(function (e) {
+ self.areaError = String(e.message || e);
+ });
+ },
+
+ // One file at a time, for the same reason as `saveNavFiles`: a second permission prompt while
+ // the first is showing is rejected outright.
+ saveAreaFiles: function () {
+ this.areaSave(null);
+ },
+
+ // Just the file new areas go into (the highlighted row).
+ saveAreaFile: function () {
+ this.areaSave(ar.files[this.areaTarget] || null);
+ },
+
+ // `only`: the one file to write, or null for every changed file.
+ areaSave: function (only) {
+ var self = this;
+ var written = [];
+ var downloaded = [];
+ var chain = Promise.resolve();
+ var selectedName = ar.selected ? ar.selected.name : null;
+ ar.files.forEach(function (file) {
+ if (!file.dirty || (only && file !== only)) {
+ return;
+ }
+ chain = chain.then(function () {
+ var text = serializeAreaFile(file);
+ return VoidMapFiles.write(file, text).then(function () {
+ (file.handle ? written : downloaded).push(file.name);
+ // Re-parse what was written so the next save diffs against it, not the original.
+ var parsed = parseAreaFile(text);
+ file.pre = parsed.pre;
+ file.areas = parsed.areas;
+ file.dirty = false;
+ });
+ });
+ });
+ this.areaNotice = '';
+ chain
+ .then(function () {
+ self.areaError = '';
+ ar.undo = [];
+ })
+ .catch(function (e) {
+ self.areaError = 'Save failed: ' + (e.message || e);
+ })
+ .then(function () {
+ var notice = [];
+ if (written.length) {
+ notice.push('Saved ' + written.join(', ') + '.');
+ }
+ if (downloaded.length) {
+ notice.push('Downloaded ' + downloaded.join(', ') + ' — this browser can\'t write to the opened files, so copy the download over the original.');
+ }
+ self.areaNotice = notice.join(' ');
+ // Saved files hold new area objects; keep the same area selected.
+ ar.selected = selectedName === null ? null : self.areaNamed(selectedName);
+ self.areaRebuild();
+ });
+ },
+
+ // --- Model ---
+
+ areaNamed: function (name) {
+ for (var f = 0; f < ar.files.length; f++) {
+ for (var i = 0; i < ar.files[f].areas.length; i++) {
+ if (ar.files[f].areas[i].name === name) {
+ return ar.files[f].areas[i];
+ }
+ }
+ }
+ return null;
+ },
+
+ areaRebuild: function () {
+ var shapes = [];
+ var tags = {};
+ var count = 0;
+ ar.files.forEach(function (file, f) {
+ file.areas.forEach(function (area) {
+ area.file = f;
+ count++;
+ var v = vertices(area);
+ shapes.push({
+ name: area.name,
+ minLevel: area.level === null ? 0 : area.level,
+ maxLevel: area.level === null ? 3 : area.level,
+ x: v.map(function (p) {
+ return p.x;
+ }),
+ y: v.map(function (p) {
+ return p.y;
+ }),
+ area: area,
+ });
+ area.tags.forEach(function (tag) {
+ tags[tag] = true;
+ });
+ });
+ });
+ ar.shapes = shapes;
+ if (ar.selected && !this.areaExists(ar.selected)) {
+ ar.selected = null;
+ }
+ this.areaTags = Object.keys(tags).sort();
+ this.areaStats = ar.files.length ? count + ' areas' : '';
+ // The search index is built from the areas being drawn.
+ this._searchEntries = null;
+ this.syncAreaState();
+ this.scheduleRender();
+ },
+
+ areaExists: function (area) {
+ var file = ar.files[area.file];
+ return !!file && file.areas.indexOf(area) !== -1;
+ },
+
+ syncAreaState: function () {
+ this.areaLoaded = ar.files.length > 0;
+ this.areaFiles = ar.files.map(function (file, i) {
+ return { index: i, name: file.name, path: file.path, dirty: file.dirty, count: file.areas.length, writable: !!file.handle };
+ });
+ this.areaDirty = ar.files.some(function (file) {
+ return file.dirty;
+ });
+ this.areaCanUndo = ar.undo.length > 0;
+ this.areaDrawing = ar.draft ? ar.draft.length : -1;
+ if (this.areaTarget >= ar.files.length) {
+ this.areaTarget = 0;
+ }
+ var area = ar.selected;
+ if (area !== ar.selSig) {
+ // A newly picked area makes the file it lives in the target.
+ ar.selSig = area;
+ ar.vertex = -1;
+ if (area) {
+ this.areaTarget = area.file;
+ }
+ }
+ if (!area) {
+ this.areaSel = null;
+ return;
+ }
+ var v = vertices(area);
+ if (ar.vertex >= v.length) {
+ ar.vertex = -1;
+ }
+ var b = bounds(area.x, area.y);
+ var clash = ar.shapes.filter(function (shape) {
+ return shape.name === area.name;
+ }).length > 1;
+ this.areaSel = {
+ name: area.name,
+ file: ar.files[area.file] ? ar.files[area.file].name : '',
+ shape: isRect(area) ? 'Rectangle' : 'Polygon · ' + area.x.length + ' points',
+ bounds: b.minX + ', ' + b.minY + ' → ' + b.maxX + ', ' + b.maxY,
+ level: area.level === null ? '' : String(area.level),
+ tags: area.tags.slice(),
+ extras: area.extras.map(function (e) {
+ return { key: e.key, value: e.value };
+ }),
+ clash: clash,
+ vertex: ar.vertex >= 0 ? v[ar.vertex].x + ', ' + v[ar.vertex].y : '',
+ canDeleteVertex: ar.vertex >= 0 && !isRect(area) && area.x.length > 3,
+ };
+ },
+
+ areaCheckpoint: function () {
+ ar.undo.push({
+ files: ar.files.map(function (file) {
+ return {
+ dirty: file.dirty,
+ areas: file.areas.map(function (area) {
+ return { area: area, state: stateOf(area) };
+ }),
+ };
+ }),
+ draft: ar.draft ? ar.draft.slice() : null,
+ selected: ar.selected,
+ target: this.areaTarget,
+ });
+ if (ar.undo.length > UNDO_LIMIT) {
+ ar.undo.shift();
+ }
+ },
+
+ areaUndo: function () {
+ var state = ar.undo.pop();
+ if (!state) {
+ return;
+ }
+ for (var f = 0; f < state.files.length && f < ar.files.length; f++) {
+ ar.files[f].dirty = state.files[f].dirty;
+ ar.files[f].areas = state.files[f].areas.map(function (entry) {
+ applyState(entry.area, entry.state);
+ return entry.area;
+ });
+ }
+ ar.draft = state.draft;
+ ar.selected = state.selected;
+ this.areaTarget = state.target;
+ this.areaRebuild();
+ },
+
+ // Every change to an area goes through here: an undo checkpoint, the change, and the file
+ // marked unsaved — unless the change turned out to change nothing.
+ areaEdit: function (change) {
+ var area = ar.selected;
+ if (!area) {
+ return;
+ }
+ var before = JSON.stringify(stateOf(area));
+ this.areaCheckpoint();
+ change.call(this, area);
+ if (JSON.stringify(stateOf(area)) === before) {
+ ar.undo.pop();
+ this.syncAreaState();
+ return;
+ }
+ ar.files[area.file].dirty = true;
+ this.areaRebuild();
+ },
+
+ areaSelect: function (area) {
+ ar.selected = area;
+ ar.vertex = -1;
+ this.syncAreaState();
+ this.scheduleRender();
+ },
+
+ // Picking an area in the search pane while the editor has files loaded selects it too.
+ areaSelectNamed: function (name) {
+ if (this.activeEditor() !== 'area') {
+ return;
+ }
+ var area = this.areaNamed(name);
+ if (area) {
+ this.areaSelect(area);
+ }
+ },
+
+ areaFrameSelected: function () {
+ var area = ar.selected;
+ if (!area) {
+ return;
+ }
+ var b = bounds(area.x, area.y);
+ var level = area.level === null ? this.level : area.level;
+ this.focusOn((b.minX + b.maxX) / 2 + 0.5, (b.minY + b.maxY) / 2 + 0.5, level, this.zoomFitting(b.maxX - b.minX + 1, b.maxY - b.minY + 1));
+ },
+
+ // --- The Area panel ---
+
+ areaRename: function (value) {
+ var name = value.trim();
+ var area = ar.selected;
+ if (!area || name === area.name) {
+ return;
+ }
+ if (!NAME_RE.test(name)) {
+ this.areaError = 'Names can only use letters, digits, _ and -.';
+ this.syncAreaState();
+ return;
+ }
+ if (this.areaNamed(name)) {
+ this.areaError = 'An area called ' + name + ' is already open.';
+ this.syncAreaState();
+ return;
+ }
+ var baked = (window.VOID_AREAS || []).some(function (shape) {
+ return shape.name === name;
+ });
+ this.areaError = baked ? name + ' already exists in a file that isn\'t loaded — the server keeps whichever it loads last.' : '';
+ this.areaEdit(function (a) {
+ a.name = name;
+ });
+ },
+
+ areaSetLevel: function (value) {
+ this.areaEdit(function (a) {
+ a.level = value === '' ? null : parseInt(value, 10);
+ });
+ },
+
+ // Takes one tag or several (separated by commas or spaces).
+ areaAddTag: function (value) {
+ var tags = value.split(/[\s,]+/).filter(function (tag) {
+ return tag;
+ });
+ if (!tags.length) {
+ return;
+ }
+ this.areaEdit(function (a) {
+ tags.forEach(function (tag) {
+ if (a.tags.indexOf(tag) === -1) {
+ a.tags.push(tag);
+ }
+ });
+ });
+ },
+
+ areaRemoveTag: function (index) {
+ this.areaEdit(function (a) {
+ a.tags.splice(index, 1);
+ });
+ },
+
+ areaSetExtra: function (index, part, value) {
+ value = part === 'key' ? value.trim() : value;
+ if (part === 'key' && value && (SHAPE_KEYS.indexOf(value) !== -1 || !/^[A-Za-z0-9_-]+$/.test(value))) {
+ this.areaError = '"' + value + '" can\'t be used as a key here.';
+ this.syncAreaState();
+ return;
+ }
+ if (part === 'value' && /^[{\[]/.test(value.trim()) && T.valueProblem(value)) {
+ this.areaError = T.valueProblem(value) + '.';
+ this.syncAreaState();
+ return;
+ }
+ this.areaError = '';
+ this.areaEdit(function (a) {
+ a.extras[index][part] = part === 'value' && /^[{\[]/.test(value.trim()) ? T.oneLine(value) : value;
+ });
+ },
+
+ areaAddExtra: function () {
+ this.areaEdit(function (a) {
+ a.extras.push({ key: a.extras.some(function (e) {
+ return e.key === 'hint';
+ }) ? '' : 'hint', value: '' });
+ });
+ },
+
+ areaRemoveExtra: function (index) {
+ this.areaEdit(function (a) {
+ a.extras.splice(index, 1);
+ });
+ },
+
+ areaDeleteSelected: function () {
+ var area = ar.selected;
+ if (!area) {
+ return;
+ }
+ this.areaCheckpoint();
+ var file = ar.files[area.file];
+ file.areas = file.areas.filter(function (a) {
+ return a !== area;
+ });
+ file.dirty = true;
+ ar.selected = null;
+ this.areaRebuild();
+ },
+
+ areaDeleteVertex: function (index) {
+ var area = ar.selected;
+ if (!area) {
+ return;
+ }
+ if (isRect(area)) {
+ this.areaError = 'A rectangle keeps its 4 corners — drag an edge\'s midpoint to make it a polygon first.';
+ this.syncAreaState();
+ return;
+ }
+ if (area.x.length <= 3) {
+ this.areaError = 'A polygon needs at least 3 points.';
+ this.syncAreaState();
+ return;
+ }
+ this.areaError = '';
+ this.areaEdit(function (a) {
+ a.x.splice(index, 1);
+ a.y.splice(index, 1);
+ });
+ ar.vertex = -1;
+ this.syncAreaState();
+ },
+
+ // --- Drawing a new area ---
+
+ areaStartDraw: function () {
+ if (!ar.files.length) {
+ return;
+ }
+ ar.draft = [];
+ ar.selected = null;
+ this.areaError = '';
+ this.syncAreaState();
+ this.areaUpdateCursor(null);
+ this.scheduleRender();
+ },
+
+ areaCancelDraw: function () {
+ if (!ar.draft) {
+ return false;
+ }
+ ar.draft = null;
+ this.syncAreaState();
+ this.areaUpdateCursor(null);
+ this.scheduleRender();
+ return true;
+ },
+
+ // Two points make a rectangle, more a polygon. Drawn on the surface it covers every level
+ // (as most areas do); drawn on an upper floor, just that floor.
+ areaFinishDraw: function () {
+ var draft = ar.draft;
+ if (!draft) {
+ return;
+ }
+ if (draft.length < 2) {
+ this.areaError = 'Place at least 2 points: 2 for a rectangle, 3 or more for a polygon.';
+ this.syncAreaState();
+ return;
+ }
+ var file = ar.files[this.areaTarget] || ar.files[0];
+ var name = 'new_area';
+ for (var n = 2; this.areaNamed(name); n++) {
+ name = 'new_area_' + n;
+ }
+ var xs = draft.map(function (p) {
+ return p.x;
+ });
+ var ys = draft.map(function (p) {
+ return p.y;
+ });
+ if (draft.length === 2) {
+ var b = bounds(xs, ys);
+ xs = [b.minX, b.maxX];
+ ys = [b.minY, b.maxY];
+ }
+ this.areaCheckpoint();
+ var area = { name: name, x: xs, y: ys, level: this.level === 0 ? null : this.level, tags: [], extras: [], added: true };
+ area.orig = stateOf(area);
+ file.areas.push(area);
+ file.dirty = true;
+ ar.draft = null;
+ ar.selected = area;
+ this.areaError = '';
+ this.areaEditPanelOpen = true;
+ this.areaRebuild();
+ this.areaUpdateCursor(null);
+ },
+
+ // --- Interaction (called from worldmap.js's pointer handlers) ---
+ //
+ // click inside an area select it (again: the next one overlapping it, smallest first)
+ // drag a vertex move it — a rectangle's corner keeps it a rectangle
+ // drag an edge's midpoint add a vertex there (a rectangle becomes a polygon)
+ // right-click a vertex delete it
+ // arrows nudge the selected vertex, or the whole area (Shift: 8 tiles)
+ // New area (or N), then clicks place its points; Enter or clicking the first one finishes
+ // shift+click start a new area with its first point there — the way to
+ // draw one inside another, where a click would just select
+ // drag the map pan, as ever (and any drag at all while Space is held)
+
+ areaActive: function () {
+ return this.showAreaPolygons && this.areaLoaded && this.activeEditor() === 'area';
+ },
+
+ areaInteractive: function () {
+ return this.areaActive() && !ar.panKey;
+ },
+
+ // The selected area's vertex or edge-midpoint handle under the cursor, as
+ // `{ vertex: i }` / `{ mid: i }` (the midpoint of the edge from vertex i to i + 1), or null.
+ areaHandleAt: function (e) {
+ var area = ar.selected;
+ if (!area || !this.areaOnLevel(area)) {
+ return null;
+ }
+ var p = this.navLocal(e);
+ var v = vertices(area);
+ var best = null;
+ var bestDist = HANDLE_HIT_PX;
+ for (var i = 0; i < v.length; i++) {
+ var s = this.navScreen(v[i]);
+ var d = Math.hypot(s.x - p.x, s.y - p.y);
+ if (d <= bestDist) {
+ best = { vertex: i };
+ bestDist = d;
+ }
+ }
+ if (best) {
+ return best;
+ }
+ for (var m = 0; m < v.length; m++) {
+ var mid = this.areaMidpoint(v[m], v[(m + 1) % v.length]);
+ if (mid) {
+ var dm = Math.hypot(mid.x - p.x, mid.y - p.y);
+ if (dm <= bestDist) {
+ best = { mid: m };
+ bestDist = dm;
+ }
+ }
+ }
+ return best;
+ },
+
+ // Screen position of the midpoint handle between two vertices, or null if the edge is too
+ // short on screen for one.
+ areaMidpoint: function (a, b) {
+ var sa = this.navScreen(a);
+ var sb = this.navScreen(b);
+ if (Math.hypot(sb.x - sa.x, sb.y - sa.y) < MID_MIN_PX) {
+ return null;
+ }
+ return { x: (sa.x + sb.x) / 2, y: (sa.y + sb.y) / 2 };
+ },
+
+ areaOnLevel: function (area) {
+ return area.level === null || area.level === this.level;
+ },
+
+ // Every loaded area on this level that `tile` is in, smallest first.
+ areasAt: function (tile) {
+ var self = this;
+ return ar.shapes
+ .filter(function (shape) {
+ return self.areaOnLevel(shape.area) && contains(shape.x, shape.y, tile.x, tile.y);
+ })
+ .map(function (shape) {
+ var b = bounds(shape.x, shape.y);
+ return { area: shape.area, size: (b.maxX - b.minX + 1) * (b.maxY - b.minY + 1) };
+ })
+ .sort(function (a, b) {
+ return a.size - b.size;
+ })
+ .map(function (entry) {
+ return entry.area;
+ });
+ },
+
+ // Returns true when the press lands on a handle, so the map doesn't also start panning.
+ areaPointerDown: function (e) {
+ ar.press = null;
+ if (!this.areaInteractive()) {
+ return false;
+ }
+ ar.press = { x: e.clientX, y: e.clientY, shift: e.shiftKey };
+ var handle = ar.draft ? null : this.areaHandleAt(e);
+ if (!handle) {
+ return false;
+ }
+ ar.drag = { handle: handle, moved: false };
+ return true;
+ },
+
+ areaPointerMove: function (e) {
+ if (!this.areaActive()) {
+ return false;
+ }
+ ar.hover = this.navTileAt(e);
+ var drag = ar.drag;
+ if (drag) {
+ if (!drag.moved) {
+ if (!ar.press || Math.hypot(e.clientX - ar.press.x, e.clientY - ar.press.y) <= CLICK_SLOP_PX) {
+ return true;
+ }
+ // Past the slop: a move, not a click. One undo step covers the whole drag.
+ this.areaCheckpoint();
+ drag.moved = true;
+ var area = ar.selected;
+ if (drag.handle.mid !== undefined) {
+ if (isRect(area)) {
+ toPolygon(area);
+ }
+ var at = drag.handle.mid + 1;
+ area.x.splice(at, 0, ar.hover.x);
+ area.y.splice(at, 0, ar.hover.y);
+ drag.vertex = at;
+ } else {
+ drag.vertex = drag.handle.vertex;
+ }
+ ar.files[area.file].dirty = true;
+ }
+ drag.vertex = moveVertex(ar.selected, drag.vertex, ar.hover);
+ ar.vertex = drag.vertex;
+ this.areaRebuild();
+ return true;
+ }
+ this.areaUpdateCursor(e);
+ if (ar.draft) {
+ // The preview line follows the cursor.
+ this.scheduleRender();
+ }
+ return false;
+ },
+
+ areaPointerUp: function (e) {
+ var press = ar.press;
+ var drag = ar.drag;
+ ar.press = null;
+ ar.drag = null;
+ if (drag && drag.moved) {
+ this.areaUpdateCursor(e);
+ return;
+ }
+ if (!press || !this.areaInteractive() || Math.hypot(e.clientX - press.x, e.clientY - press.y) > CLICK_SLOP_PX) {
+ return;
+ }
+ if (drag && drag.handle.vertex !== undefined) {
+ // Clicking a vertex selects it (again to deselect), for the arrow keys and Delete.
+ ar.vertex = ar.vertex === drag.handle.vertex ? -1 : drag.handle.vertex;
+ this.syncAreaState();
+ this.scheduleRender();
+ return;
+ }
+ this.areaClick(e, press.shift);
+ },
+
+ areaPointerCancel: function () {
+ ar.drag = null;
+ ar.press = null;
+ },
+
+ areaPointerLeave: function () {
+ if (ar.hover) {
+ ar.hover = null;
+ if (ar.draft) {
+ this.scheduleRender();
+ }
+ }
+ },
+
+ areaClick: function (e, shift) {
+ var tile = this.navTileAt(e);
+ if (!ar.draft && shift) {
+ this.areaStartDraw();
+ }
+ if (ar.draft) {
+ var first = ar.draft[0];
+ if (first && ar.draft.length >= 2) {
+ var s = this.navScreen(first);
+ var p = this.navLocal(e);
+ if (Math.hypot(s.x - p.x, s.y - p.y) <= HANDLE_HIT_PX) {
+ this.areaFinishDraw();
+ return;
+ }
+ }
+ var last = ar.draft[ar.draft.length - 1];
+ if (!last || last.x !== tile.x || last.y !== tile.y) {
+ ar.draft.push({ x: tile.x, y: tile.y });
+ }
+ this.syncAreaState();
+ this.scheduleRender();
+ return;
+ }
+ var under = this.areasAt(tile);
+ var at = under.indexOf(ar.selected);
+ this.areaSelect(under.length ? under[(at + 1) % under.length] : null);
+ },
+
+ // Right-click. Returns true when the editor handled it, so the browser's menu is suppressed.
+ areaContextMenu: function (e) {
+ if (!this.areaInteractive()) {
+ return false;
+ }
+ if (ar.draft) {
+ // Takes back the last point placed.
+ ar.draft.pop();
+ this.syncAreaState();
+ this.scheduleRender();
+ return true;
+ }
+ var handle = this.areaHandleAt(e);
+ if (handle && handle.vertex !== undefined) {
+ this.areaDeleteVertex(handle.vertex);
+ }
+ return true;
+ },
+
+ areaUpdateCursor: function (e) {
+ var vp = this.viewport;
+ if (!vp || this.activeEditor() !== 'area') {
+ return;
+ }
+ var active = this.areaInteractive() && !!e;
+ var handle = active && !ar.draft && this.areaHandleAt(e);
+ vp.classList.toggle('wm-nav-grab', !!handle);
+ vp.classList.toggle('wm-nav-add', this.areaInteractive() && !!ar.draft);
+ vp.classList.toggle('wm-nav-hot', false);
+ },
+
+ areaKeyDown: function (e) {
+ if (!this.areaActive()) {
+ return;
+ }
+ var target = e.target;
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) {
+ return;
+ }
+ var mod = e.ctrlKey || e.metaKey;
+ if (e.key === ' ') {
+ e.preventDefault();
+ if (!ar.panKey) {
+ ar.panKey = true;
+ this.areaUpdateCursor(null);
+ }
+ } else if (mod && e.key.toLowerCase() === 'z') {
+ e.preventDefault();
+ this.areaUndo();
+ } else if (mod && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ this.saveMapFiles();
+ } else if (!mod && !ar.draft && e.key.toLowerCase() === 'n') {
+ e.preventDefault();
+ this.areaStartDraw();
+ } else if (e.key === 'Enter' && ar.draft) {
+ e.preventDefault();
+ this.areaFinishDraw();
+ } else if (e.key === 'Escape') {
+ if (this.areaCancelDraw()) {
+ return;
+ }
+ if (ar.vertex >= 0) {
+ ar.vertex = -1;
+ } else {
+ ar.selected = null;
+ }
+ this.syncAreaState();
+ this.scheduleRender();
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
+ if (!ar.selected) {
+ return;
+ }
+ e.preventDefault();
+ if (ar.vertex >= 0) {
+ this.areaDeleteVertex(ar.vertex);
+ } else {
+ this.areaDeleteSelected();
+ }
+ } else if (!mod && ar.selected && e.key.indexOf('Arrow') === 0) {
+ e.preventDefault();
+ var step = e.shiftKey ? 8 : 1;
+ var dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
+ var dy = e.key === 'ArrowDown' ? -step : e.key === 'ArrowUp' ? step : 0;
+ var index = ar.vertex;
+ this.areaEdit(function (a) {
+ if (index >= 0) {
+ var v = vertices(a)[index];
+ ar.vertex = moveVertex(a, index, { x: v.x + dx, y: v.y + dy });
+ } else {
+ a.x = a.x.map(function (x) {
+ return x + dx;
+ });
+ a.y = a.y.map(function (y) {
+ return y + dy;
+ });
+ }
+ });
+ }
+ },
+
+ // --- Rendering ---
+
+ // SVG class for a shape in the polygon layer (see `renderAreaPolygons` in worldmap.js).
+ areaShapeClass: function (shape) {
+ return shape.area && shape.area === ar.selected ? ' wm-area-polygon-selected' : '';
+ },
+
+ // The editor's handles, drawn over the polygon layer: the selected area's vertices (squares,
+ // to tell them from the nav graph's round points) and edge midpoints, and the area being drawn.
+ renderAreaEditor: function () {
+ if (!this.showAreaPolygons || !this.areaLoaded) {
+ return '';
+ }
+ var self = this;
+ var active = this.areaActive();
+ var html = '';
+ function point(t) {
+ var s = self.navScreen(t);
+ return s.x.toFixed(1) + ',' + s.y.toFixed(1);
+ }
+ function square(t, cls, half) {
+ var s = self.navScreen(t);
+ return '';
+ }
+ var area = ar.selected;
+ if (active && area && this.areaOnLevel(area) && !ar.draft) {
+ var v = vertices(area);
+ if (active) {
+ for (var m = 0; m < v.length; m++) {
+ var mid = this.areaMidpoint(v[m], v[(m + 1) % v.length]);
+ if (mid) {
+ html += '';
+ }
+ }
+ }
+ for (var i = 0; i < v.length; i++) {
+ html += square(v[i], 'wm-area-vertex' + (i === ar.vertex ? ' wm-area-vertex-selected' : ''), 4.5);
+ }
+ }
+ var draft = ar.draft;
+ if (draft && draft.length) {
+ var placed = active && ar.hover ? draft.concat([ar.hover]) : draft;
+ if (placed.length === 2) {
+ // Two points finish as a rectangle, so that's what's previewed.
+ var b = bounds([placed[0].x, placed[1].x], [placed[0].y, placed[1].y]);
+ html += '';
+ } else if (placed.length > 2) {
+ html += '';
+ }
+ for (var d = 0; d < draft.length; d++) {
+ html += square(draft[d], 'wm-area-vertex' + (d === 0 && draft.length >= 2 ? ' wm-area-vertex-first' : ''), 4.5);
+ }
+ }
+ return html;
+ },
+ };
+ };
+})();
diff --git a/web/site/src/main/resources/static/js/mapedit.js b/web/site/src/main/resources/static/js/mapedit.js
new file mode 100644
index 0000000000..a8efb6a286
--- /dev/null
+++ b/web/site/src/main/resources/static/js/mapedit.js
@@ -0,0 +1,603 @@
+// Shared by the world map's two file-backed editors — the nav graph (navgraph.js, `*.nav-edges.toml`)
+// and the area polygons (areas.js, `*.areas.toml`) — and loaded before both:
+//
+// VoidToml the small amount of TOML scanning both need to edit a file's own text in place
+// (rather than re-serialising it), so comments and layout survive a save.
+// VoidMapFiles opening files/folders from the viewer's disk and writing them back: the File
+// System Access API where the browser has it, a file input and a download otherwise.
+// mapEditMethods mixed into `worldMapApp()`: the shared "Load folder…/Load files…" buttons, which
+// load whichever of the two layers is switched on and still empty, each from its
+// own files, and `activeEditor`, which decides which editor gets the pointer and
+// keyboard when both layers are showing.
+(function () {
+ // Directories under `data/` that can't hold either kind of file and are large enough to make
+ // "Load folder" crawl if it had to walk them.
+ var SKIPPED_DIRS = { cache: true, 'map-tiles': true, saves: true, dump: true, '.temp': true, '.git': true, node_modules: true };
+
+ // Every file kind an editor loads, by the id `openMapFiles`/`openMapFolder` take.
+ var KINDS = {
+ nav: { suffix: '.nav-edges.toml', label: 'nav graph' },
+ area: { suffix: '.areas.toml', label: 'areas' },
+ };
+
+ // --- TOML scanning -------------------------------------------------------------------------
+
+ // Index just past the string starting at `i` (which is on its opening quote). Handles basic
+ // ("...", with escapes) and literal ('...') strings; multi-line strings don't appear in these
+ // files.
+ function skipString(text, i) {
+ var quote = text[i];
+ i++;
+ while (i < text.length && text[i] !== quote) {
+ if (quote === '"' && text[i] === '\\') {
+ i++;
+ }
+ i++;
+ }
+ return i + 1;
+ }
+
+ function skipComment(text, i) {
+ while (i < text.length && text[i] !== '\n') {
+ i++;
+ }
+ return i;
+ }
+
+ // Index just past the bracket matching the one at `i`, skipping strings and comments.
+ function matchBracket(text, i) {
+ var depth = 0;
+ while (i < text.length) {
+ var c = text[i];
+ if (c === '"' || c === "'") {
+ i = skipString(text, i);
+ continue;
+ }
+ if (c === '#') {
+ i = skipComment(text, i);
+ continue;
+ }
+ if (c === '{' || c === '[') {
+ depth++;
+ } else if (c === '}' || c === ']') {
+ depth--;
+ if (depth === 0) {
+ return i + 1;
+ }
+ }
+ i++;
+ }
+ throw new Error('Unclosed bracket');
+ }
+
+ // The elements of an inline array (`[ ... ]`), as their own text.
+ function splitArray(text) {
+ var out = [];
+ var i = 1;
+ var end = text.length - 1;
+ while (i < end) {
+ var c = text[i];
+ if (c === '#') {
+ i = skipComment(text, i);
+ } else if (c === ',' || /\s/.test(c)) {
+ i++;
+ } else {
+ var start = i;
+ if (c === '{' || c === '[') {
+ i = matchBracket(text, i);
+ } else if (c === '"' || c === "'") {
+ i = skipString(text, i);
+ } else {
+ while (i < end && text[i] !== ',' && !/\s/.test(text[i])) {
+ i++;
+ }
+ }
+ out.push(text.slice(start, i));
+ }
+ }
+ return out;
+ }
+
+ // A value's text on one line: comments dropped and every run of whitespace outside a string
+ // collapsed to a single space, so a hand-wrapped `success = { ... }` fits in a text field.
+ function oneLine(text) {
+ var out = '';
+ var i = 0;
+ while (i < text.length) {
+ var c = text[i];
+ if (c === '"' || c === "'") {
+ var j = skipString(text, i);
+ out += text.slice(i, j);
+ i = j;
+ } else if (c === '#') {
+ i = skipComment(text, i);
+ } else if (/\s/.test(c)) {
+ while (i < text.length && /\s/.test(text[i])) {
+ i++;
+ }
+ out += ' ';
+ } else {
+ out += c;
+ i++;
+ }
+ }
+ return out.trim().replace(/\[ /g, '[').replace(/ \]/g, ']').replace(/,\s*([\]}])/g, ' $1').replace(/\[ \]/g, '[]');
+ }
+
+ // A value typed into an editor that TOML would read as something other than a string: a number,
+ // a boolean, a quoted string, or an inline table/array.
+ function isRawValue(text) {
+ return /^-?\d+$/.test(text) || text === 'true' || text === 'false' || /^["'{\[]/.test(text);
+ }
+
+ // Strings are shown and edited without their quotes (`Open`, not `"Open"`), as long as the bare
+ // text would still read back as a string.
+ function displayValue(raw) {
+ if (/^"([^"\\]|\\.)*"$/.test(raw)) {
+ var text = JSON.parse(raw);
+ if (!isRawValue(text) && text.trim() === text && text !== '') {
+ return text;
+ }
+ }
+ return raw;
+ }
+
+ function tomlValue(text) {
+ text = text.trim();
+ return isRawValue(text) ? text : JSON.stringify(text);
+ }
+
+ // The text of a TOML string literal (`"a"` or `'a'`), or the text itself if it isn't one.
+ function stringValue(raw) {
+ if (/^"([^"\\]|\\.)*"$/.test(raw)) {
+ return JSON.parse(raw);
+ }
+ if (/^'[^']*'$/.test(raw)) {
+ return raw.slice(1, -1);
+ }
+ return raw;
+ }
+
+ // Why `text` isn't a usable value, or '' if it is: brackets must balance and close at the end.
+ function valueProblem(text) {
+ text = text.trim();
+ if (text === '') {
+ return 'empty';
+ }
+ if (/^[{\[]/.test(text)) {
+ try {
+ if (matchBracket(text, 0) !== text.length) {
+ return 'unexpected text after the closing bracket';
+ }
+ } catch (e) {
+ return 'unclosed bracket';
+ }
+ }
+ if (/^["']/.test(text) && skipString(text, 0) !== text.length) {
+ return 'unclosed or trailing text after string';
+ }
+ return '';
+ }
+
+ window.VoidToml = {
+ skipString: skipString,
+ skipComment: skipComment,
+ matchBracket: matchBracket,
+ splitArray: splitArray,
+ oneLine: oneLine,
+ isRawValue: isRawValue,
+ displayValue: displayValue,
+ tomlValue: tomlValue,
+ stringValue: stringValue,
+ valueProblem: valueProblem,
+ };
+
+ // --- Files ---------------------------------------------------------------------------------
+
+ function endsWith(name, suffix) {
+ return name.slice(-suffix.length) === suffix;
+ }
+
+ // Which of `kinds` a file belongs to by its name. A file named for neither goes to the only kind
+ // asked for, if just one was — "Files…" in one editor's panel opens whatever `.toml` it's given.
+ function kindOf(name, kinds) {
+ for (var i = 0; i < kinds.length; i++) {
+ if (endsWith(name, KINDS[kinds[i]].suffix)) {
+ return kinds[i];
+ }
+ }
+ for (var k in KINDS) {
+ if (endsWith(name, KINDS[k].suffix)) {
+ return null;
+ }
+ }
+ return kinds.length === 1 ? kinds[0] : null;
+ }
+
+ // `{ byKind: { nav: [...], area: [...] }, skipped: [names] }` from a flat list of loaded files.
+ function group(loaded, kinds) {
+ var byKind = {};
+ kinds.forEach(function (kind) {
+ byKind[kind] = [];
+ });
+ var skipped = [];
+ loaded.forEach(function (entry) {
+ var kind = kindOf(entry.name, kinds);
+ if (kind) {
+ byKind[kind].push(entry);
+ } else {
+ skipped.push(entry.name);
+ }
+ });
+ Object.keys(byKind).forEach(function (kind) {
+ byKind[kind].sort(function (a, b) {
+ return a.path.localeCompare(b.path);
+ });
+ });
+ return { byKind: byKind, skipped: skipped };
+ }
+
+ function readInputFiles(files, pathOf) {
+ return Promise.all(
+ files.map(function (file) {
+ return file.text().then(function (text) {
+ return { name: file.name, path: pathOf(file), text: text, handle: null };
+ });
+ }),
+ );
+ }
+
+ // Resolves to null when the viewer cancels the picker.
+ function pickFiles(kinds) {
+ if (typeof window.showOpenFilePicker === 'function') {
+ return window
+ .showOpenFilePicker({ multiple: true, types: [{ description: 'Map data', accept: { 'text/plain': ['.toml'] } }] })
+ .then(function (handles) {
+ return Promise.all(
+ handles.map(function (handle) {
+ return handle.getFile().then(function (file) {
+ return file.text().then(function (text) {
+ return { name: handle.name, path: handle.name, text: text, handle: handle };
+ });
+ });
+ }),
+ );
+ })
+ .then(function (loaded) {
+ return group(loaded, kinds);
+ }, cancelled);
+ }
+ // No File System Access API (Firefox, Safari): read through a file input, save by download.
+ return new Promise(function (resolve) {
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = true;
+ input.accept = '.toml';
+ input.addEventListener('change', function () {
+ readInputFiles(Array.prototype.slice.call(input.files || []), function (file) {
+ return file.name;
+ }).then(function (loaded) {
+ resolve(group(loaded, kinds));
+ });
+ });
+ input.click();
+ });
+ }
+
+ // Every file of `kinds` anywhere under a picked folder — the repo's `data/` is the one that
+ // makes sense, holding everything the server loads.
+ function pickFolder(kinds) {
+ function wanted(name) {
+ return kinds.some(function (kind) {
+ return endsWith(name, KINDS[kind].suffix);
+ });
+ }
+ if (typeof window.showDirectoryPicker !== 'function') {
+ // Firefox/Safari: a directory hands over every file under the folder (read-only, so
+ // saving downloads them, like the plain file picker).
+ return new Promise(function (resolve) {
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.setAttribute('webkitdirectory', '');
+ input.addEventListener('change', function () {
+ var files = Array.prototype.slice.call(input.files || []).filter(function (file) {
+ var path = file.webkitRelativePath || file.name;
+ return wanted(file.name) && !path.split('/').slice(0, -1).some(function (dir) {
+ return SKIPPED_DIRS[dir];
+ });
+ });
+ readInputFiles(files, function (file) {
+ return file.webkitRelativePath || file.name;
+ }).then(function (loaded) {
+ resolve(group(loaded, kinds));
+ });
+ });
+ input.click();
+ });
+ }
+ var found = [];
+ function walk(dir, path) {
+ var pending = [];
+ var iterator = dir.values();
+ function next() {
+ return iterator.next().then(function (step) {
+ if (step.done) {
+ return Promise.all(pending);
+ }
+ var entry = step.value;
+ if (entry.kind === 'directory') {
+ if (!SKIPPED_DIRS[entry.name]) {
+ pending.push(walk(entry, path + entry.name + '/'));
+ }
+ } else if (wanted(entry.name)) {
+ pending.push(
+ entry
+ .getFile()
+ .then(function (file) {
+ return file.text();
+ })
+ .then(function (text) {
+ found.push({ name: entry.name, path: path + entry.name, text: text, handle: entry });
+ }),
+ );
+ }
+ return next();
+ });
+ }
+ return next();
+ }
+ return window
+ .showDirectoryPicker({ mode: 'readwrite' })
+ .then(function (dir) {
+ return walk(dir, dir.name + '/');
+ })
+ .then(function () {
+ return group(found, kinds);
+ }, cancelled);
+ }
+
+ function cancelled(e) {
+ if (e && e.name === 'AbortError') {
+ return null;
+ }
+ throw e;
+ }
+
+ function download(name, text) {
+ var blob = new Blob([text], { type: 'text/plain' });
+ var a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = name;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(function () {
+ URL.revokeObjectURL(a.href);
+ }, 1000);
+ }
+
+ // Writes `text` through the file's handle and reads it back, so a write that silently didn't
+ // land is reported rather than the file just being marked clean. Without a handle (Firefox/Safari,
+ // or a browser with the File System Access API turned off) the only way out is a download.
+ function write(file, text) {
+ if (!file.handle) {
+ download(file.name, text);
+ return Promise.resolve();
+ }
+ var handle = file.handle;
+ var query = typeof handle.queryPermission === 'function' ? handle.queryPermission({ mode: 'readwrite' }) : Promise.resolve('granted');
+ return query
+ .then(function (state) {
+ // Only prompt when access isn't already there — a folder opened read-write covers every
+ // file under it, and asking again would needlessly spend the click's user activation.
+ return state === 'granted' || typeof handle.requestPermission !== 'function' ? state : handle.requestPermission({ mode: 'readwrite' });
+ })
+ .then(function (state) {
+ if (state !== 'granted') {
+ throw new Error('write permission denied for ' + file.name);
+ }
+ return handle.createWritable();
+ })
+ .then(function (writable) {
+ return writable.write(text).then(function () {
+ return writable.close();
+ });
+ })
+ .then(function () {
+ return handle.getFile();
+ })
+ .then(function (written) {
+ return written.text();
+ })
+ .then(function (content) {
+ if (content !== text) {
+ throw new Error(file.name + ' was written but reads back differently');
+ }
+ });
+ }
+
+ // A new file of `kind` holding `content`. Where the browser can write files it's created on disk
+ // straight away through a save dialog (so it lands wherever the viewer picks, usually next to the
+ // others under `data/`); otherwise it's just a name, downloaded on the next Save. Resolves to
+ // `{ name, handle, dirty, warning }`, or null when cancelled.
+ function create(kind, content) {
+ var suffix = KINDS[kind].suffix;
+ if (typeof window.showSaveFilePicker === 'function') {
+ return window
+ .showSaveFilePicker({ suggestedName: 'new' + suffix, types: [{ description: 'Map data', accept: { 'text/plain': ['.toml'] } }] })
+ .then(function (handle) {
+ return handle.createWritable().then(function (writable) {
+ return writable.write(content).then(function () {
+ return writable.close();
+ });
+ }).then(function () {
+ return {
+ name: handle.name,
+ handle: handle,
+ dirty: false,
+ warning: endsWith(handle.name, suffix) ? '' : 'Files must be named *' + suffix + ' to be loaded by the server.',
+ };
+ });
+ }, cancelled);
+ }
+ var name = window.prompt('New file name', 'new' + suffix);
+ if (!name) {
+ return Promise.resolve(null);
+ }
+ name = name.trim();
+ if (!endsWith(name, suffix)) {
+ name += suffix;
+ }
+ return Promise.resolve({ name: name, handle: null, dirty: true, warning: '' });
+ }
+
+ window.VoidMapFiles = { KINDS: KINDS, pickFiles: pickFiles, pickFolder: pickFolder, write: write, create: create };
+
+ // --- Alpine methods --------------------------------------------------------------------------
+
+ window.mapEditMethods = function () {
+ return {
+ // Which editor the pointer and keyboard go to — 'none', 'nav' or 'area', picked with the
+ // display panel's "Editing" switch (see `activeEditor`). None to begin with, and loading
+ // files doesn't change that, so the map can't be edited by accident while just looking.
+ mapEditor: 'none',
+ // A problem with the last load (nothing found, a file of neither kind), shown under the
+ // load buttons.
+ loadError: '',
+
+ mapEditBoot: function () {
+ var self = this;
+ // Hiding the layer being edited stops editing it, rather than leaving it to come back
+ // editable the moment it's switched on again.
+ this.$watch('showNavGraph', function (on) {
+ if (!on && self.mapEditor === 'nav') {
+ self.setMapEditor('none');
+ }
+ });
+ this.$watch('showAreaPolygons', function (on) {
+ if (!on && self.mapEditor === 'area') {
+ self.setMapEditor('none');
+ }
+ });
+ },
+
+ // Whether each editor has files loaded and its layer showing — what the "Editing" switch
+ // offers.
+ navEditable: function () {
+ return this.showNavGraph && this.navLoaded;
+ },
+
+ areaEditable: function () {
+ return this.showAreaPolygons && this.areaLoaded;
+ },
+
+ // 'nav' or 'area' while that editor is picked and has something to edit, else ''.
+ activeEditor: function () {
+ if (this.mapEditor === 'nav' && this.navEditable()) {
+ return 'nav';
+ }
+ if (this.mapEditor === 'area' && this.areaEditable()) {
+ return 'area';
+ }
+ return '';
+ },
+
+ anyEditable: function () {
+ return this.navEditable() || this.areaEditable();
+ },
+
+ setMapEditor: function (editor) {
+ if (this.mapEditor === editor) {
+ return;
+ }
+ this.mapEditor = editor;
+ // Whatever the other editor was previewing under the cursor no longer applies.
+ this.navUpdateCursor(null);
+ this.areaUpdateCursor(null);
+ this.scheduleRender();
+ },
+
+ // The layers switched on that have nothing loaded yet — what the display panel's shared load
+ // buttons fill.
+ kindsToLoad: function () {
+ var kinds = [];
+ if (this.showNavGraph && !this.navLoaded) {
+ kinds.push('nav');
+ }
+ if (this.showAreaPolygons && !this.areaLoaded) {
+ kinds.push('area');
+ }
+ return kinds;
+ },
+
+ loadHint: function () {
+ return this.kindsToLoad().map(function (kind) {
+ return '*' + KINDS[kind].suffix;
+ }).join(' and ');
+ },
+
+ // `kinds` left out: whichever layers need loading (see `kindsToLoad`).
+ openMapFiles: function (kinds) {
+ this.mapLoad(VoidMapFiles.pickFiles, kinds);
+ },
+
+ openMapFolder: function (kinds) {
+ this.mapLoad(VoidMapFiles.pickFolder, kinds);
+ },
+
+ mapLoad: function (pick, kinds) {
+ var self = this;
+ kinds = kinds || this.kindsToLoad();
+ if (!kinds.length) {
+ return;
+ }
+ pick(kinds)
+ .then(function (result) {
+ if (!result) {
+ return;
+ }
+ var errors = [];
+ if (result.skipped.length) {
+ errors.push('Not ' + kinds.map(function (kind) {
+ return '*' + KINDS[kind].suffix;
+ }).join(' or ') + ': ' + result.skipped.join(', '));
+ }
+ var any = false;
+ kinds.forEach(function (kind) {
+ var files = result.byKind[kind];
+ if (!files.length) {
+ if (!result.skipped.length || kinds.length > 1) {
+ errors.push('No *' + KINDS[kind].suffix + ' files found.');
+ }
+ return;
+ }
+ any = true;
+ if (kind === 'nav') {
+ self.navAddFiles(files);
+ } else {
+ self.areaAddFiles(files);
+ }
+ });
+ self.loadError = errors.join('\n');
+ if (!any) {
+ self.syncNavState();
+ self.syncAreaState();
+ }
+ })
+ .catch(function (e) {
+ self.loadError = String((e && e.message) || e);
+ });
+ },
+
+ // Ctrl+S saves whatever either editor has changed, not just the active one's files.
+ saveMapFiles: function () {
+ if (this.navDirty) {
+ this.saveNavFiles();
+ }
+ if (this.areaDirty) {
+ this.saveAreaFiles();
+ }
+ },
+ };
+ };
+})();
diff --git a/web/site/src/main/resources/static/js/navgraph.js b/web/site/src/main/resources/static/js/navgraph.js
new file mode 100644
index 0000000000..cd08283577
--- /dev/null
+++ b/web/site/src/main/resources/static/js/navgraph.js
@@ -0,0 +1,1753 @@
+// Bot navigation graph overlay and editor for the world map (see WorldMap.kt's "Nav graph" panel).
+//
+// The graph lives in `*.nav-edges.toml` files under `data/` (read by the game's
+// NavigationGraph.loadGraph): one `edges = [ ... ]` array per file, each element an inline table
+// with a `from` and `to` tile and, optionally, a `cost`, `actions` and `requires`. Nodes aren't
+// declared anywhere — a node is simply a tile that some edge starts or ends on — so "adding a
+// point" here always means adding an edge to it, and deleting one deletes every edge touching it.
+//
+// Nothing is baked into the page: the files are opened from the viewer's own disk (the File System
+// Access API where the browser has it, so saving writes straight back; a plain file input and a
+// download otherwise), which is also why the toggle offers to load them before there's anything to show.
+//
+// Editing is done on the file's own text rather than a re-serialised copy of it: `parseNavFile`
+// splits the array into one chunk per edge, remembering where in each element the `from`/`to`
+// tables sit, and `serializeNavFile` stitches the chunks back together with only the tables that
+// actually changed rewritten. Comments, hand-wrapped multi-line `actions`, key order and spacing all
+// survive a save untouched, so the diff is just the edges that were edited. An edge whose cost,
+// actions or requirements were changed in the Edge panel is the exception: it's written out afresh,
+// on one line, from what `readMeta` parsed out of it.
+(function () {
+ // Screen-space hit radii, in CSS pixels, for picking a node or an edge under the cursor.
+ var NODE_HIT_PX = 9;
+ var EDGE_HIT_PX = 5;
+ // A press that travels further than this before release is a pan, not a click.
+ var CLICK_SLOP_PX = 4;
+ var UNDO_LIMIT = 100;
+
+ // Keys each bot action type takes, from content.bot.behaviour.action.ActionParser — just the
+ // types that make sense on a nav edge. Drives the Edge panel's key suggestions, the keys added
+ // when a type is picked, and the "missing" warning. Any other type can still be typed in.
+ var ACTION_KEYS = {
+ object: { required: ['option', 'id', 'success'], optional: ['x', 'y', 'radius', 'delay', 'if'] },
+ tile: { required: ['x', 'y'], optional: ['radius'] },
+ npc: { required: ['option', 'id'], optional: ['success', 'radius', 'delay'] },
+ floor_item: { required: ['option', 'id', 'success'], optional: ['x', 'y', 'radius', 'delay'] },
+ item_on_object: { required: ['id', 'object'], optional: ['success', 'delay'] },
+ item_on_item: { required: ['id', 'on'], optional: ['success'] },
+ interface: { required: ['option', 'id'], optional: ['success', 'if'] },
+ interface_close: { required: ['id'], optional: [] },
+ continue: { required: ['id'], optional: ['option', 'success'] },
+ enter: { required: [], optional: ['int', 'string'] },
+ wait: { required: ['ticks'], optional: [] },
+ go_to: { required: [], optional: ['area', 'nearest'] },
+ jewellery_teleport: { required: ['item', 'area'], optional: ['if', 'success'] },
+ restart: { required: ['success'], optional: ['wait_if'] },
+ };
+
+ function field(key, value) {
+ return { key: key, value: String(value) };
+ }
+
+ // The Edge panel's "+ Door", "+ Stairs"... buttons: the shapes the existing files use, filled in
+ // from the edge's own ends where that's a good guess.
+ var ACTION_TEMPLATES = {
+ door: function (edge) {
+ return [
+ { type: 'object', fields: [field('option', 'Open'), field('id', ''), field('x', edge.to.x), field('y', edge.to.y),
+ field('success', '{ object = { id = "", x = ' + edge.to.x + ', y = ' + edge.to.y + ' } }')] },
+ { type: 'tile', fields: [field('x', edge.to.x), field('y', edge.to.y), field('radius', 1)] },
+ ];
+ },
+ stairs: function (edge) {
+ var up = (edge.to.level || 0) > (edge.from.level || 0);
+ return [
+ { type: 'object', fields: [field('option', up ? 'Climb-up' : 'Climb-down'), field('id', ''), field('x', edge.from.x), field('y', edge.from.y),
+ field('success', '{ tile = { level = ' + (edge.to.level || 0) + ' } }')] },
+ ];
+ },
+ object: function (edge) {
+ return [{ type: 'object', fields: [field('option', ''), field('id', ''), field('x', edge.to.x), field('y', edge.to.y), field('success', '{ tile = { x = ' + edge.to.x + ', y = ' + edge.to.y + ' } }')] }];
+ },
+ walk: function (edge) {
+ return [{ type: 'tile', fields: [field('x', edge.to.x), field('y', edge.to.y)] }];
+ },
+ other: function () {
+ return [{ type: '', fields: [] }];
+ },
+ };
+
+ function tileKey(t) {
+ return t.x + ',' + t.y + ',' + (t.level || 0);
+ }
+
+ function sameTile(a, b) {
+ return a.x === b.x && a.y === b.y && (a.level || 0) === (b.level || 0);
+ }
+
+ function copyTile(t) {
+ return { x: t.x, y: t.y, level: t.level || 0 };
+ }
+
+ // Matches the style the existing files are written in: `level` left out on the ground floor.
+ function formatTile(t) {
+ return '{ x = ' + t.x + ', y = ' + t.y + ((t.level || 0) !== 0 ? ', level = ' + t.level : '') + ' }';
+ }
+
+ // --- Parsing -------------------------------------------------------------------------------
+
+ var T = window.VoidToml;
+ var skipString = T.skipString;
+ var skipComment = T.skipComment;
+ var matchBracket = T.matchBracket;
+ var splitArray = T.splitArray;
+ var oneLine = T.oneLine;
+ var displayValue = T.displayValue;
+ var tomlValue = T.tomlValue;
+ var valueProblem = T.valueProblem;
+
+ function readTile(table) {
+ var tile = { x: 0, y: 0, level: 0 };
+ var re = /(\w+)\s*=\s*(-?\d+)/g;
+ var m;
+ while ((m = re.exec(table))) {
+ if (m[1] === 'x' || m[1] === 'y' || m[1] === 'level') {
+ tile[m[1]] = parseInt(m[2], 10);
+ }
+ }
+ return tile;
+ }
+
+ // Top-level keys of one edge element (`{ ... }`), each with the span of its value relative to
+ // the element's own text.
+ function readEdgeKeys(element) {
+ var keys = {};
+ readEntries(element).forEach(function (entry) {
+ keys[entry.key] = { start: entry.start, end: entry.end };
+ });
+ return keys;
+ }
+
+ // The `key = value` entries of an inline table (`{ ... }`), in order, each with the span of its
+ // value relative to the table's own text.
+ function readEntries(element) {
+ var entries = [];
+ var i = 1;
+ var end = element.length - 1;
+ while (i < end) {
+ var c = element[i];
+ if (c === '#') {
+ i = skipComment(element, i);
+ continue;
+ }
+ var key = /^[A-Za-z_][\w-]*/.exec(element.slice(i, i + 64));
+ if (!key) {
+ i++;
+ continue;
+ }
+ i += key[0].length;
+ while (i < end && /\s/.test(element[i])) {
+ i++;
+ }
+ if (element[i] !== '=') {
+ continue;
+ }
+ i++;
+ while (i < end && /\s/.test(element[i])) {
+ i++;
+ }
+ var start = i;
+ if (element[i] === '{' || element[i] === '[') {
+ i = matchBracket(element, i);
+ } else if (element[i] === '"' || element[i] === "'") {
+ i = skipString(element, i);
+ } else {
+ while (i < end && element[i] !== ',' && element[i] !== '}' && !/\s/.test(element[i])) {
+ i++;
+ }
+ }
+ entries.push({ key: key[0], start: start, end: i });
+ }
+ return entries;
+ }
+
+ // `actions = [ { object = { option = "Open", ... } }, { tile = { ... } } ]` as
+ // `[{ type: 'object', fields: [{ key: 'option', value: 'Open' }, ...] }, ...]`.
+ function parseActions(text) {
+ return splitArray(text).map(function (element) {
+ var entry = readEntries(element)[0];
+ if (!entry) {
+ return { type: '', fields: [] };
+ }
+ var body = element.slice(entry.start, entry.end);
+ return {
+ type: entry.key,
+ fields: body[0] === '{'
+ ? readEntries(body).map(function (field) {
+ return { key: field.key, value: displayValue(oneLine(body.slice(field.start, field.end))) };
+ })
+ : [],
+ };
+ });
+ }
+
+ function formatAction(action) {
+ var fields = action.fields.filter(function (field) {
+ return field.key;
+ }).map(function (field) {
+ return field.key + ' = ' + tomlValue(field.value);
+ });
+ return '{ ' + (action.type || 'object') + ' = ' + (fields.length ? '{ ' + fields.join(', ') + ' }' : '{}') + ' }';
+ }
+
+ // The parts of an edge the Edge panel edits, besides its ends. `cost` and `requires` are kept as
+ // TOML text ('' when absent).
+ function readMeta(element, keys) {
+ function raw(key) {
+ return keys[key] ? oneLine(element.slice(keys[key].start, keys[key].end)) : '';
+ }
+ return {
+ cost: raw('cost'),
+ actions: keys.actions ? parseActions(element.slice(keys.actions.start, keys.actions.end)) : [],
+ requires: raw('requires'),
+ };
+ }
+
+ function copyMeta(meta) {
+ return JSON.parse(JSON.stringify(meta));
+ }
+
+ // Splits a nav-edges file into `head` (everything up to and including `edges = [`), `items` (in
+ // order: `{gap}` for blank lines/standalone comments between edges, `{edge}` for an edge and its
+ // own line — indentation, trailing comma and same-line comment included) and `tail` (from the
+ // whitespace before the closing `]` to the end of the file).
+ function parseNavFile(text) {
+ var open = /^[ \t]*edges[ \t]*=[ \t]*\[/m.exec(text);
+ if (!open) {
+ throw new Error('No `edges = [` array found');
+ }
+ var head = text.slice(0, open.index + open[0].length);
+ var items = [];
+ var i = head.length;
+ var gapStart = i;
+ while (i < text.length) {
+ var c = text[i];
+ if (c === '#') {
+ i = skipComment(text, i);
+ continue;
+ }
+ if (c === ']') {
+ break;
+ }
+ if (c !== '{') {
+ i++;
+ continue;
+ }
+ // An edge's chunk starts at its line's indentation when nothing else precedes it on that
+ // line, so deleting it takes the whole line rather than leaving the indent behind.
+ var lineStart = text.lastIndexOf('\n', i - 1) + 1;
+ var chunkStart = lineStart >= gapStart && /^[ \t]*$/.test(text.slice(lineStart, i)) ? lineStart : i;
+ if (chunkStart > gapStart) {
+ items.push({ gap: text.slice(gapStart, chunkStart) });
+ }
+ var elementEnd = matchBracket(text, i);
+ var j = elementEnd;
+ while (j < text.length && (text[j] === ' ' || text[j] === '\t')) {
+ j++;
+ }
+ var hasComma = text[j] === ',';
+ if (hasComma) {
+ j++;
+ }
+ // Anything else up to the end of the line — a trailing `# name` comment, mostly — stays
+ // with this edge, so it goes wherever the edge does.
+ var lineEnd = text.indexOf('\n', j);
+ var rest = lineEnd === -1 ? text.slice(j) : text.slice(j, lineEnd + 1);
+ var restEnd = j + rest.length;
+ if (!/^[ \t]*(#[^\n]*)?\r?\n?$/.test(rest)) {
+ // Another element follows on the same line; stop this chunk at the comma.
+ rest = '';
+ restEnd = j;
+ }
+ var element = text.slice(i, elementEnd);
+ var keys = readEdgeKeys(element);
+ if (!keys.from || !keys.to) {
+ throw new Error('Edge without from/to near: ' + element.slice(0, 80));
+ }
+ var from = readTile(element.slice(keys.from.start, keys.from.end));
+ var to = readTile(element.slice(keys.to.start, keys.to.end));
+ var meta = readMeta(element, keys);
+ items.push({
+ edge: {
+ pre: text.slice(chunkStart, i),
+ element: element,
+ between: text.slice(elementEnd, j - (hasComma ? 1 : 0)),
+ hasComma: hasComma,
+ post: rest,
+ fromSpan: keys.from,
+ toSpan: keys.to,
+ origFrom: from,
+ origTo: to,
+ from: copyTile(from),
+ to: copyTile(to),
+ meta: meta,
+ // What `meta` was when read, to tell whether the element needs rewriting.
+ origMeta: JSON.stringify(meta),
+ // Edges with actions are one-way (and walk-only ones both ways) — see loadGraph.
+ directed: meta.actions.length > 0,
+ },
+ });
+ i = restEnd;
+ gapStart = i;
+ }
+ if (i >= text.length) {
+ throw new Error('Unclosed `edges` array');
+ }
+ // Whitespace/comments between the last edge and `]` belong to the tail, so appended edges
+ // land after the last one rather than after a trailing comment block.
+ return { head: head, items: items, tail: text.slice(gapStart), eol: text.indexOf('\r\n') !== -1 ? '\r\n' : '\n' };
+ }
+
+ function indentOf(file) {
+ for (var i = 0; i < file.items.length; i++) {
+ var edge = file.items[i].edge;
+ if (edge && /^[ \t]+$/.test(edge.pre)) {
+ return edge.pre;
+ }
+ }
+ return ' ';
+ }
+
+ function newEdge(file, from, to, meta) {
+ meta = meta || { cost: '', actions: [], requires: '' };
+ var element = '{ from = ' + formatTile(from) + ', to = ' + formatTile(to) + ' }';
+ return {
+ meta: meta,
+ origMeta: '',
+ pre: indentOf(file),
+ element: element,
+ between: '',
+ hasComma: true,
+ post: file.eol,
+ fromSpan: null,
+ toSpan: null,
+ origFrom: copyTile(from),
+ origTo: copyTile(to),
+ from: copyTile(from),
+ to: copyTile(to),
+ directed: meta.actions.length > 0,
+ added: true,
+ };
+ }
+
+ // The whole element written out afresh, on one line, in the order the existing files use.
+ function formatElement(edge) {
+ var meta = edge.meta;
+ var text = '{ from = ' + formatTile(edge.from) + ', to = ' + formatTile(edge.to);
+ if (meta.cost !== '') {
+ text += ', cost = ' + meta.cost;
+ }
+ if (meta.actions.length) {
+ text += ', actions = [' + meta.actions.map(formatAction).join(', ') + ']';
+ }
+ if (meta.requires !== '') {
+ text += ', requires = ' + meta.requires;
+ }
+ return text + ' }';
+ }
+
+ // An edge whose cost, actions or requirements were edited is rewritten whole (so a hand-wrapped
+ // one ends up on a single line); one that only had its ends moved keeps the rest of its text.
+ function elementText(edge) {
+ if (edge.added || JSON.stringify(edge.meta) !== edge.origMeta) {
+ return formatElement(edge);
+ }
+ var text = edge.element;
+ // Later span first, so the earlier one's offsets still hold.
+ var spans = [
+ { span: edge.fromSpan, now: edge.from, orig: edge.origFrom },
+ { span: edge.toSpan, now: edge.to, orig: edge.origTo },
+ ].sort(function (a, b) {
+ return b.span.start - a.span.start;
+ });
+ for (var i = 0; i < spans.length; i++) {
+ var s = spans[i];
+ if (!sameTile(s.now, s.orig)) {
+ text = text.slice(0, s.span.start) + formatTile(s.now) + text.slice(s.span.end);
+ }
+ }
+ return text;
+ }
+
+ function serializeNavFile(file) {
+ var out = file.head;
+ var edges = file.items.filter(function (item) {
+ return item.edge;
+ });
+ var lastEdge = edges.length ? edges[edges.length - 1].edge : null;
+ for (var i = 0; i < file.items.length; i++) {
+ var item = file.items[i];
+ if (item.gap !== undefined) {
+ out += item.gap;
+ continue;
+ }
+ var edge = item.edge;
+ if (edge.added && !/\n$/.test(out)) {
+ out += file.eol;
+ }
+ // An edge that used to be last may have had no comma; one is needed once anything follows.
+ if (edge.hasComma) {
+ out += edge.pre + elementText(edge) + edge.between + ',' + edge.post;
+ } else {
+ out += edge.pre + elementText(edge) + (edge !== lastEdge ? ',' : '') + edge.between + edge.post;
+ }
+ }
+ var tail = file.tail;
+ if (lastEdge && lastEdge.added && !/^\s*\n/.test(tail) && !/\n$/.test(out)) {
+ out += file.eol;
+ }
+ return out + tail;
+ }
+
+ // --- Geometry ------------------------------------------------------------------------------
+
+ function distToSegment(px, py, ax, ay, bx, by) {
+ var dx = bx - ax;
+ var dy = by - ay;
+ var len = dx * dx + dy * dy;
+ var t = len === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len));
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
+ }
+
+ window.VoidNavGraph = { parse: parseNavFile, serialize: serializeNavFile };
+
+ // --- Alpine methods --------------------------------------------------------------------------
+
+ // Mixed into `worldMapApp()`'s data object. The files, their handles and the undo history are
+ // kept out here in the closure rather than on the (Alpine-proxied) object: a
+ // FileSystemFileHandle called through a Proxy throws "Illegal invocation", and none of it needs
+ // to be reactive — the panel only reads the summary fields `syncNavState` copies across.
+ window.navGraphMethods = function () {
+ var nav = {
+ files: [],
+ // New nodes that nothing connects to yet. Kept only until an edge reaches them — a lone node
+ // can't be written to a file, which only holds edges.
+ loose: [],
+ nodes: {},
+ selected: null,
+ selSig: '',
+ undo: [],
+ drag: null,
+ press: null,
+ hover: null,
+ hoverNode: null,
+ hoverShift: false,
+ // Space held: the editor ignores the pointer so every drag pans.
+ panKey: false,
+ // Index of the selected edge's action whose x/y the next map click sets, or null.
+ pick: null,
+ // The selected point's edges, in the order the panel lists them (see `navPickEdge`).
+ nodeEdges: [],
+ };
+
+ return {
+ showNavGraph: false,
+ navPanelOpen: true,
+ navEdgePanelOpen: true,
+ navLoaded: false,
+ navFiles: [],
+ // The file new edges go into. Follows the selection.
+ navTarget: 0,
+ navDirty: false,
+ navCanUndo: false,
+ navStats: '',
+ navSelection: '',
+ navError: '',
+ // What the last save did — which files were written in place and which could only be downloaded.
+ navNotice: '',
+ // The Edge panel's copy of the selected edge (null when a point or nothing is selected), and
+ // the selected point's edge list.
+ navEdge: null,
+ navNodeEdges: [],
+ navPicking: -1,
+ navActionTypes: Object.keys(ACTION_KEYS),
+ navFolderAccess: typeof window.showDirectoryPicker === 'function',
+
+ navBoot: function (root) {
+ var self = this;
+ this.navLayer = root.querySelector('#wm-nav-graph');
+ this.$watch('showNavGraph', function () {
+ self.navUpdateCursor(null);
+ self.scheduleRender();
+ });
+ window.addEventListener('keydown', function (e) {
+ self.navKeyDown(e);
+ });
+ window.addEventListener('keyup', function (e) {
+ self.navKeyUp(e);
+ });
+ // Released while the page didn't have focus: the keyup never arrives.
+ window.addEventListener('blur', function () {
+ nav.hoverShift = false;
+ self.navSetPanKey(false);
+ });
+ window.addEventListener('beforeunload', function (e) {
+ if (self.navDirty) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
+ },
+
+ // --- Files ---
+
+ // Opening a file that's already open (by path) replaces it rather than doubling its edges.
+ navAddFiles: function (loaded) {
+ var errors = [];
+ for (var i = 0; i < loaded.length; i++) {
+ var entry = loaded[i];
+ var parsed;
+ try {
+ parsed = parseNavFile(entry.text);
+ } catch (e) {
+ errors.push(entry.name + ': ' + e.message);
+ continue;
+ }
+ parsed.name = entry.name;
+ parsed.path = entry.path;
+ parsed.handle = entry.handle;
+ parsed.dirty = false;
+ var existing = -1;
+ for (var f = 0; f < nav.files.length; f++) {
+ if (nav.files[f].path === entry.path) {
+ existing = f;
+ }
+ }
+ if (existing === -1) {
+ nav.files.push(parsed);
+ } else {
+ nav.files[existing] = parsed;
+ }
+ }
+ this.navError = errors.join('\n');
+ this.navNotice = '';
+ if (nav.files.length) {
+ this.showNavGraph = true;
+ }
+ nav.undo = [];
+ this.navRebuild();
+ },
+
+ closeNavFiles: function () {
+ if (this.navDirty && !window.confirm('Discard unsaved nav graph changes?')) {
+ return;
+ }
+ nav.files = [];
+ nav.loose = [];
+ nav.selected = null;
+ nav.undo = [];
+ this.showNavGraph = false;
+ this.navError = '';
+ this.navNotice = '';
+ this.navTarget = 0;
+ this.navRebuild();
+ },
+
+ // Closes just the selected file, leaving the others open.
+ closeNavFile: function () {
+ var file = nav.files[this.navTarget];
+ if (!file) {
+ return;
+ }
+ if (file.dirty && !window.confirm('Discard unsaved changes to ' + file.name + '?')) {
+ return;
+ }
+ nav.files.splice(this.navTarget, 1);
+ nav.selected = null;
+ nav.undo = [];
+ this.navError = '';
+ this.navNotice = '';
+ this.navTarget = 0;
+ if (!nav.files.length) {
+ nav.loose = [];
+ this.showNavGraph = false;
+ }
+ this.navRebuild();
+ },
+
+ // A file row: makes it the file new edges go into.
+ navPickFile: function (index) {
+ this.navTarget = index;
+ },
+
+ // Adds an empty `*.nav-edges.toml` — see `VoidMapFiles.create`.
+ navNewFile: function () {
+ var self = this;
+ var content = 'edges = [\n]\n';
+ VoidMapFiles.create('nav', content)
+ .then(function (created) {
+ if (!created) {
+ return;
+ }
+ for (var i = 0; i < nav.files.length; i++) {
+ if (nav.files[i].path === created.name) {
+ self.navError = created.name + ' is already open.';
+ return;
+ }
+ }
+ var parsed = parseNavFile(content);
+ parsed.name = created.name;
+ parsed.path = created.name;
+ parsed.handle = created.handle;
+ parsed.dirty = created.dirty;
+ nav.files.push(parsed);
+ nav.undo = [];
+ self.navError = created.warning;
+ self.showNavGraph = true;
+ self.setMapEditor('nav');
+ self.navTarget = nav.files.length - 1;
+ self.navRebuild();
+ })
+ .catch(function (e) {
+ self.navError = String(e.message || e);
+ });
+ },
+
+ // Files are written one after another rather than all at once: a file opened through "Files…"
+ // only has read access until its first save asks for write access, and a second permission
+ // prompt fired while the first is still showing is rejected outright.
+ saveNavFiles: function () {
+ this.navSave(null);
+ },
+
+ // Just the file new edges go into (the highlighted row).
+ saveNavFile: function () {
+ this.navSave(nav.files[this.navTarget] || null);
+ },
+
+ // `only`: the one file to write, or null for every changed file.
+ navSave: function (only) {
+ var self = this;
+ var dirty = nav.files.filter(function (file) {
+ return file.dirty && (!only || file === only);
+ });
+ var written = [];
+ var downloaded = [];
+ var chain = Promise.resolve();
+ dirty.forEach(function (file) {
+ chain = chain.then(function () {
+ var text = serializeNavFile(file);
+ return VoidMapFiles.write(file, text).then(function () {
+ (file.handle ? written : downloaded).push(file.name);
+ // Re-parse what was written so the next save diffs against it, not the original.
+ var parsed = parseNavFile(text);
+ file.head = parsed.head;
+ file.items = parsed.items;
+ file.tail = parsed.tail;
+ file.dirty = false;
+ });
+ });
+ });
+ this.navNotice = '';
+ chain
+ .then(function () {
+ self.navError = nav.loose.length ? nav.loose.length + ' unconnected point(s) not saved — connect them with an edge first.' : '';
+ nav.undo = [];
+ })
+ .catch(function (e) {
+ self.navError = 'Save failed: ' + (e.message || e);
+ })
+ .then(function () {
+ var notice = [];
+ if (written.length) {
+ notice.push('Saved ' + written.join(', ') + '.');
+ }
+ if (downloaded.length) {
+ // No file handle to write through (Firefox/Safari, or a browser with the File System
+ // Access API turned off): the only way out is a download.
+ notice.push('Downloaded ' + downloaded.join(', ') + ' — this browser can\'t write to the opened files, so copy the download over the original.');
+ }
+ self.navNotice = notice.join(' ');
+ self.navRebuild();
+ });
+ },
+
+ // --- Model ---
+
+ // Rebuilds the node index from every open file's edges (plus loose nodes) and refreshes the
+ // panel's summary fields. Cheap enough (a few hundred edges) to run after every edit.
+ navRebuild: function () {
+ var nodes = {};
+ function node(tile) {
+ var key = tileKey(tile);
+ if (!nodes[key]) {
+ nodes[key] = { key: key, x: tile.x, y: tile.y, level: tile.level || 0, edges: [], loose: false };
+ }
+ return nodes[key];
+ }
+ var edgeCount = 0;
+ for (var f = 0; f < nav.files.length; f++) {
+ var items = nav.files[f].items;
+ for (var i = 0; i < items.length; i++) {
+ var edge = items[i].edge;
+ if (!edge) {
+ continue;
+ }
+ edge.file = f;
+ edgeCount++;
+ node(edge.from).edges.push(edge);
+ node(edge.to).edges.push(edge);
+ }
+ }
+ var loose = [];
+ for (var l = 0; l < nav.loose.length; l++) {
+ var key = tileKey(nav.loose[l]);
+ if (!nodes[key]) {
+ node(nav.loose[l]).loose = true;
+ loose.push(nav.loose[l]);
+ }
+ }
+ nav.loose = loose;
+ nav.nodes = nodes;
+ var sel = nav.selected;
+ if (sel && ((sel.node && !nodes[sel.node]) || (sel.edge && !this.navEdgeExists(sel.edge)))) {
+ nav.selected = null;
+ }
+ this.navStats = nav.files.length ? edgeCount + ' edges · ' + Object.keys(nodes).length + ' points' : '';
+ this.syncNavState();
+ this.scheduleRender();
+ },
+
+ navEdgeExists: function (edge) {
+ var file = nav.files[edge.file];
+ return !!file && file.items.some(function (item) {
+ return item.edge === edge;
+ });
+ },
+
+ syncNavState: function () {
+ this.navLoaded = nav.files.length > 0;
+ this.navFiles = nav.files.map(function (file, i) {
+ var edges = 0;
+ for (var k = 0; k < file.items.length; k++) {
+ if (file.items[k].edge) {
+ edges++;
+ }
+ }
+ return { index: i, name: file.name, path: file.path, dirty: file.dirty, edges: edges, writable: !!file.handle };
+ });
+ this.navDirty = nav.files.some(function (file) {
+ return file.dirty;
+ });
+ this.navCanUndo = nav.undo.length > 0;
+ if (this.navTarget >= nav.files.length) {
+ this.navTarget = 0;
+ }
+ var sel = nav.selected;
+ // A newly picked point or edge makes the file it lives in the target; an unchanged
+ // selection leaves whichever file was picked by hand alone.
+ var sig = !sel ? '' : sel.node ? sel.node : tileKey(sel.edge.from) + '>' + tileKey(sel.edge.to) + '@' + sel.edge.file;
+ if (sig !== nav.selSig) {
+ nav.selSig = sig;
+ var owner = -1;
+ if (sel && sel.node && nav.nodes[sel.node] && nav.nodes[sel.node].edges.length) {
+ var touching = nav.nodes[sel.node].edges;
+ var target = this.navTarget;
+ owner = touching.some(function (edge) {
+ return edge.file === target;
+ })
+ ? target
+ : touching[0].file;
+ } else if (sel && sel.edge) {
+ owner = sel.edge.file;
+ }
+ if (owner >= 0) {
+ this.navTarget = owner;
+ }
+ }
+ if (!sel) {
+ this.navSelection = '';
+ } else if (sel.node) {
+ var n = nav.nodes[sel.node];
+ var oneWay = n.edges.filter(function (edge) {
+ return edge.directed;
+ }).length;
+ this.navSelection = n.x + ', ' + n.y + ', ' + n.level + ' — ' + (n.loose ? 'unconnected' : n.edges.length + ' edge' + (n.edges.length === 1 ? '' : 's')) +
+ (oneWay ? ' (' + oneWay + ' with actions)' : '');
+ } else {
+ var e = sel.edge;
+ this.navSelection = tileKey(e.from).replace(/,/g, ', ') + ' → ' + tileKey(e.to).replace(/,/g, ', ') +
+ (e.directed ? ' (one-way, has actions)' : '') + ' — ' + nav.files[e.file].name;
+ }
+ this.syncEdgePanel();
+ },
+
+ // Plain copies for the Edge panel — the panel edits through the navSet*/navAdd* methods,
+ // never these objects, so they're rebuilt from scratch on every change.
+ syncEdgePanel: function () {
+ var sel = nav.selected;
+ var edge = sel && sel.edge;
+ if (!edge) {
+ nav.pick = null;
+ }
+ this.navPicking = nav.pick === null ? -1 : nav.pick;
+ function label(t) {
+ return t.x + ', ' + t.y + ((t.level || 0) ? ', ' + t.level : '');
+ }
+ function summary(e) {
+ return e.meta.actions.map(function (a) {
+ return a.type || '?';
+ }).join(' → ');
+ }
+ var node = sel && sel.node ? nav.nodes[sel.node] : null;
+ nav.nodeEdges = node ? node.edges.slice() : [];
+ this.navNodeEdges = nav.nodeEdges.map(function (e, i) {
+ var out = tileKey(e.from) === node.key;
+ return {
+ i: i,
+ label: (e.directed ? (out ? '→ ' : '← ') : '↔ ') + label(out ? e.to : e.from),
+ actions: summary(e),
+ };
+ });
+ if (!edge) {
+ this.navEdge = null;
+ return;
+ }
+ var reverse = this.navReverseOf(edge);
+ this.navEdge = {
+ title: label(edge.from) + (edge.directed ? ' → ' : ' ↔ ') + label(edge.to),
+ file: nav.files[edge.file] ? nav.files[edge.file].name : '',
+ directed: edge.directed,
+ cost: edge.meta.cost,
+ walkCost: Math.abs(edge.to.x - edge.from.x) + Math.abs(edge.to.y - edge.from.y),
+ requires: edge.meta.requires,
+ hasReverse: !!reverse,
+ reverseSummary: reverse ? summary(reverse) || 'walk' : '',
+ actions: edge.meta.actions.map(function (action) {
+ var spec = ACTION_KEYS[action.type];
+ var keys = action.fields.map(function (f) {
+ return f.key;
+ });
+ return {
+ type: action.type,
+ known: !!spec,
+ fields: action.fields.map(function (f) {
+ return { key: f.key, value: f.value, empty: f.value.trim() === '' };
+ }),
+ missing: spec ? spec.required.filter(function (key) {
+ return keys.indexOf(key) === -1;
+ }) : [],
+ suggestions: spec ? spec.required.concat(spec.optional).filter(function (key) {
+ return keys.indexOf(key) === -1;
+ }) : [],
+ pickable: action.type === 'object' || action.type === 'tile' || action.type === 'floor_item' || keys.indexOf('x') !== -1,
+ };
+ }),
+ };
+ },
+
+ // Snapshot of every file's edge list (edges copied, since moves mutate them in place) for
+ // undo. Taken before each edit.
+ navCheckpoint: function () {
+ nav.undo.push({
+ files: nav.files.map(function (file) {
+ return {
+ dirty: file.dirty,
+ items: file.items.map(function (item) {
+ if (!item.edge) {
+ return item;
+ }
+ var copy = Object.assign({}, item.edge);
+ copy.from = copyTile(item.edge.from);
+ copy.to = copyTile(item.edge.to);
+ copy.meta = copyMeta(item.edge.meta);
+ return { edge: copy };
+ }),
+ };
+ }),
+ loose: nav.loose.map(copyTile),
+ target: this.navTarget,
+ });
+ if (nav.undo.length > UNDO_LIMIT) {
+ nav.undo.shift();
+ }
+ },
+
+ navUndo: function () {
+ var state = nav.undo.pop();
+ if (!state) {
+ return;
+ }
+ for (var f = 0; f < state.files.length && f < nav.files.length; f++) {
+ nav.files[f].items = state.files[f].items;
+ nav.files[f].dirty = state.files[f].dirty;
+ }
+ nav.loose = state.loose;
+ nav.selected = null;
+ this.navTarget = state.target;
+ this.navRebuild();
+ },
+
+ navMarkDirty: function (fileIndex) {
+ if (nav.files[fileIndex]) {
+ nav.files[fileIndex].dirty = true;
+ }
+ },
+
+ // Every open file holding an edge that touches `key` gets that edge's endpoint moved. Refused
+ // (returns false) onto a tile that's already a point — merging two points by accident while
+ // dragging past one would be easy and hard to notice.
+ navMoveNode: function (key, tile) {
+ var node = nav.nodes[key];
+ if (!node || nav.nodes[tileKey(tile)]) {
+ return false;
+ }
+ if (node.loose) {
+ for (var l = 0; l < nav.loose.length; l++) {
+ if (tileKey(nav.loose[l]) === key) {
+ nav.loose[l] = copyTile(tile);
+ }
+ }
+ }
+ for (var i = 0; i < node.edges.length; i++) {
+ var edge = node.edges[i];
+ if (tileKey(edge.from) === key) {
+ edge.from = copyTile(tile);
+ }
+ if (tileKey(edge.to) === key) {
+ edge.to = copyTile(tile);
+ }
+ this.navMarkDirty(edge.file);
+ }
+ nav.selected = { node: tileKey(tile) };
+ this.navRebuild();
+ return true;
+ },
+
+ navDeleteNode: function (key) {
+ var node = nav.nodes[key];
+ if (!node) {
+ return;
+ }
+ this.navCheckpoint();
+ nav.loose = nav.loose.filter(function (t) {
+ return tileKey(t) !== key;
+ });
+ for (var f = 0; f < nav.files.length; f++) {
+ var before = nav.files[f].items.length;
+ nav.files[f].items = nav.files[f].items.filter(function (item) {
+ return !item.edge || (tileKey(item.edge.from) !== key && tileKey(item.edge.to) !== key);
+ });
+ if (nav.files[f].items.length !== before) {
+ nav.files[f].dirty = true;
+ }
+ }
+ nav.selected = null;
+ this.navRebuild();
+ },
+
+ navDeleteEdge: function (edge) {
+ var file = nav.files[edge.file];
+ if (!file) {
+ return;
+ }
+ this.navCheckpoint();
+ file.items = file.items.filter(function (item) {
+ return item.edge !== edge;
+ });
+ file.dirty = true;
+ nav.selected = null;
+ this.navRebuild();
+ },
+
+ // Walk edge between two tiles, into `navTarget`'s file. Skipped if the two are already joined.
+ navConnect: function (from, to) {
+ if (sameTile(from, to)) {
+ return false;
+ }
+ var existing = nav.nodes[tileKey(from)];
+ if (existing) {
+ for (var i = 0; i < existing.edges.length; i++) {
+ var e = existing.edges[i];
+ if ((sameTile(e.from, from) && sameTile(e.to, to)) || (!e.directed && sameTile(e.from, to) && sameTile(e.to, from))) {
+ return false;
+ }
+ }
+ }
+ var file = nav.files[this.navTarget];
+ if (!file) {
+ return false;
+ }
+ file.items.push({ edge: newEdge(file, from, to) });
+ file.dirty = true;
+ return true;
+ },
+
+ navDeleteSelected: function () {
+ var sel = nav.selected;
+ if (!sel) {
+ return;
+ }
+ if (sel.node) {
+ this.navDeleteNode(sel.node);
+ } else {
+ this.navDeleteEdge(sel.edge);
+ }
+ },
+
+ navSelectNode: function (key) {
+ nav.selected = key ? { node: key } : null;
+ // New edges from here go into the file this point already belongs to, so extending an
+ // area's graph doesn't scatter its edges into whichever file happened to be picked.
+ var node = key && nav.nodes[key];
+ if (node && node.edges.length) {
+ this.navTarget = node.edges[0].file;
+ }
+ this.syncNavState();
+ this.scheduleRender();
+ },
+
+ // --- Edge editor (the "Edge" panel) ---
+ //
+ // Every change goes through `navEditEdge`, which takes an undo checkpoint, applies it to the
+ // selected edge's `meta` and refreshes the panel's copy (`navEdge`). Fields commit on `change`
+ // (Enter or leaving the field), not per keystroke, so one edit is one undo step.
+
+ navSelectedEdge: function () {
+ return nav.selected && nav.selected.edge ? nav.selected.edge : null;
+ },
+
+ navEditEdge: function (change) {
+ var edge = this.navSelectedEdge();
+ if (!edge) {
+ return;
+ }
+ var before = JSON.stringify(edge.meta);
+ this.navCheckpoint();
+ change.call(this, edge.meta, edge);
+ if (JSON.stringify(edge.meta) === before) {
+ nav.undo.pop();
+ this.syncNavState();
+ return;
+ }
+ edge.directed = edge.meta.actions.length > 0;
+ this.navMarkDirty(edge.file);
+ this.navRebuild();
+ },
+
+ navSetCost: function (value) {
+ value = value.trim();
+ if (value !== '' && !/^\d+$/.test(value)) {
+ this.navError = 'Cost must be a whole number.';
+ this.syncNavState();
+ return;
+ }
+ this.navError = '';
+ this.navEditEdge(function (meta) {
+ meta.cost = value;
+ });
+ },
+
+ navSetRequires: function (value) {
+ value = value.trim();
+ var problem = value === '' ? '' : value[0] !== '[' ? 'must be an array: [{ ... }]' : valueProblem(value);
+ if (problem) {
+ this.navError = 'Requires: ' + problem + '.';
+ this.syncNavState();
+ return;
+ }
+ this.navError = '';
+ this.navEditEdge(function (meta) {
+ meta.requires = value === '' ? '' : oneLine(value);
+ });
+ },
+
+ // Changing an action's type fills in the keys the new type can't do without.
+ navSetActionType: function (index, type) {
+ type = type.trim();
+ this.navEditEdge(function (meta) {
+ var action = meta.actions[index];
+ action.type = type;
+ var spec = ACTION_KEYS[type];
+ (spec ? spec.required : []).forEach(function (key) {
+ if (!action.fields.some(function (field) {
+ return field.key === key;
+ })) {
+ action.fields.push({ key: key, value: '' });
+ }
+ });
+ });
+ },
+
+ navSetField: function (index, fieldIndex, part, value) {
+ value = part === 'key' ? value.trim() : value;
+ if (part === 'value' && /^[{\[]/.test(value.trim()) && valueProblem(value)) {
+ this.navError = 'Action ' + (index + 1) + ': ' + valueProblem(value) + '.';
+ this.syncNavState();
+ return;
+ }
+ this.navError = '';
+ this.navEditEdge(function (meta) {
+ var field = meta.actions[index].fields[fieldIndex];
+ field[part] = part === 'value' && /^[{\[]/.test(value.trim()) ? oneLine(value) : value;
+ });
+ },
+
+ navAddField: function (index, key) {
+ this.navEditEdge(function (meta) {
+ meta.actions[index].fields.push({ key: key || '', value: '' });
+ });
+ },
+
+ navRemoveField: function (index, fieldIndex) {
+ this.navEditEdge(function (meta) {
+ meta.actions[index].fields.splice(fieldIndex, 1);
+ });
+ },
+
+ navRemoveAction: function (index) {
+ this.navEditEdge(function (meta) {
+ meta.actions.splice(index, 1);
+ });
+ },
+
+ navMoveAction: function (index, by) {
+ this.navEditEdge(function (meta) {
+ var to = index + by;
+ if (to < 0 || to >= meta.actions.length) {
+ return;
+ }
+ var moved = meta.actions.splice(index, 1)[0];
+ meta.actions.splice(to, 0, moved);
+ });
+ },
+
+ // Appends one of the ready-made actions (see ACTION_TEMPLATES). The first action on an edge
+ // also turns it one-way, and gives it a cost if it had none — edges with actions aren't
+ // costed by distance like walk edges are, so without one it'd be free.
+ navAddAction: function (template) {
+ this.navEditEdge(function (meta, edge) {
+ if (!meta.actions.length && meta.cost === '') {
+ meta.cost = String(Math.max(1, Math.abs(edge.to.x - edge.from.x) + Math.abs(edge.to.y - edge.from.y)));
+ }
+ ACTION_TEMPLATES[template](edge).forEach(function (action) {
+ meta.actions.push(action);
+ });
+ });
+ },
+
+ // The next click on the map sets this action's `x`/`y` instead of editing the graph.
+ navPickTile: function (index) {
+ nav.pick = nav.pick === index ? null : index;
+ this.syncNavState();
+ this.navUpdateCursor(null);
+ },
+
+ navPickAt: function (tile) {
+ var index = nav.pick;
+ nav.pick = null;
+ this.navEditEdge(function (meta) {
+ var action = meta.actions[index];
+ if (!action) {
+ return;
+ }
+ ['x', 'y'].forEach(function (key) {
+ var field = action.fields.filter(function (f) {
+ return f.key === key;
+ })[0];
+ if (!field) {
+ field = { key: key, value: '' };
+ action.fields.push(field);
+ }
+ field.value = String(tile[key]);
+ });
+ });
+ },
+
+ // The edge going the other way between the same two tiles, if there is one.
+ navReverseOf: function (edge) {
+ var node = nav.nodes[tileKey(edge.to)];
+ if (!node) {
+ return null;
+ }
+ for (var i = 0; i < node.edges.length; i++) {
+ var other = node.edges[i];
+ if (other !== edge && sameTile(other.from, edge.to) && sameTile(other.to, edge.from)) {
+ return other;
+ }
+ }
+ return null;
+ },
+
+ navSelectEdge: function (edge) {
+ nav.selected = { edge: edge };
+ nav.pick = null;
+ this.navTarget = edge.file;
+ this.syncNavState();
+ this.scheduleRender();
+ },
+
+ navGoReverse: function () {
+ var edge = this.navSelectedEdge();
+ var reverse = edge && this.navReverseOf(edge);
+ if (reverse) {
+ this.navSelectEdge(reverse);
+ }
+ },
+
+ // One-way edges mostly come in pairs (through a door and back). Copies this edge's actions
+ // onto a new edge the other way, with any action that walked to this edge's end retargeted
+ // to its start — the rest (which door, which object) is the same both ways.
+ navCreateReverse: function () {
+ var edge = this.navSelectedEdge();
+ var file = edge && nav.files[edge.file];
+ if (!file || this.navReverseOf(edge)) {
+ return;
+ }
+ this.navCheckpoint();
+ var meta = copyMeta(edge.meta);
+ meta.actions.forEach(function (action) {
+ if (action.type !== 'tile') {
+ return;
+ }
+ var x = action.fields.filter(function (f) {
+ return f.key === 'x';
+ })[0];
+ var y = action.fields.filter(function (f) {
+ return f.key === 'y';
+ })[0];
+ if (x && y && x.value === String(edge.to.x) && y.value === String(edge.to.y)) {
+ x.value = String(edge.from.x);
+ y.value = String(edge.from.y);
+ }
+ });
+ var reverse = newEdge(file, edge.to, edge.from, meta);
+ // Straight after the original, where the existing files keep each pair.
+ var at = file.items.findIndex(function (item) {
+ return item.edge === edge;
+ });
+ file.items.splice(at + 1, 0, { edge: reverse });
+ file.dirty = true;
+ this.navRebuild();
+ this.navSelectEdge(reverse);
+ },
+
+ // For the point panel: select one of a point's edges (the only way to reach one of a
+ // two-way pair, which draw over each other).
+ navPickEdge: function (index) {
+ var edge = nav.nodeEdges[index];
+ if (edge) {
+ this.navSelectEdge(edge);
+ }
+ },
+
+ navCancelPick: function () {
+ if (nav.pick === null) {
+ return false;
+ }
+ nav.pick = null;
+ this.syncNavState();
+ this.navUpdateCursor(null);
+ return true;
+ },
+
+ // --- Interaction (called from worldmap.js's pointer handlers) ---
+
+ // Showing, so drawn — though with the area layer showing too, only one of the two editors
+ // takes the pointer and keyboard (see `activeEditor` in mapedit.js).
+ navEditing: function () {
+ return this.showNavGraph && this.navLoaded;
+ },
+
+ navActive: function () {
+ return this.navEditing() && this.activeEditor() === 'nav';
+ },
+
+ // Viewport-local screen position of the centre of a game tile. Tile (x, y) covers the map from
+ // x to x + 1 and y to y + 1 (the same square `updateHoverTile` highlights), so a point sits in
+ // the middle of the tile it's on rather than on its south-west corner.
+ navScreen: function (t) {
+ return { x: this._offsetX + (t.x + 0.5) * this._scale, y: this._offsetY - (t.y + 0.5) * this._scale };
+ },
+
+ navLocal: function (e) {
+ var rect = this.viewport.getBoundingClientRect();
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ },
+
+ navTileAt: function (e) {
+ var p = this.navLocal(e);
+ return {
+ x: Math.floor((p.x - this._offsetX) / this._scale),
+ y: Math.floor((this._offsetY - p.y) / this._scale),
+ level: this.level,
+ };
+ },
+
+ navNodeAt: function (e) {
+ var p = this.navLocal(e);
+ var best = null;
+ var bestDist = NODE_HIT_PX;
+ for (var key in nav.nodes) {
+ var n = nav.nodes[key];
+ if (n.level !== this.level) {
+ continue;
+ }
+ var s = this.navScreen(n);
+ var d = Math.hypot(s.x - p.x, s.y - p.y);
+ if (d <= bestDist) {
+ best = n;
+ bestDist = d;
+ }
+ }
+ return best;
+ },
+
+ navEdgeAt: function (e) {
+ var p = this.navLocal(e);
+ var best = null;
+ var bestDist = EDGE_HIT_PX;
+ for (var f = 0; f < nav.files.length; f++) {
+ var items = nav.files[f].items;
+ for (var i = 0; i < items.length; i++) {
+ var edge = items[i].edge;
+ if (!edge || edge.from.level !== this.level || edge.to.level !== this.level) {
+ continue;
+ }
+ var a = this.navScreen(edge.from);
+ var b = this.navScreen(edge.to);
+ var d = distToSegment(p.x, p.y, a.x, a.y, b.x, b.y);
+ if (d <= bestDist) {
+ best = edge;
+ bestDist = d;
+ }
+ }
+ }
+ return best;
+ },
+
+ // The editor has no modes; what a press does depends on what's under it:
+ //
+ // press on a point, drag move the point
+ // click a point select it (clicking the selected one again deselects it)
+ // shift+click a point join the selected point to it
+ // click the map with a point selected: add a point joined to it and carry on
+ // drawing from there; otherwise select the edge under the cursor,
+ // or start a new point if there isn't one
+ // right-click delete the point or edge under the cursor, else stop drawing
+ // drag the map pan, as ever
+ //
+ // Holding Space switches all of that off, so any drag (even one starting on a point) pans.
+ navInteractive: function () {
+ return this.navActive() && !nav.panKey;
+ },
+
+ // Returns true when the press lands on a point, so the map doesn't also start panning — it
+ // may become a drag (move) or stay a click (select), which navPointerMove/Up decide.
+ navPointerDown: function (e) {
+ nav.press = null;
+ if (!this.navInteractive()) {
+ return false;
+ }
+ nav.press = { x: e.clientX, y: e.clientY, shift: e.shiftKey };
+ // Picking a tile for an action: a press on a point picks that tile rather than dragging it.
+ var node = nav.pick === null && this.navNodeAt(e);
+ if (!node) {
+ return false;
+ }
+ nav.drag = { key: node.key, moved: false };
+ return true;
+ },
+
+ // Returns true while a point is pressed, so the map doesn't pan underneath it.
+ navPointerMove: function (e) {
+ if (!this.navActive()) {
+ return false;
+ }
+ nav.hover = this.navTileAt(e);
+ // The point a click here would land on — the same hit test the click uses, so the preview
+ // never promises something the click won't do.
+ var hovered = nav.drag ? null : this.navNodeAt(e);
+ nav.hoverNode = hovered ? hovered.key : null;
+ nav.hoverShift = e.shiftKey;
+ if (nav.drag) {
+ var press = nav.press;
+ if (!nav.drag.moved) {
+ if (!press || Math.hypot(e.clientX - press.x, e.clientY - press.y) <= CLICK_SLOP_PX) {
+ return true;
+ }
+ // Past the slop: it's a move, not a click. One undo step covers the whole drag.
+ this.navCheckpoint();
+ nav.drag.moved = true;
+ this.navSelectNode(nav.drag.key);
+ }
+ var tile = this.navTileAt(e);
+ tile.level = nav.nodes[nav.drag.key] ? nav.nodes[nav.drag.key].level : this.level;
+ if (tileKey(tile) !== nav.drag.key && this.navMoveNode(nav.drag.key, tile)) {
+ nav.drag.key = tileKey(tile);
+ }
+ return true;
+ }
+ this.navUpdateCursor(e);
+ if (nav.selected && nav.selected.node) {
+ // The preview line follows the cursor.
+ this.scheduleRender();
+ }
+ return false;
+ },
+
+ navPointerUp: function (e) {
+ var press = nav.press;
+ var drag = nav.drag;
+ nav.press = null;
+ nav.drag = null;
+ if (drag && drag.moved) {
+ this.syncNavState();
+ this.navUpdateCursor(e);
+ return;
+ }
+ if (!press || !this.navInteractive() || Math.hypot(e.clientX - press.x, e.clientY - press.y) > CLICK_SLOP_PX) {
+ return;
+ }
+ this.navClick(e, press.shift);
+ },
+
+ // Pointer lost mid-press (cancelled, or a second finger turned it into a pinch): whatever a
+ // drag already moved stays moved, but the press no longer counts as a click.
+ navPointerCancel: function () {
+ nav.drag = null;
+ nav.press = null;
+ },
+
+ navClick: function (e, shift) {
+ if (nav.pick !== null) {
+ this.navPickAt(this.navTileAt(e));
+ return;
+ }
+ var node = this.navNodeAt(e);
+ var sel = nav.selected;
+ var from = sel && sel.node ? nav.nodes[sel.node] : null;
+ if (node) {
+ if (shift && from && from.key !== node.key) {
+ this.navCheckpoint();
+ if (this.navConnect(from, node)) {
+ this.navRebuild();
+ } else {
+ nav.undo.pop();
+ }
+ this.navSelectNode(node.key);
+ } else if (from && from.key === node.key) {
+ this.navSelectNode(null);
+ } else {
+ this.navSelectNode(node.key);
+ }
+ return;
+ }
+ var tile = this.navTileAt(e);
+ if (from) {
+ // Keep drawing: a new point joined to the selected one, which then becomes the selection,
+ // so a path is just a run of clicks.
+ this.navCheckpoint();
+ if (this.navConnect(from, tile)) {
+ this.navRebuild();
+ this.navSelectNode(tileKey(tile));
+ } else {
+ nav.undo.pop();
+ }
+ return;
+ }
+ var edge = this.navEdgeAt(e);
+ if (edge || sel) {
+ // An edge picks it; empty map with an edge selected just clears the selection.
+ nav.selected = edge ? { edge: edge } : null;
+ if (edge) {
+ this.navTarget = edge.file;
+ }
+ this.syncNavState();
+ this.scheduleRender();
+ return;
+ }
+ // Nothing selected, nothing hit: start a new (for now unconnected) point to draw out from.
+ this.navCheckpoint();
+ nav.loose.push(tile);
+ this.navRebuild();
+ this.navSelectNode(tileKey(tile));
+ },
+
+ // Off the map: no preview line hanging off the edge towards wherever the cursor left.
+ navPointerLeave: function () {
+ if (nav.hover) {
+ nav.hover = null;
+ nav.hoverNode = null;
+ this.scheduleRender();
+ }
+ },
+
+ // Right-click. Returns true when the editor handled it, so the browser's menu is suppressed.
+ navContextMenu: function (e) {
+ if (!this.navInteractive()) {
+ return false;
+ }
+ var node = this.navNodeAt(e);
+ var edge = node ? null : this.navEdgeAt(e);
+ if (node) {
+ this.navDeleteNode(node.key);
+ } else if (edge) {
+ this.navDeleteEdge(edge);
+ } else if (nav.selected) {
+ nav.selected = null;
+ this.syncNavState();
+ this.scheduleRender();
+ }
+ this.navUpdateCursor(e);
+ return true;
+ },
+
+ navUpdateCursor: function (e) {
+ var vp = this.viewport;
+ if (!vp) {
+ return;
+ }
+ var active = this.navInteractive() && !!e;
+ vp.classList.toggle('wm-nav-picking', this.navInteractive() && nav.pick !== null);
+ var node = active && nav.pick === null && this.navNodeAt(e);
+ var drawing = active && !node && nav.selected && nav.selected.node;
+ var edge = active && !node && !drawing && this.navEdgeAt(e);
+ vp.classList.toggle('wm-nav-grab', !!node);
+ vp.classList.toggle('wm-nav-add', !!drawing);
+ vp.classList.toggle('wm-nav-hot', !!edge);
+ },
+
+ navSetPanKey: function (down) {
+ if (nav.panKey === down) {
+ return;
+ }
+ nav.panKey = down;
+ this.navUpdateCursor(null);
+ this.scheduleRender();
+ },
+
+ navKeyDown: function (e) {
+ if (!this.navActive()) {
+ return;
+ }
+ var target = e.target;
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable)) {
+ return;
+ }
+ var mod = e.ctrlKey || e.metaKey;
+ if (e.key === ' ') {
+ // Also stops the page scrolling, or a focused panel button being pressed.
+ e.preventDefault();
+ this.navSetPanKey(true);
+ } else if (mod && e.key.toLowerCase() === 'z') {
+ e.preventDefault();
+ this.navUndo();
+ } else if (mod && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ this.saveMapFiles();
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
+ if (nav.selected) {
+ e.preventDefault();
+ this.navDeleteSelected();
+ }
+ } else if (e.key === 'Escape') {
+ if (this.navCancelPick()) {
+ return;
+ }
+ nav.selected = null;
+ this.syncNavState();
+ this.scheduleRender();
+ } else if (e.key === 'Shift') {
+ // Redraw the preview: with Shift held it snaps to the point under the cursor.
+ nav.hoverShift = true;
+ this.scheduleRender();
+ } else if (!mod && nav.selected && nav.selected.node && e.key.indexOf('Arrow') === 0) {
+ // Nudge the selected point a tile at a time — finer than dragging at low zoom.
+ e.preventDefault();
+ var n = nav.nodes[nav.selected.node];
+ var dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
+ var dy = e.key === 'ArrowDown' ? -1 : e.key === 'ArrowUp' ? 1 : 0;
+ this.navCheckpoint();
+ if (!this.navMoveNode(n.key, { x: n.x + dx, y: n.y + dy, level: n.level })) {
+ nav.undo.pop();
+ }
+ }
+ },
+
+ navKeyUp: function (e) {
+ if (e.key === ' ') {
+ if (nav.panKey) {
+ e.preventDefault();
+ }
+ this.navSetPanKey(false);
+ } else if (e.key === 'Shift') {
+ nav.hoverShift = false;
+ this.scheduleRender();
+ }
+ },
+
+ // --- Rendering ---
+
+ renderNavGraph: function (rect) {
+ var layer = this.navLayer;
+ if (!layer) {
+ return;
+ }
+ if (!this.navEditing()) {
+ layer.innerHTML = '';
+ return;
+ }
+ var level = this.level;
+ var scale = this._scale;
+ var pad = 40;
+ var self = this;
+ function visible(a, b) {
+ return !(
+ (a.x < -pad && b.x < -pad) ||
+ (a.x > rect.width + pad && b.x > rect.width + pad) ||
+ (a.y < -pad && b.y < -pad) ||
+ (a.y > rect.height + pad && b.y > rect.height + pad)
+ );
+ }
+ var sel = nav.selected;
+ var radius = Math.max(3, Math.min(7, scale * 0.6));
+ // Drawn size of the point at `tile` (diamonds are bigger), so an arrowhead can end on its rim.
+ function rimOf(tile) {
+ var node = nav.nodes[tileKey(tile)];
+ var transfer = node && node.edges.some(function (e) {
+ return e.from.level !== e.to.level;
+ });
+ return (transfer ? radius * 1.3 : radius) + 1.5;
+ }
+ var edges = '';
+ var marked = '';
+ for (var f = 0; f < nav.files.length; f++) {
+ var items = nav.files[f].items;
+ for (var i = 0; i < items.length; i++) {
+ var edge = items[i].edge;
+ if (!edge || edge.from.level !== level || edge.to.level !== level) {
+ continue;
+ }
+ var a = self.navScreen(edge.from);
+ var b = self.navScreen(edge.to);
+ if (!visible(a, b)) {
+ continue;
+ }
+ var selected = sel && (sel.edge === edge || (sel.node && (tileKey(edge.from) === sel.node || tileKey(edge.to) === sel.node)));
+ var cls = 'wm-nav-edge' + (edge.directed ? ' wm-nav-edge-directed' : '') + (selected ? ' wm-nav-edge-selected' : '') +
+ (sel && sel.edge === edge ? ' wm-nav-edge-picked' : '');
+ var end = b;
+ var head = '';
+ var dx = b.x - a.x;
+ var dy = b.y - a.y;
+ var len = Math.hypot(dx, dy);
+ var headLen = 9;
+ // One-way edge: the triangle's tip sits on the destination point's rim and the line stops
+ // at its base, instead of a marker that stops short and overlaps the line.
+ if (edge.directed && len > rimOf(edge.to) + headLen) {
+ var ux = dx / len;
+ var uy = dy / len;
+ var rim = rimOf(edge.to);
+ var tipX = b.x - ux * rim;
+ var tipY = b.y - uy * rim;
+ var baseX = tipX - ux * headLen;
+ var baseY = tipY - uy * headLen;
+ end = { x: baseX + ux * 1, y: baseY + uy * 1 };
+ head = '';
+ }
+ var line = '' + head;
+ if (selected) {
+ marked += line;
+ } else {
+ edges += line;
+ }
+ }
+ }
+ // Euclidean distance in tiles from `anchor` to `tile` ("7.6"), drawn halfway along the line between them.
+ // Skipped where the line is too short on screen for the label to sit clear of its ends.
+ var diffs = '';
+ function diff(anchor, tile) {
+ var a = self.navScreen(anchor);
+ var b = self.navScreen(tile);
+ if (Math.hypot(b.x - a.x, b.y - a.y) < 28) {
+ return;
+ }
+ var dist = Math.hypot(tile.x - anchor.x, tile.y - anchor.y);
+ diffs += '' +
+ (Number.isInteger(dist) ? dist : dist.toFixed(1)) + '';
+ }
+ var selNode = sel && sel.node ? nav.nodes[sel.node] : null;
+ if (selNode && selNode.level === level && !nav.panKey && !nav.drag && this.navActive()) {
+ // A dashed line from the selected point to where a click would join it: the tile under the
+ // cursor, or with Shift held, the point under it. Nothing over a point without Shift,
+ // since clicking there just selects it.
+ var target = nav.hoverNode ? (nav.hoverShift ? nav.nodes[nav.hoverNode] : null) : nav.hover;
+ if (target && tileKey(target) !== selNode.key) {
+ var from = self.navScreen(selNode);
+ var to = self.navScreen(target);
+ marked += '';
+ diff(selNode, target);
+ }
+ }
+ // The selected edge's actions that name a tile (the door to open, the tile to walk to) are
+ // outlined where they point, numbered in the order the bot does them.
+ if (sel && sel.edge && (sel.edge.from.level || 0) === level) {
+ sel.edge.meta.actions.forEach(function (action, i) {
+ var at = {};
+ action.fields.forEach(function (f) {
+ if ((f.key === 'x' || f.key === 'y') && /^-?\d+$/.test(f.value.trim())) {
+ at[f.key] = parseInt(f.value, 10);
+ }
+ });
+ if (at.x === undefined || at.y === undefined) {
+ return;
+ }
+ var p = self.navScreen(at);
+ var half = Math.max(scale, 8) / 2;
+ diffs += '' +
+ '' + (i + 1) + '';
+ });
+ }
+ // The selected point's distance from each neighbour, updating as it's dragged or nudged.
+ if (selNode && selNode.level === level) {
+ for (var k = 0; k < selNode.edges.length; k++) {
+ var touching = selNode.edges[k];
+ var other = tileKey(touching.from) === selNode.key ? touching.to : touching.from;
+ if ((other.level || 0) === level) {
+ diff(other, selNode);
+ }
+ }
+ }
+ var nodes = '';
+ for (var key in nav.nodes) {
+ var n = nav.nodes[key];
+ if (n.level !== level) {
+ continue;
+ }
+ var s = self.navScreen(n);
+ if (s.x < -pad || s.x > rect.width + pad || s.y < -pad || s.y > rect.height + pad) {
+ continue;
+ }
+ // A point with an edge to another floor (stairs, ladders) is drawn as a diamond.
+ var transfer = n.edges.some(function (edge) {
+ return edge.from.level !== edge.to.level;
+ });
+ var ncls = 'wm-nav-node' + (n.loose ? ' wm-nav-node-loose' : '') + (transfer ? ' wm-nav-node-transfer' : '') +
+ (sel && sel.node === key ? ' wm-nav-node-selected' : '');
+ var title = '' + n.x + ', ' + n.y + ', ' + n.level + '';
+ if (transfer) {
+ var r = radius * 1.3;
+ nodes += '' + title + '';
+ } else {
+ nodes += '' + title + '';
+ }
+ }
+ layer.innerHTML = '';
+ },
+ };
+ };
+})();
diff --git a/web/site/src/main/resources/static/js/worldmap.js b/web/site/src/main/resources/static/js/worldmap.js
index 8fb3e31c08..7bc49c7581 100644
--- a/web/site/src/main/resources/static/js/worldmap.js
+++ b/web/site/src/main/resources/static/js/worldmap.js
@@ -145,7 +145,7 @@ window.worldMapApp = function () {
return Math.round(value * dpr) / dpr;
}
- return {
+ var app = {
gameX: 3200,
gameY: 3200,
level: 0,
@@ -189,6 +189,7 @@ window.worldMapApp = function () {
_offsetX: 0,
_offsetY: 0,
_scale: BASE_PX_PER_TILE,
+ _hoverInside: false,
// Tiles no longer in the wanted set (e.g. the previous native zoom's, after a zoom crossing)
// aren't removed until their replacements finish loading — otherwise the already-loaded old
// tile disappears immediately while the new one is still fading in from opacity 0, flashing
@@ -226,6 +227,10 @@ window.worldMapApp = function () {
this.areaPolygonLayer = root.querySelector('#wm-area-polygons');
this.areaLabelLayer = root.querySelector('#wm-area-labels');
this.playerLayer = root.querySelector('#wm-players');
+ this.hoverTile = root.querySelector('#wm-hover-tile');
+ this.navBoot(root);
+ this.areaBoot();
+ this.mapEditBoot();
this.readStateFromUrl();
this.tpX = String(Math.round(this.gameX));
@@ -409,6 +414,17 @@ window.worldMapApp = function () {
return;
}
pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
+ // A nav graph point or area vertex pressed in an editor (see navgraph.js/areas.js) is
+ // dragged instead of the map. Only ever for the first finger — a second one still starts
+ // a pinch. Only the active editor (see mapedit.js) takes the press.
+ if (pointerCount() === 1 && (self.navPointerDown(e) || self.areaPointerDown(e))) {
+ try {
+ vp.setPointerCapture(e.pointerId);
+ } catch (err) {
+ // See below.
+ }
+ return;
+ }
try {
vp.setPointerCapture(e.pointerId);
} catch (err) {
@@ -445,11 +461,14 @@ window.worldMapApp = function () {
var scale = pxPerTile(self.zoom);
var localX = e.clientX - rect.left - rect.width / 2;
var localY = e.clientY - rect.top - rect.height / 2;
- self.hoverX = Math.round(self.gameX + localX / scale);
- self.hoverY = Math.round(self.gameY - localY / scale);
+ // Floored, not rounded: tile (x, y) covers the map from x to x + 1 (see `updateHoverTile`).
+ self.hoverX = Math.floor(self.gameX + localX / scale);
+ self.hoverY = Math.floor(self.gameY - localY / scale);
+ self._hoverInside = true;
self.updateHoverAreas();
+ self.updateHoverTile();
- if (!dragging) {
+ if (self.navPointerMove(e) || self.areaPointerMove(e) || !dragging) {
return;
}
var dx = e.clientX - lastX;
@@ -463,6 +482,13 @@ window.worldMapApp = function () {
});
function endDrag(e) {
+ if (!pinching && pointers[e.pointerId] && e.type === 'pointerup') {
+ self.navPointerUp(e);
+ self.areaPointerUp(e);
+ } else if (pointers[e.pointerId]) {
+ self.navPointerCancel();
+ self.areaPointerCancel();
+ }
delete pointers[e.pointerId];
try {
vp.releasePointerCapture(e.pointerId);
@@ -500,6 +526,18 @@ window.worldMapApp = function () {
vp.addEventListener('pointerup', endDrag);
vp.addEventListener('pointercancel', endDrag);
vp.addEventListener('pointerleave', endDrag);
+ vp.addEventListener('pointerleave', function () {
+ self._hoverInside = false;
+ self.updateHoverTile();
+ self.navPointerLeave();
+ self.areaPointerLeave();
+ });
+ // Right-click deletes in the editors (see navgraph.js/areas.js) instead of opening the menu.
+ vp.addEventListener('contextmenu', function (e) {
+ if (self.navContextMenu(e) || self.areaContextMenu(e)) {
+ e.preventDefault();
+ }
+ });
vp.addEventListener(
'wheel',
@@ -677,7 +715,8 @@ window.worldMapApp = function () {
},
// Everything the search box can jump to that isn't a player, as one flat list: areas (from
- // `window.VOID_AREAS`, the same polygons the area layer draws) and place names (from
+ // `areaShapes`, the same polygons the area layer draws — the loaded files' once there are any,
+ // which is also why areas.js drops this cache on every edit) and place names (from
// `window.VOID_MAP_LABELS`, the cache's own map text). Players are merged in per query by
// `searchResults` instead — this list is cached for the life of the page and that one isn't
// stable.
@@ -689,7 +728,7 @@ window.worldMapApp = function () {
return this._searchEntries;
}
var entries = [];
- var areas = window.VOID_AREAS || [];
+ var areas = this.areaShapes();
for (var i = 0; i < areas.length; i++) {
var area = areas[i];
var minX = Math.min.apply(null, area.x);
@@ -706,6 +745,7 @@ window.worldMapApp = function () {
level: area.minLevel,
width: maxX - minX,
height: maxY - minY,
+ area: area.name,
});
}
var labels = window.VOID_MAP_LABELS || [];
@@ -815,6 +855,9 @@ window.worldMapApp = function () {
}
var zoom = result.kind === 'Area' ? this.zoomFitting(result.width, result.height) : Math.max(this.zoom, FOCUS_ZOOM);
this.focusOn(result.x, result.y, result.level, zoom);
+ if (result.area) {
+ this.areaSelectNamed(result.area);
+ }
},
// The zoom at which a `width` x `height` box of game tiles fits inside the viewport with
@@ -915,6 +958,8 @@ window.worldMapApp = function () {
} else if (this.areaLabelLayer) {
this.areaLabelLayer.innerHTML = '';
}
+ this.updateHoverTile();
+ this.renderNavGraph(rect);
this.renderPlayers();
// Re-checked on every render (not just pointermove) so panning/zooming/changing level under
// a stationary cursor, or toggling the polygon layer on, keeps the hover state honest.
@@ -1205,11 +1250,15 @@ window.worldMapApp = function () {
return (targetWidth / refWidth) * REF_SIZE;
},
- // `window.VOID_AREAS` is injected server-side by [WorldMap.areasScript] — each entry's `x`/`y`
- // are game-space polygon vertices (box areas pre-expanded to four corners), drawn here as one
- // SVG apiece rather than DOM divs since a polygon isn't expressible as a CSS box.
+ // `areaShapes()` — `window.VOID_AREAS` (injected server-side by [WorldMap.areasScript]), or the
+ // areas of the files loaded into the editor (areas.js) — each entry's `x`/`y` game-space polygon
+ // vertices (box areas pre-expanded to four corners), drawn as one SVG apiece rather
+ // than DOM divs since a polygon isn't expressible as a CSS box. A vertex is a tile, and an area
+ // includes the tiles on its edges, so each vertex sits in the middle of its tile rather than on
+ // the tile's south-west corner — the outline then runs through the edge tiles it takes in. The
+ // editor's handles are drawn in the same SVG, over the polygons.
renderAreaPolygons: function (offsetX, offsetY, scale) {
- var areas = window.VOID_AREAS || [];
+ var areas = this.areaShapes();
var level = this.level;
var html = '';
for (var i = 0; i < areas.length; i++) {
@@ -1219,23 +1268,45 @@ window.worldMapApp = function () {
}
var points = '';
for (var p = 0; p < area.x.length; p++) {
- var x = offsetX + area.x[p] * scale;
- var y = offsetY - area.y[p] * scale;
+ var x = offsetX + (area.x[p] + 0.5) * scale;
+ var y = offsetY - (area.y[p] + 0.5) * scale;
points += x.toFixed(1) + ',' + y.toFixed(1) + ' ';
}
- html += '' + escapeHtml(area.name) + '';
+ html += '' + escapeHtml(area.name) + '';
}
- this.areaPolygonLayer.innerHTML = html ? '' : '';
+ html += this.renderAreaEditor();
+ this.areaPolygonLayer.innerHTML = html ? '' : '';
},
- // Which `window.VOID_AREAS` entries (see [renderAreaPolygons]) the cursor's current game tile
+ // Outlines the game tile under the cursor (`hoverX`/`hoverY`). Tile (x, y) is the square from
+ // game x to x + 1 and y to y + 1 — the same 4px block of the zoom-8 tile image the region grid's
+ // lines bound — so its top edge on screen is y + 1 (Y is flipped, see the file header). Hidden
+ // once a tile is too small on screen to make out, and while the cursor is off the map.
+ updateHoverTile: function () {
+ var el = this.hoverTile;
+ if (!el) {
+ return;
+ }
+ var scale = this._scale;
+ if (!this._hoverInside || scale < 3) {
+ el.style.display = 'none';
+ return;
+ }
+ el.style.display = 'block';
+ el.style.left = this._offsetX + this.hoverX * scale + 'px';
+ el.style.top = this._offsetY - (this.hoverY + 1) * scale + 'px';
+ el.style.width = scale + 'px';
+ el.style.height = scale + 'px';
+ },
+
+ // Which `areaShapes()` entries (see [renderAreaPolygons]) the cursor's current game tile
// (`hoverX`/`hoverY`) falls inside, on the current level. Drives both `hoverAreaNames` (read by
// the bottom-left panel via Alpine reactivity) and the `wm-area-polygon-hover` highlight class —
// the latter toggled directly on the existing `` elements rather than going through a
// full `renderAreaPolygons` rebuild, since this runs on every `pointermove` and a full innerHTML
// rebuild per mouse pixel would be wasteful (and would restart the fill/stroke transition).
updateHoverAreas: function () {
- var areas = window.VOID_AREAS || [];
+ var areas = this.areaShapes();
var level = this.level;
var x = this.hoverX;
var y = this.hoverY;
@@ -1246,7 +1317,7 @@ window.worldMapApp = function () {
if (level < area.minLevel || level > area.maxLevel) {
continue;
}
- if (pointInPolygon(x, y, area.x, area.y)) {
+ if (VoidAreas.contains(area.x, area.y, x, y)) {
names.push(area.name);
hovered[i] = true;
}
@@ -1340,6 +1411,9 @@ window.worldMapApp = function () {
}
},
};
+ // The editors' state and methods live in navgraph.js, areas.js and mapedit.js (loaded before this
+ // file).
+ return Object.assign(app, window.navGraphMethods(), window.areaEditorMethods(), window.mapEditMethods());
};
// Neither tile source gives browsers a long-lived HTTP cache - the remote host sends a five-minute
@@ -1369,23 +1443,6 @@ function onTileError() {
this.style.display = 'none';
}
-// Standard even-odd ray-casting point-in-polygon test (ties broken however they fall on an edge —
-// unlike the server's [world.gregs.voidps.type.area.Polygon.pointInPolygon], exact edge inclusion
-// doesn't need to match pixel-for-pixel here, this is just for the hover highlight).
-function pointInPolygon(x, y, xs, ys) {
- var inside = false;
- for (var i = 0, j = xs.length - 1; i < xs.length; j = i++) {
- var xi = xs[i];
- var yi = ys[i];
- var xj = xs[j];
- var yj = ys[j];
- if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
- inside = !inside;
- }
- }
- return inside;
-}
-
function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
diff --git a/web/site/src/main/resources/static/style/world-map.css b/web/site/src/main/resources/static/style/world-map.css
index acd87f8be9..501d8e9c70 100644
--- a/web/site/src/main/resources/static/style/world-map.css
+++ b/web/site/src/main/resources/static/style/world-map.css
@@ -101,6 +101,171 @@
stroke-width: 2;
}
+/* Area editor (`renderAreaEditor` in areas.js): the selected area in parchment, its corners as
+ squares (the nav graph's points are round) and its edge midpoints as faint dots to drag a new
+ corner out of; the area being drawn dashed in gold. */
+.wm-area-polygon-selected {
+ fill: rgba(255, 244, 214, 0.16);
+ stroke: var(--parch-50);
+ stroke-width: 2.5;
+}
+.wm-area-vertex {
+ fill: var(--umber-900);
+ stroke: var(--parch-50);
+ stroke-width: 1.5;
+}
+.wm-area-vertex-selected,
+.wm-area-vertex-first {
+ fill: var(--gold-300);
+}
+.wm-area-mid {
+ fill: rgba(255, 244, 214, 0.35);
+ stroke: rgba(255, 244, 214, 0.9);
+ stroke-width: 1;
+}
+.wm-area-draft {
+ fill: rgba(240, 198, 103, 0.12);
+ stroke: var(--gold-300);
+ stroke-width: 2;
+ stroke-dasharray: 5 4;
+ stroke-linejoin: round;
+}
+
+/* The game tile under the cursor (`updateHoverTile` in worldmap.js). */
+.wm-hover-tile {
+ display: none;
+ position: absolute;
+ box-sizing: border-box;
+ border: 1px solid rgba(255, 244, 214, 0.85);
+ background: rgba(255, 244, 214, 0.18);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+}
+
+/* Nav graph overlay (`renderNavGraph` in navgraph.js). Walk edges are teal, edges with actions
+ (doors, stairs — one-way) amber and dashed with an arrowhead; everything touching the selection
+ is lifted to gold. Points with an edge to another floor are drawn as diamonds. */
+.wm-nav-edge {
+ stroke: rgba(94, 214, 196, 0.8);
+ stroke-width: 2;
+ stroke-linecap: round;
+}
+.wm-nav-edge-directed {
+ stroke: rgba(240, 150, 70, 0.9);
+ stroke-dasharray: 5 3;
+}
+.wm-nav-edge-selected {
+ stroke: var(--gold-300);
+ stroke-width: 3;
+}
+.wm-nav-edge-picked {
+ stroke: var(--parch-50);
+ stroke-width: 4;
+}
+.wm-nav-edge-preview {
+ stroke: var(--gold-300);
+ stroke-dasharray: 4 4;
+ opacity: 0.8;
+}
+/* Tile distance labels along the preview line and the selected point's edges. */
+.wm-nav-diff {
+ fill: var(--parch-50);
+ stroke: rgba(20, 14, 8, 0.9);
+ stroke-width: 3;
+ paint-order: stroke;
+ font: var(--weight-semibold) 11px/1 var(--font-ui);
+ font-variant-numeric: tabular-nums;
+ text-anchor: middle;
+ dominant-baseline: central;
+}
+.wm-nav-arrow {
+ fill: rgba(240, 150, 70, 0.95);
+ stroke: none;
+}
+.wm-nav-arrow-selected {
+ fill: var(--gold-300);
+}
+.wm-nav-node {
+ fill: rgb(28, 64, 60);
+ stroke: rgb(94, 214, 196);
+ stroke-width: 2;
+}
+.wm-nav-node-transfer {
+ fill: rgb(80, 44, 16);
+ stroke: rgb(240, 150, 70);
+}
+.wm-nav-node-loose {
+ fill: transparent;
+ stroke-dasharray: 3 2;
+}
+.wm-nav-node-selected {
+ fill: var(--gold-300);
+ stroke: var(--parch-50);
+ stroke-width: 2.5;
+}
+
+/* Editor cursors, set on the viewport by `navUpdateCursor`. */
+.wm-viewport.wm-nav-add {
+ cursor: crosshair;
+}
+.wm-viewport.wm-nav-hot {
+ cursor: pointer;
+}
+.wm-viewport.wm-nav-grab {
+ cursor: move;
+}
+/* Panning wins over whatever the cursor was hovering when the drag started. */
+.wm-viewport.wm-dragging {
+ cursor: grabbing;
+}
+
+.wm-nav-controls {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 3px var(--space-4);
+ font: var(--type-body-sm);
+ font-size: var(--text-2xs);
+ line-height: var(--leading-normal);
+ color: var(--text-faint);
+}
+.wm-nav-controls > :nth-child(odd) {
+ color: var(--text-muted);
+ font-weight: var(--weight-semibold);
+ white-space: nowrap;
+}
+
+.wm-nav-file {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+ width: 100%;
+ height: 26px;
+ padding: 0 8px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--umber-800);
+ color: var(--text-muted);
+ font: var(--type-body-sm);
+ font-size: var(--text-2xs);
+ cursor: pointer;
+ text-align: left;
+}
+.wm-nav-file:hover {
+ color: var(--gold-300);
+}
+.wm-nav-file-target {
+ border-color: var(--gold-600);
+ color: var(--gold-300);
+}
+.wm-nav-file-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.wm-nav-file-count {
+ color: var(--text-faint);
+}
.wm-pin-tooltip {
opacity: 0;
pointer-events: none;
@@ -167,3 +332,298 @@
width: 130px !important;
}
}
+
+/* Nav graph edge editor (`navEdgePanel` in WorldMap.kt). */
+.wm-nav-edge-wrap {
+ width: min(320px, calc(100vw - 40px));
+}
+.wm-nav-edge-body {
+ max-height: min(62vh, 560px);
+ overflow-y: auto;
+ padding: var(--space-5) var(--space-6) var(--space-6);
+}
+.wm-nav-edge-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+.wm-nav-edge-title {
+ font: var(--type-code);
+ font-size: var(--text-xs);
+ color: var(--gold-300);
+}
+.wm-nav-edge-hint {
+ font: var(--type-body-sm);
+ font-size: var(--text-2xs);
+ color: var(--text-faint);
+ line-height: var(--leading-normal);
+}
+.wm-nav-edge-picking {
+ color: var(--gold-300);
+}
+.wm-nav-edge-label,
+.wm-nav-edge-field > span {
+ font: var(--type-label);
+ font-size: var(--text-2xs);
+ letter-spacing: var(--tracking-wide);
+ color: var(--text-muted);
+}
+.wm-nav-edge-field {
+ display: grid;
+ grid-template-columns: 64px 1fr;
+ align-items: center;
+ gap: var(--space-4);
+}
+.wm-nav-edge-field-stacked {
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
+}
+.wm-nav-edge-warn {
+ font: var(--type-body-sm);
+ font-size: var(--text-2xs);
+ color: var(--feedback-warning);
+}
+.wm-nav-edge-row {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--space-4);
+ width: 100%;
+ padding: 5px 8px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--umber-800);
+ color: var(--text-muted);
+ font: var(--type-code);
+ font-size: var(--text-2xs);
+ text-align: left;
+ cursor: pointer;
+}
+.wm-nav-edge-row:hover {
+ color: var(--gold-300);
+ border-color: var(--gold-600);
+}
+.wm-nav-edge-row-actions {
+ color: var(--text-faint);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.wm-nav-input {
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ height: 26px;
+ padding: 0 7px;
+ background: var(--surface-inset);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ color: var(--text-body);
+ font: var(--type-code);
+ font-size: var(--text-2xs);
+ outline: none;
+}
+textarea.wm-nav-input {
+ height: auto;
+ padding: 5px 7px;
+ resize: vertical;
+}
+.wm-nav-input:focus {
+ border-color: var(--gold-400);
+}
+.wm-nav-input:disabled {
+ opacity: 0.55;
+}
+.wm-nav-input-empty {
+ border-color: var(--feedback-warning);
+}
+.wm-nav-action {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 6px;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ background: rgba(0, 0, 0, 0.12);
+}
+.wm-nav-action-head,
+.wm-nav-action-field {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.wm-nav-action-num {
+ flex: 0 0 18px;
+ text-align: center;
+ font: var(--weight-semibold) var(--text-2xs) / 1 var(--font-ui);
+ color: var(--gold-300);
+}
+.wm-nav-action-type {
+ flex: 1;
+ color: var(--gold-300);
+}
+.wm-nav-key {
+ flex: 0 0 84px;
+ color: var(--text-muted);
+}
+.wm-nav-action-foot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 4px;
+}
+.wm-nav-icon {
+ flex: 0 0 22px;
+ height: 22px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-faint);
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+}
+.wm-nav-icon:hover:not(:disabled) {
+ color: var(--gold-300);
+ border-color: var(--border-strong);
+}
+.wm-nav-icon:disabled {
+ opacity: 0.3;
+ cursor: default;
+}
+.wm-nav-icon-active {
+ color: var(--umber-900);
+ background: var(--gold-300);
+}
+.wm-nav-link {
+ align-self: flex-start;
+ padding: 0;
+ border: none;
+ background: none;
+ color: var(--gold-300);
+ font: var(--type-body-sm);
+ font-size: var(--text-2xs);
+ cursor: pointer;
+}
+.wm-nav-link:hover {
+ text-decoration: underline;
+}
+.wm-nav-templates {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.wm-nav-chip {
+ height: 24px;
+ padding: 0 8px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-pill);
+ background: var(--umber-800);
+ color: var(--text-muted);
+ font: var(--weight-semibold) var(--text-2xs) / 1 var(--font-ui);
+ cursor: pointer;
+}
+.wm-nav-chip:hover:not(:disabled) {
+ color: var(--gold-300);
+ border-color: var(--gold-600);
+}
+.wm-nav-chip:disabled {
+ opacity: 0.45;
+ cursor: default;
+}
+.wm-nav-chip-danger:hover:not(:disabled) {
+ color: var(--feedback-danger);
+ border-color: var(--feedback-danger);
+}
+
+/* The Area panel's tags (`areaEditPanel` in WorldMap.kt): a chip per tag, with the field to add
+ one flowing on after them. */
+.wm-area-tags {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px;
+}
+.wm-area-tag {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 2px 0 8px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-pill);
+ background: var(--umber-800);
+ color: var(--gold-300);
+ font: var(--type-code);
+ font-size: var(--text-2xs);
+}
+.wm-area-tag button {
+ padding: 0 5px;
+ border: none;
+ background: none;
+ color: var(--text-faint);
+ font-size: 10px;
+ cursor: pointer;
+}
+.wm-area-tag button:hover {
+ color: var(--feedback-danger);
+}
+.wm-area-tag-input {
+ flex: 1 0 90px;
+ width: auto;
+ height: 22px;
+}
+.wm-area-name {
+ color: var(--gold-300);
+}
+
+/* The top-left column holds an editor's panel under the display toggles, which together can run
+ taller than a short window: it scrolls rather than hanging off the bottom of the map. */
+.wm-display-wrap {
+ max-height: calc(100% - 40px);
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+/* The display panel's "Editing" switch, while both editors are loaded (`editorSwitch`). */
+.wm-editor-switch {
+ display: flex;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-pill);
+ overflow: hidden;
+}
+.wm-editor-switch button {
+ flex: 1 1 auto;
+ height: 24px;
+ padding: 0 6px;
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ font: var(--weight-semibold) var(--text-2xs) / 1 var(--font-ui);
+ cursor: pointer;
+}
+.wm-editor-switch button + button {
+ border-left: 1px solid var(--border-strong);
+}
+.wm-editor-switch button:hover {
+ color: var(--gold-300);
+}
+.wm-editor-switch button.wm-editor-switch-on {
+ background: var(--gold-300);
+ color: var(--umber-900);
+}
+
+/* The selected edge's action targets on the map (`renderNavGraph`). */
+.wm-nav-target {
+ fill: rgba(240, 150, 70, 0.18);
+ stroke: rgba(240, 150, 70, 0.95);
+ stroke-width: 1.5;
+ stroke-dasharray: 3 2;
+}
+.wm-nav-target-picking {
+ stroke: var(--gold-300);
+ fill: rgba(240, 198, 103, 0.3);
+}
+.wm-viewport.wm-nav-picking {
+ cursor: crosshair;
+}