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( + """ +
+ + +
+ """, + ) + } + } + } + /** * 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( + """ +
+ + + + + +
+ """, + ) + } + } + } + /** 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 = '' + edges + marked + nodes + diffs + ''; + }, + }; + }; +})(); 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 + '' : ''; + html += this.renderAreaEditor(); + this.areaPolygonLayer.innerHTML = html ? '' + 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; +}