Spaces:
Running
Running
File size: 97,759 Bytes
71eae78 2428d78 835b445 d16591c 71eae78 7d93bf0 71eae78 83e579b 75ec82b 83e579b 75ec82b ce91c1d 2428d78 71eae78 83e579b 71eae78 9f89772 75ec82b 9f89772 71eae78 2428d78 83e579b 71eae78 2428d78 71eae78 2428d78 44bae7c 42ec301 71eae78 835b445 71eae78 d16591c 2428d78 71eae78 9986f05 71eae78 2428d78 71eae78 2428d78 71eae78 2428d78 9986f05 71eae78 7d93bf0 2428d78 71eae78 2428d78 45cc183 2428d78 45cc183 2428d78 835b445 2428d78 7d93bf0 2428d78 ad47ef2 7d93bf0 2428d78 7d93bf0 2428d78 45cc183 7d93bf0 835b445 7d93bf0 2428d78 a24e266 45cc183 835b445 a24e266 45cc183 835b445 a24e266 2428d78 818aea9 9986f05 45cc183 9986f05 45cc183 9986f05 45cc183 9986f05 45cc183 9986f05 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 45cc183 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 2428d78 7d93bf0 71eae78 7d93bf0 2428d78 71eae78 44bae7c 71eae78 2428d78 44bae7c 7b6d5a9 44bae7c 2428d78 835b445 2428d78 db17af1 7d93bf0 45cc183 7d93bf0 45cc183 7d93bf0 835b445 45cc183 152c254 835b445 7d93bf0 0b13ced 7d93bf0 0b13ced 7d93bf0 ad47ef2 7b6d5a9 ad47ef2 7b6d5a9 ad47ef2 0b13ced 7d93bf0 45cc183 7d93bf0 f38d705 7d93bf0 0b13ced 7d93bf0 f38d705 7d93bf0 45cc183 7d93bf0 ad47ef2 45cc183 ad47ef2 7b6d5a9 7d93bf0 45cc183 7d93bf0 45cc183 7d93bf0 44bae7c 7d93bf0 94ca139 71eae78 94ca139 71eae78 7d93bf0 835b445 818aea9 db17af1 7d93bf0 0b13ced 7d93bf0 32103e5 835b445 7d93bf0 2428d78 45cc183 7b6d5a9 45cc183 2428d78 3647d73 9f89772 71eae78 2428d78 ad47ef2 2428d78 71eae78 7d93bf0 71eae78 44bae7c 835b445 71eae78 a24e266 835b445 a24e266 45cc183 7b6d5a9 835b445 71eae78 0b13ced 2428d78 835b445 db17af1 0b13ced 7d93bf0 45cc183 2428d78 44bae7c 94ca139 71eae78 ad47ef2 71eae78 ad47ef2 71eae78 45cc183 71eae78 ad47ef2 7b6d5a9 ad47ef2 7b6d5a9 ad47ef2 7b6d5a9 ad47ef2 71eae78 42ec301 71eae78 44bae7c 71eae78 2428d78 0b13ced 152c254 75ec82b 71eae78 98fd5ba 71eae78 42ec301 71eae78 2428d78 45cc183 2428d78 71eae78 45cc183 71eae78 94ca139 71eae78 44bae7c 71eae78 d16591c 71eae78 94ca139 71eae78 835b445 9986f05 71eae78 94ca139 835b445 152c254 d16591c 71eae78 d16591c 94ca139 835b445 d16591c 94e856b 9986f05 152c254 71eae78 2428d78 44bae7c 2428d78 71eae78 44bae7c 71eae78 2428d78 7d93bf0 0b13ced 7d93bf0 2428d78 7d93bf0 0b13ced 7d93bf0 2428d78 45cc183 2428d78 45cc183 7d93bf0 9986f05 45cc183 9986f05 45cc183 9986f05 7d93bf0 45cc183 1c99eda 45cc183 1c99eda 45cc183 1c99eda 45cc183 9986f05 45cc183 9986f05 1c99eda 835b445 7d93bf0 45cc183 7d93bf0 45cc183 7d93bf0 45cc183 7d93bf0 45cc183 0b13ced 7d93bf0 45cc183 a24e266 7d93bf0 45cc183 7d93bf0 7b6d5a9 7d93bf0 45cc183 7d93bf0 45cc183 835b445 7d93bf0 6f7f7b5 45cc183 835b445 45cc183 6f7f7b5 45cc183 32103e5 6f7f7b5 45cc183 835b445 6f7f7b5 835b445 6f7f7b5 835b445 6f7f7b5 835b445 6f7f7b5 835b445 6f7f7b5 db17af1 9986f05 835b445 2428d78 71eae78 835b445 45cc183 835b445 f38d705 835b445 f38d705 94e856b 6f7f7b5 71eae78 44bae7c 71eae78 7d93bf0 71eae78 7d93bf0 71eae78 44bae7c db17af1 152c254 db17af1 2428d78 835b445 2428d78 45cc183 2428d78 71eae78 2428d78 0b13ced 2428d78 71eae78 44bae7c 71eae78 94ca139 44bae7c ce91c1d 44bae7c 71eae78 ad47ef2 71eae78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 | // The level. A walkable stretch of forest with a beginning, encounters and an
// end.
//
// THE ARGUMENT THIS LEVEL MAKES: looking is what starts every fight. A Drained
// that has never been lit never attacks. All of the combat here is caused by
// the player choosing to see. A player who never raises the torch walks the
// whole level, learns nothing, and finishes it β and that has to stay possible.
//
// L1 scope: he walks, he has a facing, the camera follows, and the torch locks
// him in place. Background is L2, combat is L4, watchers are L5.
import { createPlayer } from './anim.js';
import { createTorch } from './torch.js';
import { createCamera } from './camera.js';
import { createBackground, tintFrames } from './background.js';
import { createDrained, HOUSE_OF, WANDER, RUSH, DOWNED, RISING, HOMING, HOME } from './drained.js';
import { TUNING, BOWL, PALETTE, TORCH, AUDIO, startX, runSeed, mulberry32, FLASH, flashAt, atLuminance } from './tuning.js';
export const W = 1920;
export const H = 1080;
export const GROUND_Y = 880;
export async function createLevelScene(clips, { audio = null, seed = null, letters = null } = {}) {
const SEED = seed ?? runSeed();
const runClip = clips.prince_run;
const idleClip = clips.prince_idle;
// EVERY POSE FALLS BACK TO HER STANDING DRAWING.
//
// The generated stand-ins are gone β raising the torch, holding the sheaf,
// standing ready, holding his side. Each was a different body next to hers,
// and changing pose looked like changing character, which is a worse lie than
// not changing pose at all.
//
// AND NOW SHE HAS DRAWN THE REST: the swing, the fall and the torch held up.
// Nothing he does is generated any more.
//
// Put any of them back in the manifest and it is used again with no code
// change. That is the whole point of this file reading the manifest instead
// of naming frames.
const pose = (clip) => clip ?? idleClip;
const torchClip = pose(clips.prince_torch);
const guardClip = pose(clips.prince_guard);
// HOLDING THE SHEAF HOLDS THE TORCH UP, so with no drawing of his papers in
// his hand, the pose is the torch held up -- her arm -- and the sheet is drawn
// at its mapPivot. Falling back to the standing drawing put the jointed
// placeholder arm back on him every time he read.
const mapClip = clips.prince_map ?? torchClip;
const hurtClip = clips.prince_hurt ?? null;
const sheetClip = clips.map_sheet;
// The hand the torch is held in, published by the manifest in sprite
// coordinates. Move the pose in the art and the light follows it β and with
// no raised-torch drawing, that is her far hand at hip height.
const hand = torchClip.handPivot ?? [torchClip.pivotX, torchClip.pivotY - 230];
const HAND_DX = hand[0] - torchClip.pivotX;
const HAND_DY = hand[1] - torchClip.pivotY;
// The jointed placeholder arm, if the manifest has one. Its down pose lands
// on handPivot to the pixel, so with or without it the lowered hand is in
// the same place and only the raise differs.
// Where the fire stands in each frame of her raise, as offsets from the pose's
// pivot. Null for a pose with one hand position, which uses HAND_DX/DY.
const TORCH_HANDS = torchClip.handPivots
? torchClip.handPivots.map(([x, y]) => [x - torchClip.pivotX, y - torchClip.pivotY])
: null;
const TORCH_ARM = torchClip.torchArm
? { ...torchClip.torchArm,
shoulder: [torchClip.torchArm.shoulder[0], torchClip.torchArm.shoulder[1]] }
: null;
const START_X = startX();
const attackClip = pose(clips.prince_attack);
const downClip = pose(clips.prince_down);
const ATTACK_TOTAL = (TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS + TUNING.ATTACK_RECOVERY_MS) / 1000;
const CONTACT_REACH = 110;
/** Deterministic 0..1 from an integer. Same walk, same encounters. */
const hash01 = (i, salt) => {
let h = Math.imul((i | 0) ^ salt, 2654435761) >>> 0;
h ^= h >>> 15; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13;
return (h >>> 0) / 4294967296;
};
const drained = [];
// Bounded at both ends from the first frame: the start of the road, and the
// shut gate. See the note at GATE_CAM_X for why the right-hand bound is not
// simply the gate.
const camera = createCamera(W, 0, 0); // real bounds set once GATE_CAM_X exists
const torch = createTorch();
const prince = {
x: START_X, prevX: START_X,
facing: 1,
moving: false,
hasSword: true,
clip: idleClip,
frame: 0,
run: createPlayer(runClip),
idle: createPlayer(idleClip),
torchPlayer: createPlayer(torchClip),
guard: createPlayer(guardClip),
mapPlayer: mapClip ? createPlayer(mapClip) : null,
hurtPlayer: hurtClip ? createPlayer(hurtClip) : null,
// Hits TAKEN, not left. There is no bar; it is read off his body.
hits: 0,
// HOW FAR THE LIGHT HAS GONE OUT OF HIM. 0 is a full white eye, 1 is dark.
// This is the health readout, and it replaces nothing because there never
// was one: no bar, no number. He limps, he holds his side, the swing slows,
// and now the one thing you can always see on a black silhouette dims.
eyeDim: 0,
attacking: false,
attackTime: 0,
attackConnected: false,
attack: createPlayer(attackClip),
down: createPlayer(downClip),
// What he is carrying. Each entry knows whether he ever actually LOOKED at
// it: take one in the dark and you own the thing without ever learning
// whose it was, which is the level's whole argument stated as an object.
inventory: [],
// KEYS ARE NOT IN THE SHEAF and this is why they are a number rather than
// a page. They carry no deduction, they cannot be read, and they are the
// same object five times over. A pile of loose paper is what he has worked
// out; this is just what is in his pocket.
keys: 0,
};
// Async because the sky texture and the tinted hills are built once, here,
// rather than per frame.
const background = await createBackground(clips, GROUND_Y, H);
// THE BACK LANE'S VALUE, baked once. Everything a Drained can be showing
// while it stands beside the road β the still pose, the three loops, the
// walk a wanderer paces on and steps down with β and the page a carrier
// holds, because the page is part of the figure's mass in the dark.
//
// A tint and not a translucent black. Black at 86% would be 14 against the
// hills and 21 against the sky: a figure you can see the world through.
// `back` sits beside `dark` and `lit` on every frame, and the renderer
// blends it toward `dark` while one steps down.
{
const hex = atLuminance(PALETTE.WATCHERS, TUNING.BACK_LANE_VALUE);
for (const name of ['drained_watcher', 'drained_walk', 'drained_step',
'loop_swing', 'loop_haul', 'loop_bear', 'kept']) {
const c = clips[name];
if (!c || c.frames[0]?.back) continue;
const tinted = await tintFrames(c, hex, 'dark');
c.frames.forEach((f, i) => { f.back = tinted[i]; });
}
}
// Three Drained along the road, deterministic, NONE of them hostile until the
// player makes them hostile. A player who never raises the torch walks the
// whole level, learns nothing, and finishes it. That has to remain possible β
// it is the fed run in miniature.
//
// They stand. Nothing in this level wanders any more: an unlit Drained is a
// person who stopped, and standing still is what that looks like. See the
// note at stillClip in drained.js.
//
// Each one gets a window and stands at the spot inside it with the most open
// sky behind the action. A silhouette only shows up against light, so this is
// the one thing Blade Warrior does that costs nothing β the fight belongs
// under the bright patch. See background.skyOpenAt for what "open" counts.
// THE TREE GOES DOWN FIRST, the way the doors do, and the three road drained
// go in what is left. See TREE_CLEAR.
const TREE_X = Math.round(TUNING.LEVEL_LENGTH_PX * TUNING.TREE_AT);
{
const L = TUNING.LEVEL_LENGTH_PX;
const n = TUNING.ROAD_ENCOUNTERS;
for (let i = 0; i < n; i++) {
// The jitter moves the WINDOW, not the answer. Applying it after the
// choice simply undid the choice.
const centre = (0.25 + i * (0.5 / Math.max(1, n - 1))) * L +
(hash01(i, 0x51) - 0.5) * 500;
const span = 0.08 * L;
let bestX = Math.round(centre), best = -Infinity;
for (let k = -12; k <= 12; k++) {
const cand = centre + (k / 12) * span;
if (cand < 900 || cand > BOWL.X0 - 900) continue;
// Never within the torch's own WIDTH of one already placed. Chasing the
// open sky pulled two of three encounters to 667px apart, and the torch
// is 680 across β so one raise woke both, on the stretch that is
// supposed to be teaching him what raising it costs.
if (drained.some((o) => Math.abs(o.x - cand) < TORCH.RADIUS * 2)) continue;
// And never within the torch's reach of the note on the tree.
if (Math.abs(cand - TREE_X) < TUNING.TREE_CLEAR) continue;
// Gentle pull to the middle of the window so ties do not drift to an end.
const score = background.skyOpenAt(cand, W) - Math.abs(cand - centre) * 0.6;
if (score > best) { best = score; bestX = Math.round(cand); }
}
// They carry NOTHING and they do no loop at all. They are from elsewhere:
// travellers who stopped, not residents. One object per kind means a
// second hammer out here would file against the forge and the map's only
// error state is two objects landing on one house.
//
// So the loop skill correctly rules them out, once the player has it.
drained.push(createDrained(clips, bestX, hash01(i, 0x77) < 0.5 ? -1 : 1, i,
{ kept: null, keptMaxX: BOWL.X0 }));
}
}
// --- the village ------------------------------------------------------
// What used to be here: thirty-four watchers stepping onto the path until he
// could not get through. A placeholder from the first day, and deleted rather
// than disabled. The background watcher layer already populates the bowl, and
// a second non-interactive crowd system beside it was one system too many.
//
// The mouth is a one-way threshold. Cross it and the camera stops following
// and starts being bounded; walking back inside is free, walking back out is
// not. There is no art at the mouth and no fade. The houses are the only
// thing that says the mode changed.
const GATE_X = BOWL.X1;
const GATE_STOP_X = GATE_X - 380; // inside the gateway, clear of the post
// AND THE FAR SIDE OF THE SAME GATEWAY, once the man who holds it has gone
// home. 40px inside the right post, as GATE_STOP_X is inside the left, so he
// never stands in front of pure black and disappears into it.
const GATE_ROOM_X = GATE_X + 380;
// Where the view stops, and it is not simply the gate. Bounding the camera at
// GATE_X - W/2 put the wall across the middle of the frame and shoved him
// into the left half to stand at it. Stopping three quarters of the way over
// leaves the village behind him visible while he is working out that the gate
// will not open, which is the exact moment he has to turn around.
const GATE_CAM_X = GATE_X - W * 0.75;
// AND A LITTLE FURTHER ONCE THE GATEWAY IS HIS. With its keeper home he can
// walk to the far post, and at GATE_CAM_X that stood him 100px from the edge
// of the frame with the post cut in half. This frames the whole gateway β
// both posts, the leaf and all four sockets β with 100px of wall past it.
// The keeper leaves while the prince is at the gatehouse, a screen away, so
// the view is never at either bound when it changes and nothing jumps.
const GATE_ROOM_CAM_X = GATE_X + 620 - W;
// THERE IS NO ONE-WAY MOUTH, and the reason is worth keeping.
//
// The spec called the threshold at the village's edge "the whole transition":
// cross it and the camera stops following and starts being bounded, and you
// cannot walk back out. That was written when the road was twelve screens of
// empty approach nobody would ever want to re-walk.
//
// The road is four screens now and has three Drained on it. Locking the
// threshold behind him does not focus the level, it deletes three encounters
// and everything they were carrying the moment he steps past a house. So the
// camera is bounded at BOTH ends of the whole level from the first frame, and
// the village is a place rather than a room with a door.
//
// What the threshold was FOR survives without it: the houses are still the
// only signal the mode changed, and the shut gate is still the only way out.
camera.setBounds(0, GATE_CAM_X);
// --- the street --------------------------------------------------------
// Five houses and eleven shapes, laid out as a RHYTHM rather than a scatter.
//
// The village is divided into five blocks. Each block is a house at its head
// and open ground behind it, and the shapes live in the open ground. That
// guarantees the clear pocket around every door by construction, instead of
// scattering shapes and hoping none of them lands on a doorstep β which is
// what the first pass did, and all five doors were unlookable.
//
// It also gives the place a shape you can read while walking: a building, a
// stretch of people, a building. A village street, not a field of posts.
const CLEAR = TUNING.BOWL_CLEAR;
// THE GATEHOUSE COMES OUT OF THE ROW AND STANDS BESIDE THE GATE, which is
// what its own art note has said since it was drawn: "palace-built, like the
// gate it stands beside". It was 2400px away, which is off the right edge of
// a pinned camera β so filing the gatekeeper's fragment, the first thing
// anybody files and the tutorial for the entire ending, showed nothing at all
// for the better part of a minute.
//
// At 1100px short of the gate his walk home is 870px: he is 210px off the
// right edge when the button is pressed and in frame within a second. His
// house is still not the gate and he still walks away from the post, which
// is the whole of what section 8 is about. It just happens where you can see
// it. The facade is 960 wide, so it ends at 27380 and the gate's posts begin
// at 27580; the torch on its door reaches 27100, which is 670px short of him
// and comfortably outside DOOR_CLEAR, so lighting it still wakes nothing.
const GATEHOUSE_X = BOWL.X1 - 1100;
const SPAN4 = (GATEHOUSE_X - 1600) - (BOWL.X0 + 500);
const houseX = (i) => (i === 4 ? GATEHOUSE_X
: Math.round(BOWL.X0 + 500 + SPAN4 * (i + 0.5) / 4));
// Lighting a door tells you who lived there and nothing else β no shaped
// gaps, no racks, no missing-tool puzzle.
//
// Four of the five have someone out in the forest carrying their answer. The fifth
// is not anywhere, and the game never once says so.
//
// SIZED AGAINST A MAN, not picked. At 470 tall a house came out 1.8x a
// standing figure, and a building a man nearly fills the door of reads as a
// shed. These run 1.9x to 2.3x and are wider than they are tall.
//
// AND THEY ARE NOT PURE BLACK. Standing the prince in front of an 820px black
// wall merged the two into one mass and he simply disappeared for two seconds
// at a time β the same failure the watchers had, at ten times the area. So a
// house takes the watcher treatment, which is already measured and already
// tested: luminance 26 rather than 0. Everything ON the road stays pure black
// and reads against them. Its foot still meets the ground β a building
// floating above the ground line reads as a mistake, and the VALUE is what
// says it is set back, not a gap under it.
//
// The gate is the exception and stays pure black, because he never reaches
// it β he stops 380px short and the wall is the thing that stopped him.
const houseClip = clips.houses;
// Where his orders came from. Not one of the five, and deliberately not a
// sixth variant of `houses` -- see the manifest's note on the palace clip.
const palaceClip = clips.palace ?? null;
// Index order is house order and the names come straight off HOUSE_OF, so a
// fragment's house and a plot on the map are the same number and cannot
// drift apart.
const HOUSES = ['forge', 'bakehouse', 'wellhouse', 'home', 'gatehouse'].map((kind, i) => ({
kind,
index: i,
answers: HOUSE_OF[i],
variant: i,
x: houseX(i),
w: houseClip ? houseClip.width : 960,
// HAS THE TORCH BEEN ON ITS DOOR OR ITS LETTER? Latches, like the objects
// on the ground, and for the same reason. It is the whole of what the door
// remembers about him: the band reads a lit door again for free, and the
// slot takes a pin.
//
// THE LETTER NEVER COMES DOWN, so there is no second flag for it. Letters
// stay where they were left and fragments come off bodies: he does not
// pocket a stranger's mail, and the door is where he judges.
seen: false,
// WHAT HE HAS FILED UNDER THIS HOUSE, and nothing else in the build writes
// it. The `kept` index of one fragment, or null. The map used to fill this
// in by itself the moment he looked at something, which made it a receipt;
// a claim only means anything if it can be wrong, so he makes it.
filed: null,
// SOMEBODY CAME HOME TO IT. Set when a body finishes its walk at this
// door, and it is the only thing in the level that is ever a verdict β
// except that it is not one, because the game does not say it. A person
// is standing in the doorway who was not standing there before.
open: false,
// The key off its hook. One per house, and four of the five can be had.
keyTaken: false,
// THE FRAME IS FINISHED WITH. Set the instant a correct fragment goes in,
// which is the instant its owner starts walking, not when it arrives.
//
// It is not a cost. Brute-forcing a door is still four presses and the
// addendum allows it. What it stops is one thing: taking a name back off a
// wall after the person it belonged to is already on their way, which
// would either have to turn them round or quietly not.
settled: false,
}));
// The door, as a world point, from the clip's own pixels. Move it in the art
// and the reveal moves with it β the same deal keptAnchor and handPivot get.
//
// No fallback. A missing doorAt used to quietly default to 140px above the
// ground, which happened to be the exact value the manifest asks for β so the
// field was not being read at all and nothing showed it. Throwing is the same
// guard tintFrames got after it silently painted the previous colour.
if (houseClip && !houseClip.doorAt) {
throw new Error('clip "houses" has no doorAt: nothing would say where the door is');
}
const doorOffset = houseClip
? [houseClip.doorAt[0] - houseClip.pivotX, houseClip.doorAt[1] - houseClip.pivotY]
: [0, 0];
const doorPoint = (h) => [h.x + doorOffset[0], GROUND_Y + doorOffset[1]];
/**
* The middle of the letter beside that door, as a world point. It never
* comes down, so this is always where it hangs.
*
* Straight out of the clip's own pixels, the same deal `doorAt` gets: move it
* in the art and the thing the torch has to cover moves with it.
*/
const letterPoint = (h) => {
const r = houseClip?.letterAt?.[h.variant];
if (!r) return null;
return [h.x - houseClip.pivotX + r[0] + r[2] / 2,
GROUND_Y - houseClip.pivotY + r[1] + r[3] / 2];
};
/**
* And the middle of the slot under it: the second nail, the lighter patch
* where their own paper hung, and the page he pins back into it. Same column
* as the letter, so the reach to either is the same reach.
*/
const slotPoint = (h) => {
const r = houseClip?.slotAt?.[h.variant];
if (!r) return null;
return [h.x - houseClip.pivotX + r[0] + r[2] / 2,
GROUND_Y - houseClip.pivotY + r[1] + r[3] / 2];
};
/**
* The middle of the key on its hook, as a world point. Same deal doorAt and
* letterAt get: move it in the art and what the player has to walk to moves
* with it.
*/
const keyPoint = (h) => {
const r = houseClip?.keyAt?.[h.variant];
if (!r) return null;
return [h.x - houseClip.pivotX + r[0] + r[2] / 2,
GROUND_Y - houseClip.pivotY + r[1] + r[3] / 2];
};
// The dark pass wants them at the watcher value, not black β see the note
// above. Tinted once here rather than per frame, like the watchers are.
const houseArt = houseClip
? await tintFrames(houseClip, PALETTE.WATCHERS, 'dark')
: null;
// --- holes the shape of what goes in them --------------------------------
//
// The patch beside a door was a rectangle and the gate's sockets were
// rectangles, and a rectangle says "a place" without saying what for. Cut
// them to the shape of the thing instead and they say both at once, with no
// words and no prompt: a torn page fits in a torn-page-shaped mark, a key
// fits in a key-shaped hole. It is the oldest trick there is and this level
// had been asking its question in the vaguest possible shape.
//
// TINTED ONCE AT LOAD, off the real art, so the hole and the thing can never
// drift apart β the same treatment the houses and the watchers get. All five
// torn pages share one contour by design and the silhouette pass throws the
// scribble away, so frame 0 is every fragment's outline.
const PATCH_HEX = '#6f6553'; // lum 101 β the cleaner wall. See render.js
const patchArt = clips.kept
? (await tintFrames(clips.kept, PATCH_HEX, 'dark'))[0] : null;
const socketArt = clips.key
? (await tintFrames(clips.key, PALETTE.WATCHERS, 'dark'))[0] : null;
// --- the tree on the road ------------------------------------------------
// A rebel nailed the rule to it: they cannot speak, what they carried is all
// that is left of them, take it home. Never an answer β not which ones, not
// which home. It reads like a house's letter in every way the code cares
// about: light it and the band reads it, and after that standing by it
// does. It stays on the tree, the same as every letter stays on its door,
// and it is not a door β no slot, and nothing is ever pinned back to it.
const treeClip = clips.road_tree ?? null;
const TREE_NOTE = letters?.byHouse?.findIndex((h) => h.origin === 'tree') ?? -1;
const TREE = treeClip && TREE_NOTE >= 0
? { kind: 'tree', x: TREE_X, index: TREE_NOTE, variant: 0, seen: false }
: null;
/** The middle of the note on the tree, as a world point. Off the clip's pixels. */
const treePoint = () => {
const r = treeClip?.letterAt?.[0];
if (!TREE || !r) return null;
return [TREE.x - treeClip.pivotX + r[0] + r[2] / 2,
GROUND_Y - treeClip.pivotY + r[1] + r[3] / 2];
};
// PALE IN THE DARK. A house letter has a lit doorway beside it to draw the
// eye; a note on a lone tree has nothing, and would be walked past. So the
// scrap shows as paper catching what light there is β enough to say
// "something is pinned here", never enough to read. Above the no-mud floor.
const NOTE_IN_THE_DARK = '#9a9484'; // lum 148
const treeNoteDark = TREE && clips.letter
? (await tintFrames(clips.letter, NOTE_IN_THE_DARK, 'dark'))[TREE_NOTE] ?? null : null;
// --- the twelve --------------------------------------------------------
// Twelve shapes you can swing at, against the background watchers you cannot.
// Two populations doing different jobs: the watchers make it feel like a
// village and cost nothing, and these are what you walk to.
//
// Eleven stand out in the village and the twelfth is the gatekeeper, at his
// post sixty years after anyone relieved him.
//
// V1 CARRIES NOTHING. The four carriers, the loops and the one-in-three
// ratio that makes the search a search are V4; what V1 is for is the size of
// the room, and objects in it would only be something to look at instead.
//
// THE DOORS ARE PLACED FIRST AND THE SHAPES GO IN WHAT IS LEFT.
//
// Holding the torch freezes him and takes his sword away, so a shape inside
// the light's reach of a door means looking at that door wakes something with
// nowhere to back off to. The first village scattered shapes evenly and
// checked afterwards: all five doors came out unlookable, one of them with a
// Drained 59px away. So the clear pockets are cut out of the street FIRST,
// and the eleven are spread evenly through the ground that remains.
//
// Even in FREE-SPACE coordinates, not world ones. Walking a straight line
// through the gaps and dropping a shape every so often is what keeps them
// from bunching up in whichever gap happened to be largest β which is exactly
// how the per-block version put three of them 591px apart.
const free = (() => {
const out = [];
let at = BOWL.X0 + 400;
for (const h of HOUSES) {
if (h.x - CLEAR > at) out.push([at, h.x - CLEAR]);
at = Math.max(at, h.x + CLEAR);
}
// Short of the gate: the gatekeeper's ground is his, and a shape loitering
// there would be in the fight that is supposed to be his alone.
if (GATE_STOP_X - CLEAR > at) out.push([at, GATE_STOP_X - CLEAR]);
return out;
})();
const FREE_LEN = free.reduce((n, [a, b]) => n + (b - a), 0);
/** A distance along the free ground, as a world x. */
const atFree = (u) => {
let left = u;
for (const [a, b] of free) {
if (left <= b - a) return a + left;
left -= b - a;
}
return free[free.length - 1][1];
};
// WHO IS DOING WHAT, and it is a fixed pattern rather than a roll.
//
// Three motions are out here β the gatekeeper is the fourth carrier and he is
// at the gate. Each motion is done by three shapes: one carrying, two not. The
// last two do nothing in particular, which is what gives the loop skill
// something to correctly rule OUT. That is how a skill gets taught without
// being taught.
//
// hammer 1 carrier + 2 decoys yoke 1 carrier + 2 decoys
// peel 1 carrier + 2 decoys nothing 2 decoys
//
// Eleven. Four fights if reading outlines has landed, twelve if it has not,
// and that spread IS what V4 measures.
// WHO DOES WHAT, AND THE LOOP IS A LAST MOMENT.
//
// The drained are still doing whatever they were doing when she took them.
// That is not a life: a blacksmith swings a hammer for thirty years, and on
// the morning she came he was hauling water because his brother had a fever.
// The house letter says who he was, the loop says what he was doing at the
// end, and the gap between the two is the deduction.
//
// swinging 3 shapes, and NONE of them carries. The swinger is bait, and
// the forge letter names this motion on purpose
// hauling 3 shapes, one carrying β the forge's own fragment
// bearing 3 shapes, two carrying β the bakehouse and the well house,
// who were both doing the same thing that morning
// wandering 2 shapes doing nothing anybody wrote down
//
// THE LAST TWO ARE THE NEGATIVE CONTROL. Without something the loop skill can
// correctly rule OUT, every shape is worth a fight and watching is
// decoration. They wander rather than stand, because standing reads as the
// gatekeeper or as a watcher β their motion has to be legibly not a trade.
const ROLES = [
{ loop: 'swing' }, { loop: 'swing' }, { loop: 'swing' },
{ loop: 'haul' }, { loop: 'haul' }, { loop: 'haul' },
{ loop: 'bear' }, { loop: 'bear' }, { loop: 'bear' },
{ roams: true }, { roams: true },
];
const BOWL_N = ROLES.length;
// The furthest a dropped object may land and still be pickable. Handed to
// every Drained, because the one that got this wrong was unreachable by
// 375px and the run simply could not be finished.
const KEPT_MAX_X = GATE_STOP_X - TUNING.PICKUP_REACH * 0.6;
const village = [];
const SLOTS = [];
/**
* How far a wanderer at x may pace without ever entering a door's pocket.
*
* Bounded PER DIRECTION. Taking the nearest house in any direction and
* applying it both ways leaves the far side under-constrained, and measuring
* across a dozen seeds it came within 2px of the clearance β passing, but on
* nothing. The house on the left decides how far left it goes.
*/
const roamRoom = (x) => {
const left = HOUSES.filter((h) => h.x < x).map((h) => x - h.x - CLEAR);
const right = HOUSES.filter((h) => h.x >= x).map((h) => h.x - x - CLEAR);
return Math.max(0, Math.min(
(FREE_LEN / BOWL_N) * 0.4,
...(left.length ? [Math.min(...left)] : []),
...(right.length ? [Math.min(...right)] : []),
));
};
{
const slot = FREE_LEN / BOWL_N;
for (let i = 0; i < BOWL_N; i++) {
// The slots themselves are WORLD-seeded and never move: their clear
// pockets round each door and their spacing floor are what V1 bought.
const u = slot * (i + 0.5) + (hash01(i, 0xb0a1) - 0.5) * slot * 0.2;
SLOTS.push(Math.round(atFree(u)));
}
ROLES.forEach((role, i) => {
village.push(createDrained(clips, SLOTS[i], hash01(i, 0xb0a2) < 0.5 ? -1 : 1, i,
{ standing: !role.roams, stayDown: true, loop: role.loop ?? null,
kept: null, keptMaxX: KEPT_MAX_X,
// A wanderer stays in the open ground it was placed in. Let one drift
// and it walks into a door's clear pocket, and lighting that door
// wakes it β which is the one thing V1's placement bought.
//
// BOUNDED BY THE DOOR, NOT ONLY BY THE SLOT. Half a slot is 370px,
// and a slot only has to be BOWL_CLEAR from a door to be legal β so
// a wanderer in the tightest slot could pace 370 toward the door and
// end up well inside the pocket. Which slot a wanderer lands in
// varies per run, so this only started happening when roles began
// moving between slots.
roam: role.roams ? roamRoom(SLOTS[i]) : 0 }));
});
// The gatekeeper. Inside the opening, against the shut leaf, and within the
// prince's 175px reach of where the gate stops him. He never leaves.
village.push(createDrained(clips, GATE_X - 230, -1, BOWL_N,
{ standing: true, stayDown: true, post: true, kept: HOUSE_OF.indexOf('the gatehouse'),
keptMaxX: KEPT_MAX_X }));
}
// Whether his post is empty: walking home, or home. Nothing else takes him
// off it β a fight leaves him on the ground where he stood.
const GATEKEEPER = village[village.length - 1];
const keeperGone = () => GATEKEEPER.state === HOMING || GATEKEEPER.state === HOME;
/**
* THE DEAL, AND IT IS A CONSTRAINED SHUFFLE.
*
* The rules are the authored part of the level. Which specific body satisfies
* one is not, and it is re-rolled every run so that four friends comparing
* notes cannot say "the blacksmith is third from the left".
*
* It buys that and not a second puzzle: a player who remembers that the forge
* letter subverts has spent the deduction, and this makes them fight again
* rather than think again.
*/
function dealVillage(seed) {
const rnd = mulberry32(seed >>> 0);
const shuffled = (list) => list.map((v) => [v, rnd()])
.sort((a, b) => a[1] - b[1]).map((v) => v[0]);
// --- which body stands in which slot ---------------------------------
// Dealt so NO TWO NEIGHBOURS SHARE A MOTION. A clump of three identical
// loops hands the player a spatial tell, and the point is that narrowing by
// motion leaves candidates spread out with nothing to choose between them.
const pool = new Map();
ROLES.forEach((r, i) => {
const key = r.loop ?? '-';
if (!pool.has(key)) pool.set(key, []);
pool.get(key).push(i);
});
for (const [k, list] of pool) pool.set(k, shuffled(list));
const order = [];
let last = null;
for (let n = 0; n < BOWL_N; n++) {
// Largest group first so nothing is left stranded at the end, and TIES
// BROKEN AT RANDOM. Without the tie-break the greedy rule is fully
// determined by group sizes, so every run laid the village out in the
// same order of motions β swing, haul, bear, swing, haul, bear β and
// "the fifth shape is a hauler" survived the shuffle intact.
let best = null;
let bestN = -1;
for (const k of shuffled([...pool.keys()])) {
const arr = pool.get(k);
if (!arr.length || (k === last && pool.size > 1)) continue;
if (arr.length > bestN) { best = k; bestN = arr.length; }
}
if (!best) best = [...pool.keys()].find((k) => pool.get(k).length);
order.push(pool.get(best).shift());
if (!pool.get(best).length) pool.delete(best);
last = best;
}
order.forEach((body, slotIndex) => {
const x = SLOTS[slotIndex];
village[body].placeAt(x, roamRoom(x));
});
// --- who is carrying what --------------------------------------------
for (let i = 0; i < BOWL_N; i++) village[i].setKept(null);
const byLoop = (l) => shuffled(ROLES.map((r, i) => (r.loop === l ? i : -1)).filter((i) => i >= 0));
// The forge's fragment goes on a HAULER, never a swinger. The whole
// subversion rests on it: read "I could hear you at the anvil", walk to the
// swinging shape, and nothing falls out of it.
village[byLoop('haul')[0]].setKept(HOUSE_OF.indexOf('the forge'));
// The bakehouse and the well house were both walking somewhere with
// something that morning, so both go on BEARERS. Two of the three.
const bearers = byLoop('bear');
const pair = shuffled([HOUSE_OF.indexOf('the bakehouse'), HOUSE_OF.indexOf('the well house')]);
village[bearers[0]].setKept(pair[0]);
village[bearers[1]].setKept(pair[1]);
// Home gets nothing, anywhere. That is the residue.
// The gatekeeper keeps his own and is never re-dealt.
}
dealVillage(SEED);
// What the layout actually produced, which is what V1 is judging and is NOT
// the bowl width over twelve β the clear pockets around the doors eat it.
const VILLAGE_GAP = (() => {
const xs = village.map((v) => v.x).sort((a, b) => a - b);
let sum = 0;
for (let i = 1; i < xs.length; i++) sum += xs[i] - xs[i - 1];
return Math.round(sum / (xs.length - 1));
})();
// One list. The village shapes are Drained like any other: the same three
// hits, the same stagger, the same rules about being looked at.
drained.push(...village);
// The loss beat. No text, no shake, no sting β the light going out is the
// whole thing.
// The DEATH beat now, not the loss beat. Being caught costs a hit; the sixth
// one is this, and it restarts the level.
const loss = { active: false, timer: 0, torchWasUp: false, x: 0, y: 0, vx: 0, r: 0, by: null };
let grace = 0;
/**
* Caught. Knocked back, a moment to get clear, and one less.
*
* WANDER contact is still harmless β the whole level argues that nothing is
* hostile until it is looked at, and a man standing in the dark doing nothing
* has to stay a man standing in the dark doing nothing.
*/
function takeHit(fromX, knock) {
if (grace > 0 || loss.active) return;
prince.hits++;
run.hitsTaken++;
prince.x += (Math.sign(prince.x - fromX) || -1) * knock;
prince.prevX = prince.x;
prince.attacking = false;
grace = TUNING.HURT_GRACE_MS;
audio?.hit();
if (prince.hits < TUNING.PRINCE_HITS) return;
// The sixth. The light goes out and the level starts again -- and nobody
// goes on reading to a dead man.
audio?.stopVoice?.();
voice.started = false;
loss.active = true;
loss.timer = 0;
prince.down.reset();
loss.torchWasUp = torch.raise > 0.01;
loss.x = torch.handX;
loss.y = torch.handY;
loss.vx = prince.facing * 120;
loss.r = torch.radius;
audio?.bodyFall();
}
// THE MAP. Not a screen β a sheet he holds up in his own torchlight. It
// records and it never reasons: it knows what he has looked at and nothing
// else, and the player never does a moment's bookkeeping.
// THE GATE. Shut, with four empty marks on it, and it does not open until he
// is carrying four objects he has LOOKED at. Not four objects β take them
// blind and he is holding four question marks and the gate stays shut.
//
// He has to look. That is the level.
const gate = { filled: 0, open: 0, through: false, placed: 0 };
// THE SKY, when somebody gets their name back. `ms` counts up through the
// strike and `thundered` makes sure the rumble fires once rather than every
// frame the flash is still running. See FLASH in tuning.js for why it is a
// flash and not a sound.
const flash = { ms: 0, thundered: true };
/**
* THE SHEAF. Loose pages he carries, and the map is one of them.
*
* Built here at L2 and paged at L3. What arrives when:
* his orders at the start, and permanently page one
* a fragment when he takes it
* a key when he takes it off its hook
*
* NO HOUSE LETTERS AND NO NOTE. Those stay where they were left β on the
* door and on the tree β and the band reads them there.
*
* FOUND ORDER. Not grouped, not sorted, no index and no tabs β the order he
* picked them up in is the only order there is.
*/
// Last frame's take button, so an act fires on the edge. See where it is read.
let prevPickup = false;
// How long he has stood still, in ms. The band reads a door he has lit again
// once this passes READ_STILL_MS. See the band.
let still = 0;
// Whether he wanted the light last frame, so the flame fires on the raise
// and not on every frame of holding it.
let litLast = false;
const sheaf = {
// NO MAP PAGE. It was page one from V3 until somebody played it and said
// "I thought the first page was just the inventory index of stuff β I did
// not realise it was a map." Which is the whole verdict on it: the sheet
// that the V3 gate was built around never read as a map to anybody.
//
// So it is what it was being read as. A pile of what he is carrying, in the
// order he found it, and nothing else. A real map that draws itself as he
// solves things is a good idea and a separate one.
pages: [],
index: 0,
// Whether he is holding it up. Lived on its own `map` object until the name
// stopped being true.
up: false,
// Edge and repeat for the arrows. A turn is a FLIP WITH A BEAT, not a free
// browse: it fires on the press, then not again until REPEAT_MS, so holding
// an arrow walks the pile at reading speed rather than spinning it.
heldDir: 0,
repeat: 0,
// Counts up on every turn. The band watches it rather than watching the
// text, because paging from one page to another with the SAME words has to
// still read as a turn.
turns: 0,
};
const SHEAF_REPEAT_MS = 260;
/**
* THE OPENING. He is reading his orders when the game starts.
*
* Not a splash and not narration: the page is in his hand, held up in the
* light, in the exact posture the player will use for the whole game -- shown
* before they know there is a button for it.
*/
// `heard`: his orders have started being read aloud. `clear`: every key has
// been let go since. `askedAt`: when the first press asked for the reading,
// in opening ms, or -1. See the opening in update().
const opening = { ms: 0, done: false, heard: false, clear: false, askedAt: -1 };
/**
* What `update` is handed while the beat runs, INSTEAD of the real input.
*
* The death beat owns its frame with a bare `return` placed above every
* input read. Copying that here would freeze the fire: it returns above
* `state.clock += dt` and above the flame player, which cost nothing when
* the torch was four SVG frames and costs everything now that it is a
* 25-frame loop and the only colour on screen. Four seconds of a frozen
* flame at the front of the game is worse than no opening.
*
* So nothing is skipped. The rest of update runs on these flags instead: the
* sheaf goes up, the torch stays up, the band shows the order, the pose swaps
* to the map pose, the clock advances and the fire burns -- and he does not
* move, because left and right are false. "No input is accepted" ends up
* literally true rather than approximately true.
*/
// NOT FROZEN, AND THAT COST A CRASH. The scene clears `input.pickup = false`
// further down, every frame -- the edge detector was deliberately moved in
// here so it would not trust the input layer. Object.freeze turns that write
// into a TypeError under a module's strict mode, so the whole update died
// mid-frame on the first frame of the game. It is a plain object because
// something legitimately writes to it.
const OPENING_INPUT = {
torch: true, sheaf: true, attack: false, pickup: false, left: false, right: false,
};
/**
* Arm it. Sets the raise to 1 rather than letting it ramp: RAISE_MS is 250
* and the band needs raise > 0.9, so coming up through the input path gives
* a quarter of a second of a page rising into view with no legible word on
* it. The spec says he is ALREADY holding it up, so he is.
*/
const armOpening = () => {
opening.ms = 0;
opening.done = false;
opening.heard = false;
opening.clear = false;
opening.askedAt = -1;
torch.raise = 1;
torch.prevRaise = 1;
};
const page = () => sheaf.pages[sheaf.index] ?? null;
/**
* Has he looked at this page's contents? The one letter he carries is his
* orders, which he is reading when the game starts. A FRAGMENT CAN BE TAKEN
* BLIND and arrives unknown, and the whole gate at the end of the level
* turns on that difference.
*/
const pageSeen = (pg) => {
// A letter was read off a lit door and a key has nothing written on it, so
// only a torn page can be a thing he owns without having looked at it.
if (!pg || pg.kind !== 'fragment') return true;
return !!prince.inventory.find((it) => it.kept === pg.kept)?.seen;
};
/** Where this page is filed, if it is a fragment and he has filed it. */
const filedUnder = (pg) => {
if (!pg || pg.kind !== 'fragment') return null;
return HOUSES.find((h) => h.filed === pg.kept) ?? null;
};
const textFor = (pg) => {
if (!letters || !pg) return null;
const h = letters.byHouse?.[pg.house ?? pg.kept];
if (!h) return null;
// A KEY HAS NOTHING WRITTEN ON IT, so the band stays empty on one β which
// is the difference between a key and a letter, shown rather than said.
// (This used to say "a map", back when page zero was one.)
return pg.kind === 'letter' ? h.letter : pg.kind === 'fragment' ? h.fragment : null;
};
/**
* Put a page on the pile and open on it.
*
* FOUND ORDER means it goes on the end. Moving the index to it is what a
* person does with a handful of papers β the new one is the one in your
* hand β and it is what makes pinning one gesture instead of two: take a
* fragment off a body, walk to a door, press E, and it is already the page
* he is holding.
*/
const filePage = (pg) => {
sheaf.pages.push(pg);
sheaf.index = sheaf.pages.length - 1;
sheaf.turns++;
};
/**
* HIS ORDERS. Page one, from before the first frame, and permanently.
*
* The pile used to start empty and a check asserted it, under a comment
* explaining that he begins carrying nothing. That was true of a pile of
* things he had FOUND. This is the one page he did not find: it is what he
* was handed at the palace and what sent him down this road, and by the end
* of the village it sits under four fragments that, with the letters on the
* doors they answer, contradict every word of it. Nothing has to say so.
*
* IT IS AN ORDINARY LETTER PAGE. Not its own kind: it is a page of writing
* with a building behind it, which is what every letter in this game is, and
* making it a fourth kind meant a branch in textFor, a branch in the hand,
* and a row in every check that counts kinds -- all to say "paper" twice.
* Its byHouse entry carries `origin: 'palace'`, and the index is FOUND by
* that rather than counted to, so nobody has to keep a 5 in their head.
*
* Through filePage rather than a bare push, so it bumps `turns` -- which is
* what zeroes band.alpha and gives the opening its fade-in for free.
*
* CALLED TWICE, because reset() is not called at construction; the state
* literals are the initial state and only camera.reset runs down there.
*/
const ORDER_HOUSE = letters?.byHouse?.findIndex((h) => h.origin === 'palace') ?? -1;
const seedOrder = () => { if (ORDER_HOUSE >= 0) filePage({ kind: 'letter', house: ORDER_HOUSE }); };
/**
* THE VOICES. Every paper is read aloud by whoever wrote it: Mira reads her
* letter, the smith his own last words, the palace its order. The band still
* shows the document; the voice is the same document, word for word, and
* never a voice that describes one.
*
* Named by what they read -- `letter 0` is the forge's letter, `fragment 0`
* the smith's scrap -- so a voice is found the same way the band's text is.
*/
const voiceOf = (kind, i) => {
const h = letters?.byHouse?.[i];
return (kind === 'letter' ? h?.letterVoice : h?.fragmentVoice) ? `${kind} ${i}` : null;
};
letters?.byHouse?.forEach((h, i) => {
if (h.letterVoice) audio?.useVoice?.(`letter ${i}`, { file: h.letterVoice });
if (h.fragmentVoice) audio?.useVoice?.(`fragment ${i}`, { file: h.fragmentVoice });
});
const ORDERS_VOICE = voiceOf('letter', ORDER_HOUSE);
// Which document the voice belongs to (the band's key), how long it has been
// up, and whether its voice has started.
const voice = { key: null, ms: 0, started: false };
/**
* What the band is showing, and it shows the document rather than describing
* it: the page he is holding up, or the letter on the wall in front of him.
*
* `key` says WHICH document, so the band can blank between two of them β
* a page turn, or looking from the door to his papers and back. See the band.
*/
const band = { text: null, alpha: 0, key: null };
/**
* WHAT THIS BUILD IS FOR, counted.
*
* The question health is being added to answer is one thing: does he start
* standing still and looking at something before walking up to it? So the
* run keeps score of the only number that answers it β seconds spent with
* the torch up, standing still, with nothing hostile near enough to reach
* him. Watching from safety.
*
* Everything else here is context for that one figure.
*/
const run = { seconds: 0, fights: 0, hitsTaken: 0, deaths: 0, watching: 0, taken: 0 };
const WATCH_CLEAR_PX = 600;
// Whether the sheaf is up. A dwell timer used to live here and resolved the
// oldest unknown thing in his bag; pages replaced it, because what he
// resolves is the one he turned to.
const map = { up: false };
let lastRunFrame = -1; // so a step fires once per arrival, not once per tick
const state = {
prince, torch, camera, background, drained, loss,
get grace() { return grace; },
audio,
ground: clips.ground,
kept: clips.kept,
letterArt: clips.letter,
keyArt: clips.key,
patchArt,
socketArt,
lightning: clips.lightning,
pageBlank: clips.page_blank,
sheetClip, mapClip, hurtClip, run, sheaf, band, textFor, letters, page, pageSeen, filedUnder,
canMark: false,
markHouse: null,
canTakeKey: false,
canPlaceKey: false,
// Whether there is anything at his feet to pick up. Computed once here
// rather than re-scanned by main.js to decide what the button says.
canTake: false,
seed: SEED,
houses: HOUSES, houseTint: PALETTE.WATCHERS,
houseClip, palaceClip, houseArt, doorPoint, keyPoint, letterPoint, slotPoint,
tree: TREE, treeClip, treeNoteDark, treePoint,
// What the band is reading off a wall right now, if anything: the house or
// the tree. Published for the checks, which ask whether it is the same
// door E would act on.
readingAt: null,
// The voice that should be reading right now, if any: `letter 0`,
// `fragment 2`. Set whether or not sound can play, so the checks can see
// the decision.
voice: null,
// The house E would act on, if any. Same door the band reads.
nearDoor: null,
village, gateX: GATE_X, gateStopX: GATE_STOP_X, gateRoomX: GATE_ROOM_X,
villageGap: VILLAGE_GAP, bowlX0: BOWL.X0,
flame: { clip: clips.fire, player: createPlayer(clips.fire), frame: 0 },
torchArm: TORCH_ARM,
clock: 0,
gate,
opening,
fade: 0,
};
/**
* @param seed re-deals the village. Checks pass one so a run reproduces;
* a player's reset keeps the seed the run started with.
*/
function reset(seed = null) {
if (seed !== null) { state.seed = seed >>> 0; dealVillage(state.seed); }
prince.x = prince.prevX = START_X;
prince.facing = 1;
prince.hasSword = true;
prince.moving = false;
prince.hits = 0;
prince.keys = 0;
prince.run.reset();
prince.idle.reset();
torch.reset();
camera.reset(START_X);
state.clock = 0;
for (const h of HOUSES) {
h.seen = false; h.filed = null;
h.open = false; h.settled = false; h.keyTaken = false;
}
if (TREE) TREE.seen = false;
still = 0;
state.readingAt = null;
state.nearDoor = null;
audio?.stopVoice?.();
voice.key = null; voice.ms = 0; voice.started = false;
state.voice = null;
prevPickup = false;
prince.attacking = false;
prince.attackTime = 0;
prince.attackConnected = false;
loss.active = false;
loss.timer = 0;
loss.by = null;
grace = 0;
sheaf.up = false;
gate.filled = 0;
gate.open = 0;
gate.through = false;
gate.placed = 0;
flash.ms = FLASH.MS;
flash.thundered = true;
state.flash = 0;
state.flashMs = -1;
sheaf.pages.length = 0;
sheaf.index = 0;
sheaf.heldDir = 0;
sheaf.repeat = 0;
sheaf.turns = 0;
band.text = null;
band.alpha = 0;
band.key = null;
state.fade = 0;
// Deaths survive a restart. Everything else about the run does not β that
// is what "the findings are at stake" means.
run.seconds = 0; run.fights = 0; run.hitsTaken = 0; run.watching = 0; run.taken = 0;
lastRunFrame = -1;
prince.inventory.length = 0;
drained.forEach((d) => d.reset());
// His orders go back on the pile, because they are the one thing he cannot
// lose. After the clears above, so they are not what gets cleared.
seedOrder();
// AND THE OPENING DOES NOT PLAY AGAIN. reset() is what R calls and what
// the death beat ends with, and four seconds of re-reading an order you
// have read is a punishment for dying. Parked finished, which is the same
// idiom the storm is parked with six lines up.
opening.done = true;
}
seedOrder();
armOpening();
camera.reset(START_X);
function update(dt, input) {
prince.prevX = prince.x;
// Where he started this frame, for "is he standing still". Not prevX: a
// hit and a shove both reset that to stop the draw smearing, so a knock
// would look like standing still.
const xFrom = prince.x;
torch.snapshot();
camera.snapshot();
state.clock += dt;
// --- the opening ------------------------------------------------------
// He reads his orders, and then the game begins. The real input is looked
// at exactly once, to see whether to cut it short; after that the frame
// runs on OPENING_INPUT and he is holding the page up whatever anybody
// presses. See the note on OPENING_INPUT for why this is not a `return`.
if (!opening.done) {
// SKIPPABLE, BY ANY OF THE SIX. A player who has seen it does not have
// to sit through it, and one who has not will not know to press anything.
const pressed = input.torch || input.sheaf || input.attack ||
input.pickup || input.left || input.right;
let skipped = pressed;
// BUT THE FIRST PRESS STARTS THE READING. A browser makes no sound before
// a press, so if the first one also put the orders away nobody would
// ever hear them read. So while his orders could be read aloud and are
// not yet, a press asks for the voice and the page stays up; once it is
// speaking, the NEXT press ends it -- a key still held from the first
// does not. If the voice has not come VOICE_WAIT_MS after it was asked
// for, a press ends it as before. With sound off, nothing has changed.
if (ORDERS_VOICE && audio?.canSpeak?.(ORDERS_VOICE)) {
if (!opening.heard) {
if (pressed && opening.askedAt < 0) opening.askedAt = opening.ms;
skipped = pressed && opening.askedAt >= 0 &&
opening.ms - opening.askedAt >= TUNING.VOICE_WAIT_MS;
} else {
if (!pressed) opening.clear = true;
skipped = pressed && opening.clear;
}
}
opening.ms += dt * 1000;
// IT WAITS FOR HIM. The spec asked for a four-second hold that any input
// could cut short; holding until a key is pressed is the same thing with
// the impatient case removed, and it lets somebody read at their own
// speed instead of at mine. ORDER_HOLD_MS is an upper bound for anyone
// who wants one -- 0, the default, means no bound at all.
const rangOut = TUNING.ORDER_HOLD_MS > 0 && opening.ms >= TUNING.ORDER_HOLD_MS;
if (skipped || rangOut) {
opening.done = true;
// AND THE PRESS THAT SKIPPED DOES NOT ALSO SWING. attack and pickup
// are one-shots the SCENE clears, further down; the frames above ran
// on a different object, so the real one has never been cleared and
// the key that ended the opening would land as a sword swing on the
// frame control comes back. The loss beat has the same hole and never
// has to care, because it ends in reset().
input.attack = false;
input.pickup = false;
} else {
// A press the opening spent is spent: a swing or a take still set on
// the real input would count as held for ever, and the next press
// could never arrive.
input.attack = false;
input.pickup = false;
input = OPENING_INPUT;
}
}
// --- the loss beat ----------------------------------------------------
// Everything stops. If the torch was up it leaves his hand, falls, rolls
// forward and goes out; the circle shrinks as it rolls.
if (loss.active) {
loss.timer += dt * 1000;
// He goes down. The clip plays once and holds on the last frame, which is
// what the light goes out over.
prince.down.advance(dt);
prince.clip = downClip;
prince.frame = prince.down.index;
// All the way out, over the clip. The last thing on screen before the
// level starts again is the light leaving him.
prince.eyeDim = Math.min(1, 0.6 + loss.timer / 900);
if (loss.torchWasUp) {
loss.vx += 260 * dt;
loss.x += loss.vx * dt;
loss.y = Math.min(GROUND_Y - 20, loss.y + 900 * dt * Math.min(1, loss.timer / 260));
loss.r = Math.max(0, loss.r - 300 * dt);
torch.cx = loss.x;
torch.cy = loss.y;
torch.radius = loss.r;
}
// THE LEVEL RESTARTS. He does not get up where he fell any more.
//
// The spec's checkpoint at the mouth keeps the map, and a checkpoint that
// keeps the findings is too forgiving to change how anybody plays. The
// findings have to be at stake or the fights are not a cost.
state.fade = Math.min(1, (loss.timer - TUNING.LOSS_HOLD_MS) / TUNING.DEATH_FADE_MS);
if (loss.timer >= TUNING.LOSS_HOLD_MS + TUNING.DEATH_FADE_MS) { run.deaths++; reset(); }
return;
}
// --- Torch ------------------------------------------------------------
// The whole swing is a commitment. No cancelling into the torch, and no
// cancelling into another swing.
const committed = prince.attacking;
// Holding the map raises the torch. One button, and the cost is the torch's
// cost exactly: he stops, the sword goes away, and he is lit up in the dark.
sheaf.up = !!input.sheaf && !committed && !!mapClip;
// FLAME AND AIR, ON THE WAY UP ONLY. Lowering it is him giving up the
// light, which is a thing that should happen quietly.
const wantsLight = (!!input.torch || sheaf.up) && !committed;
litLast = wantsLight;
torch.update(dt, wantsLight, state.clock,
prince.x, GROUND_Y, prince.facing, HAND_DX, HAND_DY, TORCH_ARM, torchClip.fireCentre, TORCH_HANDS);
prince.hasSword = torch.raise <= 0.001;
// The crackle rides the raise, so it arrives with the light and leaves
// with it. A loop rather than a one-shot on the press: a torch is not an
// event, it is a thing that is burning.
audio?.torchFire?.(torch.raise);
// THE LIGHT IN HIM, every frame he is still up. Capped short of black so a
// man on his last hit still has something in his face β going fully out is
// what dying looks like, and it must not be what nearly-dying looks like.
// SPREAD ACROSS THE HITS, not clamped at the end. Clamping made four hits
// and five hits the same picture, which loses the one step that matters
// most β the last one before the restart.
prince.eyeDim = TUNING.EYE_DIM_MAX
* Math.min(1, prince.hits / Math.max(1, TUNING.PRINCE_HITS - 1));
// --- Movement ---------------------------------------------------------
// Torch up means he cannot move. To look, he stops. To move on, he stops
// looking. Every second spent understanding something is a second not spent
// leaving.
if (grace > 0) grace -= dt * 1000;
const wantDir = (input.right ? 1 : 0) - (input.left ? 1 : 0);
const canMove = prince.hasSword && !committed;
prince.moving = canMove && wantDir !== 0;
if (prince.moving) {
prince.facing = wantDir;
prince.x += wantDir * TUNING.WALK_SPEED * dt;
if (prince.x < 0) prince.x = 0;
// He does not hit a boundary, he hits a shut gate. Once it is up, the
// only thing left in his way is the man standing in it.
//
// AND THE GATEWAY IS HIS ONCE ITS KEEPER HAS GONE HOME. While the keeper
// holds the post, the shut leaf stops him just inside the left post.
// With the post empty he can walk the whole width of the leaf, under all
// four sockets, to just inside the right one. Found in play: he stood
// at the left edge and could not get close enough to see how many
// sockets the gate wanted.
const shut = keeperGone() ? GATE_ROOM_X : GATE_STOP_X;
const stop = gate.open >= 1 ? Math.max(GATE_X + 260, shut) : shut;
if (prince.x > stop) prince.x = stop;
}
camera.setBounds(0, keeperGone() ? GATE_ROOM_CAM_X : GATE_CAM_X);
camera.follow(prince.x);
// --- The sword --------------------------------------------------------
if (input.attack) {
input.attack = false;
if (prince.hasSword && !prince.attacking) {
prince.attacking = true;
prince.attackTime = 0;
prince.attackConnected = false;
audio?.swing();
}
}
if (prince.attacking) {
// THE SWING SLOWS. A cost he feels in the one thing he was using to make
// the cost not matter.
const slow = TUNING.SWING_SLOW[Math.min(prince.hits, TUNING.SWING_SLOW.length - 1)];
prince.attackTime += dt / slow;
const ms = prince.attackTime * 1000;
const active = ms >= TUNING.ATTACK_WINDUP_MS &&
ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS;
// One hitbox, one target, no multi-hit.
if (active && !prince.attackConnected) {
for (const d of drained) {
// THE SWORD SWINGS ALONG THE ROAD. A figure in the back lane is
// beside it, not on it, so the blade passes in front of it the way it
// would in any beat 'em up β and one halfway through stepping down is
// not there yet. Without this the lane is a picture: swing at a man
// standing beside the road and he wakes, which is the exact fight
// the lane exists to stop anyone starting.
if (d.lane > 0) continue;
const gap = (d.x - prince.x) * prince.facing;
if (gap > -40 && gap <= attackClip.reach) {
prince.attackConnected = true;
d.takeHit(prince.x);
audio?.hit();
break;
}
}
}
if (prince.attackTime >= ATTACK_TOTAL) prince.attacking = false;
}
// --- the Drained ------------------------------------------------------
const ctx = { princeX: prince.x, torch, groundY: GROUND_Y, inFrame: false };
for (const d of drained) {
// Only what is near enough to matter. Downed bodies persist in world
// space, so one can rise and come at him from behind.
//
// EXCEPT ONE ON ITS WAY HOME. This cull is what makes a village of twelve
// cost nothing, and it is exactly wrong for the one body that has
// somewhere to be: skipped here, it never takes a step, so the door it
// was walking to never opens and the key behind it is lost.
//
// ON THE FLAG, NOT THE STATE. Gating on HOMING alone left it culled
// during the 900ms of GETTING UP, which is a state it then never leaves
// β filing correctly from the far side of the village put a body on its
// knees for ever. The flag covers the whole journey; it is cleared when
// the door opens, and the cull takes over again.
//
// AND ONE WOKEN BY A WRONG PIN, for the same reason: it is on its way to
// him, and a body skipped here would sit in the street half risen.
if (!d.homing && !d.woken && Math.abs(d.x - prince.x) > W * 1.4) continue;
// On screen together? The camera follows the prince, so this is the same
// question as "can it see him".
const sx = d.x - camera.x;
ctx.inFrame = sx > -260 && sx < W + 260;
const wasState = d.state;
const wasFrame = d.frame;
const wasRevealed = d.revealed;
const wasLit = d.dwell > 0;
d.update(dt, ctx);
// THE LIGHT LANDS AND IT BREATHES IN. Once per look, and not again for a
// second and a half, or sweeping the torch across it would pant.
// (A clock earlier than the last breath is a restart, not a short gap.)
const sinceBreath = state.clock - (d.stirredAt ?? -9);
if (!wasLit && d.dwell > 0 && !d.revealed && (sinceBreath > 1.5 || sinceBreath < 0)) {
d.stirredAt = state.clock;
audio?.drainedStir?.(Math.max(-0.85, Math.min(0.85, (d.x - prince.x) / 1400)));
}
if (d.state === DOWNED && wasState !== DOWNED) audio?.bodyFall();
// ARRIVED. The door is not open because the torch is on it β that is
// LIT, and it has been true since L2. It is open because the person who
// lived here opened it, and it is the first thing in this level that
// somebody other than the prince does.
if (d.state === HOME && wasState !== HOME) {
const home = HOUSES.find((h) => h.x === d.homeX);
if (home) home.open = true;
}
// One dragged foot when the foot arrives, off the clip's own footFrames,
// exactly as his boots work. Only drained_walk carries any, so a rush is
// silent and a body standing in the street is silent.
//
// ON SCREEN, OR ON ITS WAY HOME. This used to be on screen only β a drag
// out of an empty frame is a thing that is not there. Except that the
// one walking home IS there, a few screens off, and played through a
// whole level nobody could tell it was happening. So that one is heard
// wherever it is, quieter the further off (AUDIO.HOME_FOOT_FAR), and
// every drag is placed on the side it is on.
if (d.frame !== wasFrame && d.clip?.footFrames?.includes(d.frame) &&
(ctx.inFrame || d.state === HOMING)) {
const off = d.x - prince.x;
const beyond = Math.max(0, Math.abs(off) - W / 2);
const near = Math.max(0, 1 - beyond / Math.max(1, AUDIO.HOME_FOOT_REACH));
const level = ctx.inFrame ? 1 : AUDIO.HOME_FOOT_FAR + (1 - AUDIO.HOME_FOOT_FAR) * near;
audio?.drainedStep(level, Math.max(-0.85, Math.min(0.85, off / 1400)));
}
// AND ONE VOICE PER KIND. The clip's own name says which β a swing, a
// haul, a bearing, one coming for him, the gatekeeper winding up β and
// the clip's own soundFrames say when. Nothing here knows what any of
// them sounds like, which is the same deal footFrames already has.
//
// They are BODY sounds and never tool sounds, and audio.js says at
// length why: a hammer-on-anvil clang would send every player to the
// swinging shape, and the forge's fragment is deliberately on a hauler.
//
// AND ONE COMING FOR HIM IS HEARD COMING. Woken by a wrong pin it rushes
// from wherever it fell, so its breathing plays off screen too: faint far
// off, louder as it closes, on the side it is on. The walk home's drag
// does the same, off the same numbers.
if (d.frame !== wasFrame && d.clip?.soundFrames?.includes(d.frame) &&
(ctx.inFrame || (d.woken && d.state === RUSH))) {
const off = d.x - prince.x;
const near = Math.max(0, 1 - Math.max(0, Math.abs(off) - W / 2) / Math.max(1, AUDIO.HOME_FOOT_REACH));
audio?.drainedVoice?.(d.clip.name,
ctx.inFrame ? 1 : AUDIO.HOME_FOOT_FAR + (1 - AUDIO.HOME_FOOT_FAR) * near,
Math.max(-0.85, Math.min(0.85, off / 1400)));
}
// THE INSTANT IT NOTICES HIM. `revealed` latches and never unlatches, so
// this fires once per shape per run β about twelve times, and every one
// of them is a fight he chose by deciding to see.
if (d.revealed && !wasRevealed) audio?.drainedWake?.();
const kind = grace > 0 ? null : d.contactKind(prince.x, CONTACT_REACH);
// Contact with a WANDER one is not a loss. He walks into someone standing
// in the dark and nothing happens at all, which is worse.
if (kind === 'shove' && grace <= 0) {
// He loses ground AND one of his six. He was the only thing in the
// village that could not hurt you, which made the hard fight free.
loss.by = d;
const back = d.x + Math.sign(prince.x - d.x || -1) * (TUNING.POST_REACH + TUNING.POST_SHOVE_PX);
takeHit(d.x, 0);
if (!loss.active) { prince.x = back; prince.prevX = back; }
}
if (kind === 'fatal' && !loss.active) {
loss.by = d;
takeHit(d.x, TUNING.HURT_KNOCK_PX);
}
}
// --- reading it ---------------------------------------------------------
// HE CAN LIGHT WHAT HE IS CARRYING, and this closes a hole rather than
// adding a convenience. The gate wants four objects he has LOOKED at; take
// four blind and the spec sent him back "to light four things on the
// ground", except he took them, so there is nothing on the ground and the
// gate could never open.
//
// At L3 that is no longer a special case at all β a fragment he took is a
// page in the sheaf like any other, and paging to it is the looking.
// --- the sheaf ----------------------------------------------------------
//
// PAGING. Left and right while the sheaf is up. He cannot walk with it
// raised, so those keys are free β and on touch the walk arrows do the same
// job, which is what a person does with a handful of letters: the pile
// under one thumb and the turning under the other.
//
// A FLIP WITH A BEAT. The turn fires on the press and does not fire again
// until SHEAF_REPEAT_MS, so holding an arrow walks the pile at reading
// speed. Free-spinning it would make the sheaf a menu.
//
// IT DOES NOT WRAP. Running off the end of a pile of paper does nothing,
// and wrapping would put the map next to the last fragment for no reason a
// player could see.
if (sheaf.up) {
const dir = (input.right ? 1 : 0) - (input.left ? 1 : 0);
if (dir === 0) { sheaf.heldDir = 0; sheaf.repeat = 0; }
else {
const fresh = dir !== sheaf.heldDir;
if (fresh) { sheaf.heldDir = dir; sheaf.repeat = 0; }
else sheaf.repeat -= dt * 1000;
if (fresh || sheaf.repeat <= 0) {
sheaf.repeat = SHEAF_REPEAT_MS;
const next = Math.min(sheaf.pages.length - 1, Math.max(0, sheaf.index + dir));
if (next !== sheaf.index) {
sheaf.index = next;
sheaf.turns++;
audio?.pageTurn?.();
}
}
}
} else {
// THE INDEX SURVIVES RELEASE. He reopens where he left off; resetting to
// the map would make every glance start from page one, and browsing a
// pile you have to walk back through is a chore rather than a document.
sheaf.heldDir = 0;
sheaf.repeat = 0;
}
// A `?` RESOLVES THE MOMENT HE PAGES TO IT, and there is no timer on it.
//
// Holding the sheaf raises the torch, so a page he is on is already being
// held in his own light β the looking has happened, and charging him
// another four hundred milliseconds for it would only mean that skimming
// does not read, which is a rule nobody would ever discover.
//
// THE COST IS STILL REAL AND IT IS STILL THE SAME. The beat on a page turn
// is 260ms, so walking four unknown pages is about a second stood still in
// the open with the torch up β within a whisker of what the old timer
// charged. What changed is that he resolves the one he turned to rather
// than the oldest thing in his bag.
//
// This is why there is no visible unknown state on the sheet for a page he
// has opened. The unknown drawing is for the pages BEHIND the one he is on:
// take three blind and only the last is in his hand.
if (sheaf.up && torch.raise > 0.99) {
const pg = page();
if (!pageSeen(pg)) {
const it = prince.inventory.find((o) => o.kept === pg.kept);
if (it) { it.seen = true; sheaf.turns++; }
}
}
// --- the doors ----------------------------------------------------------
// Lighting one is the only thing that says who lived here. It latches, the
// same way seeing an object on the ground latches, and for the same reason:
// he looked, and looking is not something he can un-do.
//
// THE DOOR OR ITS LETTER, EITHER ONE. The letter hangs left of the arch,
// nearer him than the door, so there is a stretch of wall where the light
// is on the letter and not yet on the door. If only the door counted he
// could read a letter in the band and then find the door had never been
// lit: the next read would not come free, and E would not pin.
for (const h of HOUSES) {
const [dx, dy] = doorPoint(h);
const lp = letterPoint(h);
if (torch.covers(dx, dy) || (lp && torch.covers(lp[0], lp[1]))) h.seen = true;
}
// THE NOTE ON THE TREE. No door to light first: the paper is all there is.
if (TREE) {
const p = treePoint();
if (p && torch.covers(p[0], p[1])) TREE.seen = true;
}
// THE DOOR HE IS AT, AND IT IS LATCHED RATHER THAN LIT.
//
// There used to be a `lit` house here, meaning "the torch is on this door
// right now", and it was what marking tested β which only worked because
// holding the sheaf forced the torch up. Seeing latches instead, and acting
// on it only needs him to still be standing there.
//
// ONE DOOR, AND THE BAND AND E BOTH USE IT. The band never reads him one
// door while E acts on another. The doors are about 4200px apart and the
// reach is 440, so there is never more than one to choose from anyway.
let nearDoor = null;
for (const h of HOUSES) {
if (!h.seen) continue;
const p = slotPoint(h);
if (p && Math.abs(prince.x - p[0]) <= TUNING.DOOR_REACH) { nearDoor = h; break; }
}
// And the tree, on the same terms. It reads like a door and it never joins
// nearDoor: there is nothing on it to pin to.
let nearTree = null;
if (TREE?.seen) {
const p = treePoint();
if (p && Math.abs(prince.x - p[0]) <= TUNING.DOOR_REACH) nearTree = TREE;
}
// AND THE KEY, WHICH NEEDS THE DOOR OPEN AND NOT THE TORCH UP.
//
// Two door states and this is the line between them. `seen` is what the
// torch buys: you can look into the room and the key is hanging there in
// the light. `open` is what the person buys by walking home: you can reach
// in. It is visible from the first time the door is lit and it is not his
// until somebody comes back and opens their own door, and nothing has to
// say so β the halo is the game's word for "you can take this" and it
// simply is not on yet.
let litKey = null;
for (const h of HOUSES) {
if (!h.open || h.keyTaken) continue;
const p = keyPoint(h);
if (p && Math.abs(prince.x - p[0]) <= TUNING.DOOR_REACH) { litKey = h; break; }
}
// --- the band -----------------------------------------------------------
//
// IT SHOWS A DOCUMENT, VERBATIM, and a document is in one of two places: in
// his hand, or nailed to the wall in front of him. Nothing else in this
// game gets text: not doors, not the gate, not objects and not the prince.
//
// Q held the page in his hand. Wins
// the torch on a letter, in reach that letter. The first read
// standing still, in reach of a lit door that door's letter
// otherwise closed
//
// COMPARING IS LOOKING AT THE DOOR, THEN AT HIS PAPERS, THEN AT THE DOOR.
// Hold Q and the band reads the fragment in his hand; let go and it reads
// the door again. That is the whole deduction, stood in one place.
//
// THE FIRST READ NEEDS LIGHT, AND ONLY THE FIRST. The first draft had every
// read need the torch, so comparing was SPACE held with Q tapped in
// between. On a phone TORCH and PAGES are both on the left wheel and one
// thumb cannot hold one while pressing the other β the trap taking a letter
// fell into once already. So a door he has lit reads again for nothing,
// and comparing is one button: PAGES down, PAGES up.
//
// STANDING STILL is what stops it opening at every door he walks past. Not
// walking, not swinging and not knocked back, for READ_STILL_MS. Holding Q
// counts β he cannot walk with it up β so letting go of Q goes straight
// back to the door.
const stood = !prince.moving && !prince.attacking && prince.x === xFrom;
still = stood ? still + dt * 1000 : 0;
const readAt = nearDoor ?? nearTree;
const readPoint = readAt === TREE ? treePoint() : readAt ? letterPoint(readAt) : null;
const reading = !!readAt &&
((!!readPoint && torch.covers(readPoint[0], readPoint[1])) ||
still >= TUNING.READ_STILL_MS);
state.readingAt = sheaf.up || !reading ? null : readAt;
// Gated on the raise so the band and the page arrive together β the sheet
// itself is not drawn until the arm is up, and a band that beat it there
// would read as a subtitle rather than as the paper in his hand.
//
// THE SHEAF WINS FROM THE MOMENT Q GOES DOWN, not from when the page is
// up. For the quarter-second of the raise the band is blank: he has looked
// away from the wall and not yet down at his hand.
const pg = sheaf.up && torch.raise > 0.9 ? page() : null;
// AN UNKNOWN PAGE HAS NOTHING ON IT YET. He took it blind and he has not
// looked; the band would be reading him words he cannot have.
const want = sheaf.up ? (pg && pageSeen(pg) ? textFor(pg) : null)
: state.readingAt ? textFor({ kind: 'letter', house: state.readingAt.index })
: null;
// CLEARED AND REFILLED EVERY TIME IT CHANGES DOCUMENT, never swapped in
// place. The blank between two pages is a fraction of a second and it is
// the entire reason a turn reads as a turn β swap the text and two letters
// in a row look like one letter changing its mind. Looking from the door to
// the page in his hand is the same thing: two pieces of paper, not one
// label flipping.
//
// A page is keyed by the turn count rather than by its words, because
// paging from one page to another with the SAME words has to still read as
// a turn.
const key = !want ? null : pg ? `page ${sheaf.turns}` : `wall ${state.readingAt.index}`;
if (key !== band.key) { band.key = key; if (key) band.alpha = 0; }
band.text = want;
band.alpha = Math.min(1, Math.max(0, band.alpha + (want ? dt * 5 : -dt * 9)));
// --- the voice ----------------------------------------------------------
//
// THE PAPER, READ ALOUD, while the band shows it. It follows the band's key
// exactly: a new document stops the old voice, and looking back at one
// reads it again from the top, the way reading it again does.
//
// NOT UNTIL IT HAS BEEN UP VOICE_DELAY_MS, which is longer than a page
// turn's beat -- so paging through the pile with an arrow held flips past
// every page in silence, and only the one he stops on speaks. A page he has
// not looked at has nothing written on it, so it has nothing to say either.
const said = !want ? null
: pg ? (pg.kind === 'fragment' ? voiceOf('fragment', pg.kept)
: pg.kind === 'letter' ? voiceOf('letter', pg.house) : null)
: voiceOf('letter', state.readingAt.index);
if (key !== voice.key) {
if (voice.started) audio?.stopVoice?.();
voice.key = key;
voice.ms = 0;
voice.started = false;
} else {
voice.ms += dt * 1000;
}
// AND NOT WHILE HE DIES. The hit that kills him lands earlier in this same
// frame and stops the voice; without this, the band -- still showing the
// same page -- read it again from the top over his death.
state.voice = said && voice.ms >= TUNING.VOICE_DELAY_MS && !loss.active ? said : null;
// Asked every frame until it answers: before the first press, or before
// the file has decoded, it cannot.
if (state.voice && !voice.started) voice.started = !!audio?.voice?.(state.voice);
if (voice.started && !opening.done && state.voice === ORDERS_VOICE) opening.heard = true;
// --- marking the map ----------------------------------------------------
//
// HE WRITES IT. THE GAME DOES NOT. Nothing on the map fills itself any
// more: what goes under a plot is a claim he makes, and a claim only means
// something if it can be wrong.
//
// THE ACT IS THE SAME POSTURE AS EVERYTHING ELSE. He stands at a door with
// a piece of paper in his hand and presses take. No menu, no drag, no
// assignment screen β and no new button, because TAKE is already the
// contextual one and pinning is "put this where it goes".
//
// The door has to have been LIT ONCE β the same latch that reads it again
// for free β and he has to be standing at it. Not lit now and not with Q
// held: asking for both hands is the problem the hand addendum fixed.
// ONE ACT PER PRESS, DECIDED HERE AND NOT UPSTREAM.
//
// input.js already makes `pickup` a one-shot, and that was enough while
// taking was idempotent β pressing take at a door you had already filed
// simply filed it again. Now that the same button also takes a page back
// OUT of a frame, a flag that stays true is a toggle running at 60Hz, and
// whether it lands filed or empty depends on whether the number of frames
// was odd.
//
// The checks are what make that real rather than theoretical: they drive
// these flags directly and hold as many as they like for as long as they
// like, which is the same blind spot that let two impossible phone
// controls pass every test in the suite. So the edge is found here, where
// the act is, and the scene stops depending on how the flag was made.
const pickupHeld = !!input.pickup;
const wantsPickup = pickupHeld && !prevPickup;
prevPickup = pickupHeld;
input.pickup = false;
// --- what is in his hand ------------------------------------------------
//
// THE TOP PAGE OF THE PILE IS WHAT HE IS HOLDING, and the pile does not
// have to be up for him to be holding it.
//
// It used to. Pinning a fragment meant holding Q, paging to it, and
// pressing E at a door β three conditions and a mode, and it was played and
// nobody did it. Nobody does that with paper either: you take the page you
// want, put the rest away, walk over, and pin it.
//
// So the sheaf is for CHOOSING and E is for DOING, and E never asks about
// the sheaf. Q still raises the torch, still stops him, still shows the
// band and pages with the arrows. It has just stopped being a prerequisite.
const handPage = page();
state.handPage = handPage;
// THE SLOT TAKES A PIN FROM THE FIRST LIGHT. It used to wait for the letter
// to come off: the patch was earned, and taking the letter was what showed
// him where a fragment goes. The letter never comes off now, and the slot
// is beside it from the start β a bare nail under a letter says something
// goes here.
//
// AND NOT AT A DOOR THAT IS FINISHED WITH. Someone walked home to it; the
// slot has the only thing it was ever going to hold.
const patchTakes = !!nearDoor && !gate.through && !nearDoor.settled;
const holding = handPage?.kind === 'fragment' ? handPage.kept : null;
// PIN, or TAKE BACK, and which one is decided by what is already there.
//
// THE SAME PAGE THAT IS ALREADY PINNED TAKES IT BACK. The table this comes
// from does not have a row for it β it has "a different fragment evicts and
// pins" and "not a fragment takes it back" β and holding the page that is
// already on the wall is neither. Evicting it and pinning it again is a
// press that does nothing, so it is the other one: he is holding the thing
// in front of him, and E takes it down.
const canRetrieve = patchTakes && nearDoor.filed != null &&
(holding === null || holding === nearDoor.filed);
const canPin = patchTakes && holding !== null && holding !== nearDoor.filed;
const canMark = canPin || canRetrieve;
// THE SLOT, THEN THE KEY. A claim about a door comes before the thing that
// opens the gate: evidence before conclusions before rewards, which is the
// order this whole game is arguing for.
//
// READING IS NOT ON THIS LIST. What the band shows decides nothing about E.
// It used to β a letter in reach switched off pinning until it was taken β
// and with letters never taken that would have switched pinning off at
// exactly the moment he reads the letter he is trying to answer.
state.canMark = canMark;
state.canPin = canPin;
state.nearDoor = nearDoor;
// WHICH house it would go under. The sheet draws that house's plot beside
// the page when this is set, so the paper itself says "this one, here".
state.markHouse = state.canPin ? nearDoor.index : null;
state.canTakeKey = !canMark && !!litKey;
// AND PUTTING ONE IN. He stops 380px short of the leaf and can never get
// closer, so the reach is the door's, not the ground's.
state.canPlaceKey = !canMark && prince.keys > 0 && !gate.through &&
gate.placed < TUNING.GATE_WANTS &&
Math.abs(prince.x - GATE_X) <= TUNING.DOOR_REACH;
if (wantsPickup && state.canMark && !prince.attacking) {
if (canRetrieve) {
// TAKE IT BACK DOWN. Either he is holding the page that is already
// pinned here, or he is holding nothing that could go in β and in both
// cases the only thing on that wall he can act on is what is on it.
//
// It never left the sheaf. The patch holds a CLAIM, not the paper, so
// there is nothing to hand back.
nearDoor.filed = null;
audio?.mark?.();
} else {
// MOVE, THEN REPLACE. Pinning a fragment somewhere else takes it off
// wherever it was, and pinning a different one here pushes the old one
// out. Two fragments can never sit in one patch and one fragment can
// never sit in two, and neither rule needs a message: it is what
// putting a piece of paper down on a table already does.
for (const h of HOUSES) if (h.filed === holding) h.filed = null;
nearDoor.filed = holding;
audio?.mark?.();
// AND IF IT WAS THEIRS, THEY GO HOME.
//
// `h.index` and `HOUSE_OF[i]` have been the same number since the
// fragments were written, so this is the whole correctness test and
// there is nowhere for it to drift to.
//
// NOTHING IS SAID. No tick, no sound of being right, no change to the
// patch. Somewhere out in the village a body gets up, and the only way
// to find out is that it walks up the street.
if (nearDoor.index === holding) {
const owner = drained.find((d) => d.kept === holding && d.keptTaken);
if (owner) {
nearDoor.settled = true;
owner.sendHome(nearDoor.x);
// AND THE SKY GOES OFF. It replaces a silence: the payoff for the
// one right act in this level happens somewhere else and up to a
// minute later, so doing it correctly looked exactly like doing
// nothing. A ten-year-old played it and stopped here.
flash.ms = 0;
flash.thundered = false;
}
} else {
// AND IF IT WAS NOT, THEY OBJECT. Played through, the level was
// finished without reading a word: pin, see who walks, try the next
// door. The walk home confirmed every guess instantly and for free,
// which made it an answer key. Now a stranger's house wakes the
// owner, and it comes for him. Guessing is still allowed; it costs.
//
// The page stays on the wrong wall. Putting it down does not take it
// back β he does that, with E, as always.
const owner = drained.find((d) => d.kept === holding && d.keptTaken);
if (owner && owner.wake()) {
// HEARD AT FULL STRENGTH, WHEREVER IT IS. On the side it is on, but
// never quieter for being far: a faint gasp from across the village
// and a rush thirty seconds later are two things nobody connects.
// The breathing as it comes can fall away with distance. The
// moment it wakes cannot.
audio?.drainedWake?.(1, Math.max(-0.85, Math.min(0.85, (owner.x - prince.x) / 1400)));
}
}
}
}
// --- the objects on the ground -----------------------------------------
// Seeing one is latched separately from picking it up, because they are
// different acts with different costs. The glow tells you something is
// there without the torch; only the torch tells you what.
let nearest = null;
let nearestGap = TUNING.PICKUP_REACH;
for (const d of drained) {
if (!d.keptDropped || d.keptTaken) continue;
if (torch.covers(d.keptX, GROUND_Y + d.keptY)) d.keptSeen = true;
const gap = Math.abs(d.keptX - prince.x);
if (gap <= nearestGap) { nearest = d; nearestGap = gap; }
}
// Published rather than re-derived. main.js used to run this same scan a
// second time to decide whether to light the button, which is two places
// that have to agree about what is in reach.
state.canTake = !!nearest;
if (wantsPickup && state.canPlaceKey && !prince.attacking) {
// THE SAME POSTURE AS EVERYTHING ELSE. He stands at the thing and
// presses take. Four times, one socket each, and the leaf lifts on the
// fourth β no ceremony, no confirmation, and nothing said about the
// fifth house whose key is still hanging behind a door that never opened.
prince.keys--;
gate.placed++;
// Out of the inventory too, or he is holding a key that is in the gate.
// The last one found, so the pile behaves like a pile.
for (let i = sheaf.pages.length - 1; i >= 0; i--) {
if (sheaf.pages[i].kind === 'key') { sheaf.pages.splice(i, 1); break; }
}
sheaf.index = Math.max(0, Math.min(sheaf.index, sheaf.pages.length - 1));
audio?.mark?.();
} else if (wantsPickup && state.canTakeKey && !prince.attacking) {
// Off the hook and into his pocket. No page, no band, no entry in the
// sheaf: there is nothing to read on a key and nothing to work out.
litKey.keyTaken = true;
prince.keys++;
// AND IT GOES IN WITH EVERYTHING ELSE HE IS CARRYING.
//
// Keys were a number and nothing else β deliberately, because "a key is
// not evidence, it does not go in the sheaf, there is nothing to work out
// about one". All true, and it made them invisible: somebody collected
// four and did not know until they put one in the gate by accident. A
// thing you carry that you cannot look at is a thing you do not have.
//
// Nothing to read on it, so the band stays empty on a key page. That is
// the difference between a key and a letter, said by showing you both.
filePage({ kind: 'key' });
audio?.keyTake?.();
} else if (wantsPickup && !state.canMark && nearest && !prince.attacking) {
nearest.keptTaken = true;
run.taken++;
prince.inventory.push({
kept: nearest.kept, answers: nearest.answers, seen: nearest.keptSeen,
});
// Into the sheaf where he found it, looked at or not. A blind take gets a
// page with nothing written on it yet, and paging to it is what writes it.
filePage({ kind: 'fragment', kept: nearest.kept });
}
// --- the score ----------------------------------------------------------
run.seconds += dt;
run.fights = drained.filter((d) => d.revealed).length;
if (torch.raise > 0.9 && !prince.moving) {
const near = drained.some((d) => d.state !== DOWNED &&
Math.abs(d.x - prince.x) < WATCH_CLEAR_PX);
if (!near) run.watching += dt;
}
// --- the storm ----------------------------------------------------------
// Light first, sound after, because that is the order they arrive in.
if (!flash.thundered || flash.ms < FLASH.MS) {
const was = flash.ms;
flash.ms += dt * 1000;
if (!flash.thundered && was < FLASH.THUNDER_MS && flash.ms >= FLASH.THUNDER_MS) {
flash.thundered = true;
audio?.thunder?.();
}
}
// THE VILLAGE, UNDER EVERYTHING. Up over the first stretch of the bowl and
// down again at the gate, so walking in is walking into it and walking out
// is not β the same shape the canopy already has, for the same reason.
//
// Forty-five people are standing in this place sixty years after she came
// and until now the only sound in it was his own boots.
{
const u = (prince.x - BOWL.X0) / 1800;
const out = (GATE_X - prince.x) / 1400;
audio?.villageBed?.(Math.max(0, Math.min(1, u, out)));
}
state.flash = flashAt(flash.ms);
// The bolt runs off the same clock. -1 while nothing is happening, so the
// renderer does not have to know what "no strike" looks like.
state.flashMs = flash.thundered && flash.ms >= FLASH.MS ? -1 : flash.ms;
// --- the gate -----------------------------------------------------------
//
// FOUR KEYS IN FOUR SOCKETS. The sockets were built at L1 and have had
// nothing to go in them since.
//
// WHAT THIS REPLACES, and it is deleted rather than turned off: the gate
// used to want four fragments he had LOOKED at. That was a magic lock β
// the only person who could have known what the marks meant is lying in
// front of it β and it was also the whole reason the level could be beaten
// without reading a word, because looking at four pieces of paper is not
// the same act as understanding one.
//
// A key is not a grade either. The gate still never sees the map, still
// never checks an answer, and a player can put four keys in it holding a
// completely wrong sheaf. What changed is where a key comes from: a door
// somebody opened from the inside, because you gave them their name back.
gate.filled = Math.min(TUNING.GATE_WANTS, gate.placed);
if (gate.filled >= TUNING.GATE_WANTS && gate.open < 1) {
gate.open = Math.min(1, gate.open + (dt * 1000) / TUNING.GATE_OPEN_MS);
}
// THROUGH IS A STEP, not a place. He can be standing inside the gateway
// when the leaf lifts β it is his to walk once the keeper is home β and
// the level must not end on him for standing still under it.
if (gate.open >= 1 && prince.x >= GATE_X - 40 && prince.moving && prince.facing > 0) {
// Through it, and the playthrough is over. No prompt, no score screen,
// and no mention of the empty slot. The gate wanted four. It got four.
gate.through = true;
}
if (gate.through) state.fade = Math.min(1, state.fade + dt / 1.6);
// --- Prince animation --------------------------------------------------
if (prince.attacking) {
prince.clip = attackClip;
prince.frame = Math.min(Math.floor(prince.attackTime * attackClip.fps), attackClip.count - 1);
} else if (!prince.hasSword) {
const eased = torch.raise * torch.raise * (3 - 2 * torch.raise);
// Same raise curve either way; only which pose it drives changes.
const clip = sheaf.up && mapClip ? mapClip : torchClip;
const player = sheaf.up && mapClip ? prince.mapPlayer : prince.torchPlayer;
player.time = (eased * (clip.count - 1)) / clip.fps;
prince.clip = clip;
prince.frame = player.index;
state.flame.player.advance(dt);
state.flame.frame = state.flame.player.index;
} else if (prince.moving) {
const rate = runClip.groundSpeed ? TUNING.WALK_SPEED / runClip.groundSpeed : 1;
// THE LIMP, AND IT IS HER OWN RUN FRAMES.
//
// A limp is an asymmetric rhythm, not a different drawing: the good foot
// carries longer and the bad one is hurried off. So the cycle is slowed
// through its second half and rushed through the first, by an amount that
// grows with the hits he has taken. Nothing was redrawn for it, and it
// reads because it is what a limp actually is.
const hurt = Math.max(0, prince.hits - TUNING.LIMP_FROM);
if (hurt > 0) {
const half = prince.run.index >= runClip.count / 2;
const hitch = 1 - Math.min(0.8, TUNING.LIMP_HITCH * (hurt / 3)) * (half ? 1 : -0.5);
prince.run.advance(dt * rate / hitch);
} else {
prince.run.advance(dt * rate);
}
prince.clip = runClip;
prince.frame = prince.run.index;
// A footstep when the boot ARRIVES, not on a timer. runClip.footFrames is
// measured off the drawings, so if the art changes the steps follow it.
if (prince.frame !== lastRunFrame) {
if (runClip.footFrames?.includes(prince.frame)) audio?.footstep();
lastRunFrame = prince.frame;
}
} else if (prince.hits >= TUNING.LIMP_FROM && hurtClip) {
// Standing still with something left in him, he holds his side.
//
// NOT PART OF THE READOUT ANY MORE, and not currently drawn. The eye
// dimming says how hurt he is now; this said it with his body, which is
// better, but only with a real drawing behind it.
//
// Two stages. Which pair of frames he is in is how close he is β the
// first is hurt, the second is spent, and the switch is at the halfway
// point of what he can take.
prince.hurtPlayer.advance(dt);
const spent = prince.hits >= Math.ceil(TUNING.PRINCE_HITS / 2) + 1;
prince.clip = hurtClip;
prince.frame = (spent ? 2 : 0) + (prince.hurtPlayer.index % 2);
} else if (drained.some((d) => d.revealed && d.state !== DOWNED && Math.abs(d.x - prince.x) < W)) {
// Once one has been seen and is coming, he stands ready.
prince.guard.advance(dt);
prince.clip = guardClip;
prince.frame = prince.guard.index;
} else {
prince.idle.advance(dt);
prince.clip = idleClip;
prince.frame = prince.idle.index;
}
}
// openAgain is for the checks, and for nothing else in the game: reset()
// deliberately does NOT re-arm the opening, so something has to be able to.
// An explicit call beats a check reaching in and setting two fields.
return { state, update, reset, camera, openAgain: armOpening };
}
|