Spaces:
Running
Running
File size: 89,421 Bytes
020d2dc 6742ef0 7d93bf0 db17af1 9f89772 d16591c 020d2dc 6742ef0 020d2dc 71eae78 020d2dc 71eae78 6742ef0 584bb8c bc4864e 020d2dc 71eae78 020d2dc 71eae78 44bae7c 71eae78 42ec301 020d2dc 71eae78 42ec301 71eae78 d16591c 42ec301 71eae78 d16591c 42ec301 71eae78 2428d78 71eae78 2428d78 71eae78 2428d78 71eae78 2428d78 71eae78 2428d78 71eae78 d16591c 71eae78 2428d78 020d2dc 9986f05 45cc183 9986f05 45cc183 9986f05 45cc183 9986f05 d16591c 2428d78 438638a 45cc183 835b445 e6021c6 2428d78 835b445 2428d78 f38d705 835b445 2428d78 f38d705 2428d78 818aea9 2428d78 88ca85c 6f7f7b5 db17af1 e6021c6 45cc183 e6021c6 45cc183 e6021c6 ac20418 e6021c6 ac20418 e6021c6 ac20418 e6021c6 ac20418 45cc183 ac20418 e6021c6 d1e0284 9f89772 3647d73 9f89772 3647d73 9f89772 3647d73 9f89772 3647d73 9f89772 d1e0284 db17af1 0b13ced 2428d78 0b13ced 2428d78 0b13ced 7d93bf0 0b13ced 7d93bf0 2428d78 0b13ced 7d93bf0 0b13ced 0d682f1 ad47ef2 0d682f1 45cc183 ad47ef2 0d682f1 ad47ef2 0d682f1 71eae78 7d93bf0 0b13ced 7d93bf0 0b13ced 835b445 7d93bf0 0b13ced 7d93bf0 0b13ced 7d93bf0 835b445 7d93bf0 f38d705 7d93bf0 f38d705 7d93bf0 f38d705 7d93bf0 32103e5 0b13ced 0d682f1 32103e5 7d93bf0 0b13ced 7d93bf0 32103e5 0b13ced 7d93bf0 32103e5 0b13ced 32103e5 0b13ced 32103e5 0b13ced 32103e5 0b13ced 32103e5 835b445 0b13ced 835b445 32103e5 0b13ced 32103e5 0b13ced 32103e5 7d93bf0 a24e266 45cc183 a24e266 45cc183 a24e266 835b445 45cc183 835b445 45cc183 835b445 45cc183 835b445 a24e266 835b445 a24e266 438638a 45cc183 438638a 45cc183 835b445 45cc183 835b445 45cc183 835b445 45cc183 835b445 45cc183 835b445 45cc183 835b445 f38d705 45cc183 f38d705 818aea9 835b445 818aea9 835b445 db17af1 45cc183 db17af1 45cc183 db17af1 835b445 45cc183 835b445 438638a 835b445 438638a 835b445 438638a 835b445 438638a 835b445 f38d705 835b445 438638a 947168d 62f5212 f38d705 947168d d40e519 07c60fa d40e519 94ca139 71eae78 020d2dc 94ca139 71eae78 020d2dc 94ca139 020d2dc 94ca139 020d2dc 94ca139 020d2dc 71eae78 020d2dc 9f89772 020d2dc 42ec301 9f89772 020d2dc 42ec301 9f89772 020d2dc 71eae78 020d2dc 07c60fa 020d2dc 71eae78 9f89772 71eae78 44bae7c 71eae78 d16591c 71eae78 6742ef0 71eae78 d1e0284 71eae78 db17af1 2428d78 9986f05 db17af1 d16591c 42ec301 0b13ced 9f89772 71eae78 d1e0284 71eae78 6742ef0 71eae78 94ca139 71eae78 2428d78 9986f05 71eae78 d16591c 42ec301 2428d78 0b13ced 9f89772 71eae78 d1e0284 71eae78 6742ef0 7d93bf0 947168d 2428d78 71eae78 d570ac9 5e94736 d570ac9 5e94736 d570ac9 2428d78 71eae78 2428d78 5e94736 2428d78 5e94736 44bae7c 835b445 2428d78 f38d705 5e94736 71eae78 7d93bf0 71eae78 7d93bf0 71eae78 6f7f7b5 45cc183 5e94736 7d93bf0 5e94736 6742ef0 5e94736 32103e5 5e94736 07c60fa 71eae78 6742ef0 020d2dc | 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 | // Two passes per frame.
//
// Pass 1 β the dark world: background, ground and every character as solid
// black shapes on flat grey. No interior detail anywhere.
// Pass 2 β the lit reveal: the same scene drawn with the lit art on an
// offscreen canvas, masked to the torch shape with a soft radial
// falloff, then composited over the dark world.
//
// The offscreen canvas and both gradients are allocated once, at construction.
// Nothing here allocates inside the frame loop.
//
// Pass 2 only ever touches the light's bounding box. At a 300px radius that is
// about a ninth of the screen, so the expensive pass costs a ninth of what a
// full-canvas redraw would.
import { W, H, GROUND_Y } from './scene.js';
import { createDepth } from './depth.js';
import { drawBand } from './band.js';
import { TUNING, TORCH, depthAt, DEPTH, TONE, FLASH } from './tuning.js';
import { armPose } from './torch.js';
import { laneLook } from './drained.js';
const BG_DARK = '#8a8a86'; // spec 6
const BG_LIT = '#a39684'; // warmer; only ever seen inside the torch
const GLOW_RGB = '255,180,90'; // #ffb45a
const GLOW_SPREAD = 1.35; // the glow reaches past the reveal edge
const lerp = (a, b, t) => a + (b - a) * t;
export function createRenderer(ctx) {
const depth = createDepth();
// VIGNETTE. Newly legal, and the single most Limbo-defining cue in the
// reference frames β in both of them the light is a POOL in the middle and
// every edge falls to near black. It was banned outright before, because
// dragging a 163 sky down to 0 across a large area is the forbidden zone by
// definition.
//
// Allocated once. The gradient never moves, so rebuilding it per frame would
// be the same mistake the torch mask avoided.
const vignette = (() => {
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.30,
W / 2, H / 2, Math.hypot(W, H) * 0.56);
g.addColorStop(0.00, 'rgba(0,0,0,0)');
g.addColorStop(0.55, 'rgba(0,0,0,0.30)');
g.addColorStop(1.00, 'rgba(0,0,0,1)');
return g;
})();
function drawVignette(c) {
if (TONE.VIGNETTE <= 0) return;
c.save();
c.globalAlpha = Math.min(1, TONE.VIGNETTE);
c.fillStyle = vignette;
c.fillRect(0, 0, W, H);
c.restore();
}
ctx.imageSmoothingEnabled = true;
const off = document.createElement('canvas');
off.width = W;
off.height = H;
const octx = off.getContext('2d');
octx.imageSmoothingEnabled = true;
// Unit-space gradients: defined once at the origin with radius 1, then moved
// and scaled into place each frame with setTransform. Canvas gradients live
// in user space, so they transform with the matrix β which is why a moving,
// breathing light needs no reallocation.
const maskGradient = octx.createRadialGradient(0, 0, 0, 0, 0, 1);
maskGradient.addColorStop(0.00, 'rgba(0,0,0,1)');
maskGradient.addColorStop(0.65, 'rgba(0,0,0,1)'); // spec: full opacity to 65%
maskGradient.addColorStop(0.78, 'rgba(0,0,0,0.86)');
maskGradient.addColorStop(0.88, 'rgba(0,0,0,0.55)');
maskGradient.addColorStop(0.95, 'rgba(0,0,0,0.22)');
maskGradient.addColorStop(1.00, 'rgba(0,0,0,0)');
// Darkens everything the torch is NOT lighting. Transparent in the middle,
// opaque at the rim, and the caller fills the rest of the screen solid.
const dimGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
dimGradient.addColorStop(0.00, `rgba(${TORCH.DIM_RGB},0)`);
dimGradient.addColorStop(TORCH.DIM_START, `rgba(${TORCH.DIM_RGB},0)`);
dimGradient.addColorStop(0.82, `rgba(${TORCH.DIM_RGB},${TORCH.DIM * 0.55})`);
dimGradient.addColorStop(1.00, `rgba(${TORCH.DIM_RGB},${TORCH.DIM})`);
// Halo behind the one kept object, so it is the brightest thing in the circle.
const keptGlow = octx.createRadialGradient(0, 0, 0, 0, 0, 1);
keptGlow.addColorStop(0.00, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW})`);
keptGlow.addColorStop(0.50, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW * 0.72})`);
keptGlow.addColorStop(0.80, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW * 0.24})`);
keptGlow.addColorStop(1.00, `rgba(${TORCH.KEPT_RIM},0)`);
const glowGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
glowGradient.addColorStop(0.00, `rgba(${GLOW_RGB},0.22)`);
glowGradient.addColorStop(0.45, `rgba(${GLOW_RGB},0.12)`);
glowGradient.addColorStop(0.80, `rgba(${GLOW_RGB},0.04)`);
glowGradient.addColorStop(1.00, `rgba(${GLOW_RGB},0)`);
// D walks right now, so the debug readout moved to the backtick key.
//
// IT MUST BE OFF WHENEVER THE FRAME IS MEASURED. It is antialiased black text
// on a bright sky, so it sits in the forbidden zone by construction, and it
// covers enough of the frame to swamp what it is measuring β a baseline that
// read 0.78% was really 0.199%. Worse, the line gets LONGER when a cue is
// switched on, because it prints that cue's name, so every cue was charged
// for its own label. setDebug exists so a test can never forget.
// OFF BY DEFAULT, AND THAT IS NOT A TIDYING-UP.
//
// It was on, everywhere, including on the public link β and it prints
// "[E] PIN IT HERE" the moment you are standing at a door holding a page.
// Which means nobody who was ever sent that link played this game: they were
// told the one thing the whole build exists to find out whether a person can
// work out on their own. The person who designed it could not get a clean
// first impression from anybody, including himself.
//
// It is a dev overlay β fps, pass timings, world x, canopy percent, clip
// names β and it stays exactly as it was. Backtick toggles it, ?debug=1
// brings it up from the address bar. It has just stopped being the first
// thing a stranger sees.
//
// (A phone has no backtick key and no address bar worth typing in, so on a
// handset this is now simply gone, which it should always have been: eleven
// lines of instrumentation over the top of the game.)
let showDebug = false;
if (typeof location !== 'undefined') {
const want = new URLSearchParams(location.search).get('debug');
if (want !== null) showDebug = want !== '0' && want !== 'false';
}
window.addEventListener('keydown', (e) => {
if (e.code === 'Backquote') showDebug = !showDebug;
});
// Anchor a clip's frame at a world point. The pivot comes from the manifest,
// so art of any size drops in without touching this.
//
// Mirrors only when the character is facing the opposite way to how its art
// was DRAWN. Not every sprite is drawn facing right: the prince is, the
// Drained is drawn facing left, and a blanket "mirror when facing is -1" rule
// drew the Drained backwards every time it moved. The manifest says which way
// each clip was authored.
//
// docs/ART_HANDOFF.md used to promise nothing was mirrored in code. That stopped
// being true the moment the prince got a facing and could be attacked from
// either side. The pivot stays on the anchor through the flip.
function drawClip(c, clip, index, mode, worldX, worldY, camX = 0, facing = 1, dimEye = 0) {
const f = clip.frames[index];
if (!f) return;
const sx = Math.round(worldX - camX);
const sy = Math.round(worldY);
// THE EYE IS PAINTED OVER, not painted in. It is already white in both
// passes; this is a black stencil of exactly those pixels, drawn on top at
// the alpha the health asks for. At 0 it is not drawn at all, so the
// ordinary case costs nothing.
const dim = f.eyes && dimEye > 0.01 ? Math.min(1, dimEye) : 0;
if (facing !== (clip.authoredFacing ?? 1)) {
c.save();
c.translate(sx, sy);
c.scale(-1, 1);
c.drawImage(f[mode] ?? f.dark, -clip.pivotX, -clip.pivotY);
if (dim) {
c.globalAlpha = dim;
c.drawImage(f.eyes, -clip.pivotX, -clip.pivotY);
}
c.restore();
} else {
c.drawImage(f[mode] ?? f.dark, sx - clip.pivotX, sy - clip.pivotY);
if (dim) {
c.save();
c.globalAlpha = dim;
c.drawImage(f.eyes, sx - clip.pivotX, sy - clip.pivotY);
c.restore();
}
}
}
// The one thing each of them kept. Drawn ON the figure in both passes: in
// silhouette it merges into the black mass and says nothing, and in the light
// it is the only thing that says who this was.
function drawKept(c, keptClip, d, mode, worldX, camX, clock = 0) {
// Eight of the village's twelve kept nothing. A fight that drops nothing is
// the negative result and it needs no marking β but without this the lit
// pass still painted the halo, so an empty-handed shape glowed at the exact
// spot where an object it does not have would be.
if (!keptClip || d.kept == null || d.keptTaken) return;
const face = d.facing === (d.clip.authoredFacing ?? 1) ? 1 : -1;
// Two placements, and which one applies is a fact about the body.
//
// Still standing, it rides the figure at keptAnchor: part of the black mass,
// saying nothing until the torch finds it.
//
// Once it has been put down, the object is out and on the ground at a point
// frozen at the moment it fell. It does not travel with the body, and it
// does not climb back on when the body gets up an hour later.
let px, oy;
if (d.keptDropped) {
px = d.keptX;
oy = d.keptY;
} else {
// One anchor, or one per frame. A trade loop swings its working hand all
// the way over the head, so a single point would leave the hammer hanging
// in mid-air while the arm went past it.
let a = d.clip.keptAnchor;
if (!a) return;
if (Array.isArray(a[0])) a = a[Math.min(d.frame, a.length - 1)];
px = worldX + (a[0] - d.clip.pivotX) * face;
oy = a[1] - d.clip.pivotY;
}
const sx = Math.round(px - camX);
const sy = Math.round(GROUND_Y + oy);
// A spear is not the size of a hammer. Per variant, so the one object that
// has to out-reach a sword in silhouette can, without inflating the rest.
const kScale = keptClip.variantScale ? (keptClip.variantScale[d.kept] ?? 1) : 1;
// The halo. Inside the torch it separates the object from the body's own
// line work. Outside it, on a dropped object only, it is the thing that
// tells you from across the screen that there is something there at all.
if (mode === 'lit' || d.keptDropped) {
const R = (keptClip.width / 2) * TORCH.KEPT_GLOW_R * kScale;
// Breathing, not steady. Alpha on the pre-allocated gradient rather than
// rebuilding it, so the pulse costs nothing.
const pulse = (1 - TORCH.KEPT_PULSE_DEPTH *
(0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ))) *
(mode === 'lit' ? 1 : TORCH.KEPT_GLOW_DARK);
c.save();
c.globalCompositeOperation = 'lighter';
c.globalAlpha = pulse;
// COMPOSED, not set. A page on a figure in the back lane is drawn under
// that lane's scale and lift, and setTransform threw both away: the
// page went up with the body and its halo stayed 56px below it.
c.translate(sx, sy);
c.scale(R, R);
c.fillStyle = keptGlow;
c.fillRect(-1, -1, 2, 2);
c.restore();
}
// The object keeps its own black line work. The colour is all in the halo
// behind it, so it has a hot pool to sit in and an edge to read against.
//
// AND IT TURNS WITH THE HAND. A trade loop swings a hammer through 100
// degrees; drawn axis-aligned the head stays pointing at the sky while the
// arm comes down, and the whole thing reads as a shape floating near a man
// rather than a thing he is holding. Negated when he is mirrored, or the
// hammer turns the wrong way on half the village.
// Held, it turns with the hand. Dropped, it keeps the art's own angle.
const angles = d.keptDropped ? null : d.clip.keptAngle;
const ang = angles ? angles[Math.min(d.frame, angles.length - 1)] : 0;
if (ang || kScale !== 1) {
c.save();
c.translate(sx, sy);
if (ang) c.rotate(face >= 0 ? ang : -ang);
if (kScale !== 1) c.scale(kScale, kScale);
c.translate(-sx / kScale, -sy / kScale);
drawClip(c, keptClip, d.kept, mode, px / kScale, (GROUND_Y + oy) / kScale,
camX / kScale, face * (keptClip.authoredFacing ?? 1));
c.restore();
} else {
drawClip(c, keptClip, d.kept, mode, px, GROUND_Y + oy, camX,
face * (keptClip.authoredFacing ?? 1));
}
}
/**
* The tree with the note on it. On the road at his own scale, so it draws
* with the houses: black in the dark pass, its bark in the lit one.
*
* THE NOTE, BOTH WAYS. In the dark the scrap shows pale β paper catching
* what light there is, so a lone tree says "something is pinned here" the
* way a house's lit doorway does. Under the torch it is the note on its
* nail, drawn by drawNail like every letter. It stays there, and there is
* no slot under it: nothing is ever pinned back to a tree.
*/
function drawTree(c, s, camX, mode) {
const t = s.tree;
const clip = s.treeClip;
if (!t || !clip) return;
const sx = Math.round(t.x - camX);
if (sx < -clip.width || sx > W + clip.width) return;
const x = sx - clip.pivotX;
const y = GROUND_Y - clip.pivotY;
c.drawImage(clip.frames[0][mode === 'lit' ? 'lit' : 'dark'], x, y);
if (mode === 'lit') {
drawNail(c, s, clip, t, x, y, { slot: false });
} else if (s.treeNoteDark) {
const art = s.letterArt;
const b = nailBox(clip.letterAt?.[t.variant], x, y, art);
if (b) {
c.drawImage(s.treeNoteDark, b.px - b.ink.x * b.k, b.py - b.ink.y * b.k,
art.width * b.k, art.height * b.k);
}
}
}
/**
* One Drained, in whichever lane it is standing in.
*
* THE BACK LANE IS A TRANSFORM ABOUT ITS FEET. Scaled and lifted around the
* point it stands on, so the figure, the page it carries and the page's own
* anchor all go together and nothing below here has to know. On the road it
* is the identity and costs one branch.
*
* In the dark pass it also takes the lane's VALUE: the tinted `back` frames,
* with the black ones laid over them at the share of the way it has stepped
* down. Both have the same alpha, so the blend is a value and never a
* see-through figure. The lit pass keeps its lit art in either lane β the
* torch shows what they are, and value is a thing only a silhouette has.
*/
function drawDrained(c, s, d, mode, x, camX) {
// THE STARE, BUILDING. Nothing while the light is off it, and nothing for
// the first STIR_TREMBLE_FROM of the stare either: that part is the gasp
// and the turn. Then a shiver, growing to a hard shake by the moment it
// wakes. Squared, so it starts small. Offset per body so two lit at once
// do not shake in step.
const from = TUNING.STIR_TREMBLE_FROM;
const k = d.stir > from ? (d.stir - from) / (1 - from) : 0;
if (k > 0) x += Math.sin((s.clock ?? 0) * 71 + d.spawnX * 0.37) * TUNING.STIR_TREMBLE_PX * k * k;
const L = laneLook(d.lane);
if (L.lane <= 0) {
drawClip(c, d.clip, d.frame, mode, x, GROUND_Y, camX, d.facing);
drawKept(c, s.kept, d, mode, x, camX, s.clock);
return;
}
const sx = x - camX;
c.save();
c.translate(sx, GROUND_Y - L.rise);
c.scale(L.scale, L.scale);
c.translate(-sx, -GROUND_Y);
if (mode === 'dark') {
drawClip(c, d.clip, d.frame, 'back', x, GROUND_Y, camX, d.facing);
drawKept(c, s.kept, d, 'back', x, camX, s.clock);
if (L.lane < 1) {
c.globalAlpha = 1 - L.lane;
drawClip(c, d.clip, d.frame, 'dark', x, GROUND_Y, camX, d.facing);
drawKept(c, s.kept, d, 'dark', x, camX, s.clock);
}
} else {
drawClip(c, d.clip, d.frame, mode, x, GROUND_Y, camX, d.facing);
drawKept(c, s.kept, d, mode, x, camX, s.clock);
}
c.restore();
}
/**
* Where the back lane is standing this frame, as screen spans, for the
* watchers to keep out of. Off each figure's own ink rather than its canvas
* β a loop's canvas is 380 wide to give the swing room, and the body in it
* is a third of that β and mirrored with it. One buffer, reused.
*/
const laneSpans = new Float32Array(32 * 3);
function backLaneSpans(s, camX, alpha) {
let n = 0;
for (const d of s.drained) {
if (!(d.lane > 0) || n >= 32) continue;
const x = lerp(d.prevX, d.x, alpha) - camX;
if (x < -400 || x > W + 400) continue;
const L = laneLook(d.lane);
const ink = d.clip.ink;
let l = ink ? ink.x - d.clip.pivotX : -60;
let r = ink ? ink.x + ink.w - d.clip.pivotX : 60;
if (d.facing !== (d.clip.authoredFacing ?? 1)) { const t = l; l = -r; r = -t; }
laneSpans[n * 3] = x + l * L.scale;
laneSpans[n * 3 + 1] = x + r * L.scale;
laneSpans[n * 3 + 2] = L.lane;
n++;
}
return n;
}
/**
* Every Drained on screen, BACK LANE FIRST. Whatever stands beside the road
* is behind whatever is on it, and one partway through stepping down is
* between the two. Three sweeps over a dozen bodies rather than a sort, so
* the frame still allocates nothing.
*/
function drawAllDrained(c, s, mode, camX, alpha) {
for (let pass = 0; pass < 3; pass++) {
for (const d of s.drained) {
const lane = d.lane ?? 0;
if (pass === 0 ? lane < 1 : pass === 1 ? (lane <= 0 || lane >= 1) : lane > 0) continue;
if (d.x - camX < -400 || d.x - camX > W + 400) continue;
drawDrained(c, s, d, mode, lerp(d.prevX, d.x, alpha), camX);
}
}
}
// The village. Five houses and a shut gate, at the actor's own parallax rate
// because they stand on the same ground he does.
//
// V1 IS DELIBERATELY BOXES. A gable and a body, pure black, sitting on the
// ground line. Art is V2 and the door that lights is the whole of V2, but the
// question V1 asks β is the room the right size, and does a building tell you
// the mode changed without a word β is answerable from a silhouette.
//
// Pure black, like every other built thing at this distance. The palette rule
// lets the BACKGROUND carry tone; anything standing on the ground line stays
// black, or the prince stops being the darkest shape in his own frame.
function drawVillage(c, s, camX, mode = 'dark') {
if (!s.houses) return;
const clip = s.houseClip;
if (clip && s.houseArt) {
for (const h of s.houses) {
const sx = Math.round(h.x - camX);
if (sx < -clip.width || sx > W + clip.width) continue;
const x = sx - clip.pivotX;
const y = GROUND_Y - clip.pivotY;
// DARK: the tinted silhouette, one solid mass, saying nothing.
// LIT: the art itself β the wall at the same value, and the door open
// with a trade standing in it. The torch mask does the rest, so a door
// only shows where the light actually falls.
if (mode === 'lit') {
c.drawImage(clip.frames[h.variant].lit, x, y);
drawNail(c, s, clip, h, x, y);
drawRoomKey(c, s, clip, h, x, y, s.clock ?? 0);
} else {
c.drawImage(s.houseArt[h.variant], x, y);
drawDoorGlow(c, s, clip, h, x, y, s.clock ?? 0);
}
}
} else {
// Before the art exists: boxes, which is enough to judge the size of the
// room but not the mechanic.
c.fillStyle = s.houseTint;
for (const h of s.houses) {
const sx = Math.round(h.x - camX);
if (sx < -h.w || sx > W + h.w) continue;
c.fillRect(sx - h.w / 2, GROUND_Y - 560, h.w, 560);
}
c.fillStyle = '#000';
}
// The gate. Palace-built, shut, and it does not open until he is carrying
// four objects he has looked at. V1 is the barrier only β the four marks
// that state its terms, and the opening, are V5.
//
// NOT ONE BLACK SLAB. The first pass was, and the gatekeeper standing at
// his own post disappeared into it exactly the way the prince disappeared
// into the houses. So it is built like a gate: black posts, a black lintel
// and black wall running off past the frame, with the shut LEAF between
// them at the same set-back value the houses use. Everything that stands in
// front of the gate stands in front of the leaf, and reads.
const gx = Math.round(s.gateX - camX);
if (gx > W + 520) return;
// 420, not 280. At 280 the opening was 560px wide and the prince stopped
// INSIDE the left post β pure black behind a pure black figure again, one
// layer further in. The gateway has to be wide enough to stand in.
const half = 420, post = 100;
const leafTop = 200, leafBot = GROUND_Y + 40;
// THE LEAF LIFTS. Straight up and out of sight, the way a thing that heavy
// is raised rather than swung. It is the only moving piece of the village
// and it moves once.
const open = s.gate?.open ?? 0;
const lift = open * (leafBot - leafTop + 40);
c.save();
c.beginPath();
c.rect(gx - half, leafTop, half * 2, leafBot - leafTop);
c.clip();
c.fillStyle = s.houseTint;
c.fillRect(gx - half, leafTop - lift, half * 2, leafBot - leafTop);
// FOUR MARKS, and this is how the gate states its terms. A door that simply
// does nothing reads as broken; four empty sockets read as four of
// something wanted. They are on the GATE rather than in the map because
// they need no interaction, and because being blocked is the moment the
// player asks the question.
//
// Live, not latched: it counts what he is carrying and has looked at, so
// putting one down puts a mark out again.
const filled = s.gate?.filled ?? 0;
// HIGH ON THE LEAF, well above head height. At chest height the leftmost
// one sat behind the gatekeeper and the prince standing in front of him,
// so the gate asked for three things instead of four.
const mw = 130, mh = 150, mgap = 80;
// OFF GATE_WANTS, not off a literal four. The leaf and the requirement
// used to be two separate fours and changing one left the other lying.
const n = TUNING.GATE_WANTS;
const row = gx - ((mw * n + mgap * (n - 1)) / 2);
const my = leafTop + 70 - lift;
// AN EMPTY SOCKET BREATHES, the same as an empty patch on a door and for
// the same reason. Four holes in a leaf have always asked their own
// question β but only of somebody who already knows the answer is a key.
// The first person to reach this gate carrying four of them put one in by
// accident, pressing the button to see what it would do.
const sock = 1 - TORCH.KEPT_PULSE_DEPTH *
(0.5 - 0.5 * Math.cos((s.clock ?? 0) * Math.PI * 2 * TORCH.KEPT_PULSE_HZ));
for (let i = 0; i < TUNING.GATE_WANTS; i++) {
const mx = row + i * (mw + mgap);
if (i >= filled) {
const scx = mx + mw / 2;
const scy = my + mh / 2;
const sr = Math.max(mw, mh) * 0.95;
const sg = c.createRadialGradient(scx, scy, 0, scx, scy, sr);
sg.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * sock * 0.8).toFixed(3)})`);
sg.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`);
const wasOp = c.globalCompositeOperation;
c.globalCompositeOperation = 'lighter';
c.fillStyle = sg;
c.fillRect(scx - sr, scy - sr, sr * 2, sr * 2);
c.globalCompositeOperation = wasOp;
}
c.fillStyle = '#000';
c.fillRect(mx, my, mw, mh);
if (i >= filled) {
// EMPTY: A KEY-SHAPED HOLE, not a rectangular one.
//
// Four rectangles ask a question and do not say what the answer is.
// Somebody arrived here carrying four keys and put one in by accident,
// pressing the button to see what it would do. A lock the shape of its
// key needs no telling β and it is the same idea as the mark on a door
// being the shape of the page.
if (s.socketArt && s.keyArt) {
const ka = s.keyArt;
const kin = ka.ink ?? { x: 0, y: 0, w: ka.width, h: ka.height };
const kk = Math.min((mw - 30) / kin.w, (mh - 24) / kin.h);
c.drawImage(s.socketArt,
mx + (mw - kin.w * kk) / 2 - kin.x * kk,
my + (mh - kin.h * kk) / 2 - kin.y * kk,
ka.width * kk, ka.height * kk);
} else {
c.fillStyle = s.houseTint;
c.fillRect(mx + 12, my + 12, mw - 24, mh - 24);
}
}
}
c.restore();
c.fillStyle = '#000';
c.fillRect(gx - half - post, 120, post, leafBot - 120);
c.fillRect(gx + half, 120, post, leafBot - 120);
c.fillRect(gx - half - post - 20, 80, (half + post) * 2 + 40, 130);
c.fillRect(gx + half + post, 0, W + 520 - (gx + half + post), GROUND_Y + 200);
}
/**
* The map, and it is a prop rather than a screen.
*
* A sheet anchored at the pose's mapPivot, the same way the flame is anchored
* at handPivot β which is what lets the sheet be LIT while he stays a
* silhouette inside his own torchlight. In pass 1 it is a black rectangle in
* his hand, because a sheet of paper is a thing that blocks light. In pass 2
* it is paper with a village drawn on it.
*
* IT RECORDS AND IT NEVER REASONS. Five plots along a street in the order he
* walks past them; a building appears when he has lit its door, and its
* object drops into the slot beneath when he has taken it AND looked at it.
* Anything taken blind sits along the top, filed against nothing.
*
* No error state, no completion state, and an empty slot is never flagged.
* The empty slot is the finding.
*/
// THE PAGE IN HIS HAND IS GONE, and it used to be drawn here.
//
// It came in with the top-page model, to say what he was holding without
// making him raise anything. It worked, and it looked wrong: a small pale
// shape at hip height on a black silhouette, and there is exactly one thing
// a person reads that as.
//
// It is also redundant now. When it was added the pile was still called
// "pages" and keys were an invisible number; the inventory holds everything
// he has and choosing in it is how he picks what to use. A shape at the hip
// was answering a question the inventory now answers properly.
//
// `state.handPage` stays. It is still the top of the pile, still what E acts
// on at a door, and still what the wall previews. It is simply not drawn on
// his body.
/**
* THE SKY GOING OFF, and where it is drawn is the whole trick.
*
* AFTER the ground and the houses, BEFORE the people. So the sky, the hills,
* the road and the walls go white and every figure in front of them stays
* pure black β which is the picture this game is already built out of, at
* maximum contrast, for a quarter of a second. You see the entire street and
* everyone standing in it, with no torch.
*
* A wash over the FINISHED frame would lift the silhouettes with everything
* else and flatten the one thing that matters. This is the difference
* between lightning and a camera flash.
*/
/**
* THE BOLT, and it is the only thing in this game that is light.
*
* Everything else is a black shape stopping light. This is the source, so it
* is the one drawing that is never silhouetted and never tinted β it goes
* down exactly as it was drawn, pale blue-white on a dark sky.
*
* BEFORE THE HOUSES, so a building stands in front of it the way a building
* stands in front of weather. And before the flash, so the white sky washes
* it out at the peak rather than the bolt sitting on top of a blown-out sky
* β which is the order these two things happen in.
*
* SMOOTHED, which is the opposite of what pixel art usually wants. At the
* size it has to be to read, nearest-neighbour steps are obvious and there is
* no other pixel art anywhere in this game. Interpolated, the steps become
* the soft glow a bolt has anyway.
*/
function drawBolt(c, s) {
const clip = s.lightning;
const ms = s.flashMs ?? -1;
if (!clip || !(ms >= 0)) return;
// Number, not NaN. These constants live on FLASH and were read off TUNING
// once: `ms / undefined` is NaN, every comparison against NaN is false, so
// it fell through to `frames[NaN]` and returned without drawing and
// without complaining. A guard that cannot be satisfied looks exactly like
// an effect that is switched off.
const i = Math.floor(ms / FLASH.BOLT_FRAME_MS);
if (!Number.isFinite(i) || i < 0 || i >= clip.count) return;
const frame = clip.frames[i];
if (!frame) return;
const h = FLASH.BOLT_H;
const w = h * (clip.width / clip.height);
const was = c.imageSmoothingEnabled;
c.imageSmoothingEnabled = true;
c.drawImage(frame.lit, Math.round(W * FLASH.BOLT_AT - w / 2), 30, w, h);
c.imageSmoothingEnabled = was;
}
/**
* A warm pulse at a door, OUT IN THE DARK, with the torch down β for a key
* whose door somebody opened, and nothing else.
*
* The key only ever existed inside the torchlight, because everything in a
* doorway is drawn in the lit pass. So the street said nothing: you had to
* already be holding the light on a door to learn there was anything at it,
* which is the wrong way round β the light is what you spend, and something
* has to be worth spending it on.
*
* THE EMPTY SLOT USED TO PULSE HERE TOO, and does not any more. It is a
* value, not a glow: a mark on a wall that breathes in the dark is UI, and
* the loom house's slot, which nothing ever fills, would have been asking
* for the whole level in a voice the game uses for "take this".
*
* THE DROPPED SCRAPS HAVE ALWAYS DONE THIS. "A warm pulse out there is a
* thing you notice from across the screen, and it is the only reason to walk
* over; the torch is what turns the glowing dot into a page with writing on
* it." Same tuning value, same rhythm, same bargain β the glow says HERE and
* only the torch says WHAT.
*
* Glow only, no art. In the dark a black key on a wall at luminance 26 is
* nothing; the pool is the whole signal.
*/
function drawDoorGlow(c, s, clip, house, x, y, clock) {
// THE SAME GLOW A DROPPED PAGE HAS, off the same gradient and the same
// maths β because the first version was not. It took the rect's LONGEST
// side, multiplied by KEPT_GLOW_R and then again by 1.6, and drew no object
// at all: a 350px formless brown smear on a wall. The tuning note says
// exactly why that is wrong β "multiples of the object's HALF-WIDTH.
// Tighter = hotter" β and the pool is meant to have a thing sitting in it.
//
// So: half the art's width, one pool, and the black shape inside it. A
// page-shaped dark mark in a hot spot, which is a dropped scrap with a nail
// through it.
const pulse = (1 - TORCH.KEPT_PULSE_DEPTH *
(0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ))) *
TORCH.KEPT_GLOW_DARK;
const at = (rect, art) => {
if (!rect || !art) return;
const [rx, ry, rw, rh] = rect;
const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
const k = Math.min(rw / ink.w, rh / ink.h);
const w = ink.w * k;
const h = ink.h * k;
const dx = x + rx + (rw - w) / 2;
const dy = y + ry + (rh - h) / 2;
const sx = dx + w / 2;
const sy = dy + h / 2;
// Half the drawn width, exactly as the dropped page measures it.
const R = (w / 2) * TORCH.KEPT_GLOW_R;
c.save();
c.globalCompositeOperation = 'lighter';
c.globalAlpha = pulse;
c.setTransform(R, 0, 0, R, sx, sy);
c.fillStyle = keptGlow;
c.fillRect(-1, -1, 2, 2);
c.restore();
// And the thing itself, black, sitting in its own pool β the object is
// what makes it a page on a wall rather than a light on a wall.
const frame = art.frames[0];
if (frame) {
c.drawImage(frame.dark, dx - ink.x * k, dy - ink.y * k,
art.width * k, art.height * k);
}
};
// A key whose door somebody opened.
if (house.open && !house.keyTaken) at(clip.keyAt?.[house.variant], s.keyArt);
}
/**
* A low mist on the ground: three layers of it, drifting, in front of
* everybody.
*
* THE FIRST VERSION WAS A FLAT LINEAR GRADIENT and it was judged and
* rejected in one sentence: "it looks like the sky." Which it did, and for
* the obvious reason β a smooth band of even tone that never moves is
* shading, not weather. Two things separate fog from a wash, and it needs
* both: it is PATCHY, and it MOVES.
*
* So it is built the way everything else in this file is built: one soft
* blob gradient made once in unit space, then stamped with setTransform at
* whatever size and place each puff wants β the same trick the dropped
* pages' halo uses, for the same reason, which is that building a gradient
* per puff per frame is forty gradients a frame for nothing.
*
* THREE LAYERS, AND THE PARALLAX IS THE POINT. Each drifts at its own rate
* against the camera and slides on its own slow clock, so the near mist
* overtakes the far mist and the bank never reads as one sheet. Position and
* size come off a hash of the puff's index, so it is patchy in a way that is
* the same every run and never repeats inside a screen.
*
* IN FRONT AND NOT BEHIND. Fog behind everybody is a backdrop; fog in front
* of them is weather, and only weather takes anybody's feet.
*/
const fogPuff = octx.createRadialGradient(0, 0, 0, 0, 0, 1);
fogPuff.addColorStop(0.00, 'rgba(150,168,186,0.42)');
fogPuff.addColorStop(0.30, 'rgba(142,160,180,0.30)');
fogPuff.addColorStop(0.58, 'rgba(132,150,170,0.12)');
fogPuff.addColorStop(0.82, 'rgba(126,144,164,0.03)');
fogPuff.addColorStop(1.00, 'rgba(126,144,164,0)');
// LUMPS, NOT A BAND. The first pass spaced the puffs closer together than
// they were wide, so they merged into one even wash β which is the flat
// gradient again wearing a costume. These are spaced wider than their own
// radius, so the top edge dips between them and you can see the crests.
//
// far, mid, near: [parallax against the camera, drift px/s, puffs across the
// view, wide, tall, how far the centre sinks below the ground line, weight]
const FOG_BANDS = [
[0.30, 7, 4, 0.62, 0.78, 0.10, 0.50],
[0.62, 16, 5, 0.54, 0.58, 0.18, 0.78],
[1.00, 34, 7, 0.44, 0.42, 0.26, 1.00],
];
function drawFog(c, camX, clock) {
const k = TONE.FOG;
if (!(k > 0)) return;
const H0 = TONE.FOG_H;
const was = c.globalAlpha;
for (let b = 0; b < FOG_BANDS.length; b++) {
const [par, drift, n, wide, tall, sink, weight] = FOG_BANDS[b];
const span = W / n;
const shift = camX * par + clock * drift;
const period = span * (n + 2);
for (let i = -1; i <= n; i++) {
// Deterministic per puff, so the bank is the same bank every run and
// no two in a band share a size, a height or a spacing.
const id = i + b * 97;
const h1 = ((Math.sin(id * 12.9898) * 43758.5453) % 1 + 1) % 1;
const h2 = ((Math.sin(id * 78.233) * 12345.6789) % 1 + 1) % 1;
const h3 = ((Math.sin(id * 39.425) * 24634.6345) % 1 + 1) % 1;
const rx = span * wide * (0.74 + h1 * 0.52);
const ry = H0 * tall * (0.62 + h2 * 0.76);
// The extra h3 jitter on x is what stops the crests arriving on a beat.
const slot = i * span + (h3 - 0.5) * span * 0.55;
const x = ((slot - shift) % period + period) % period - span;
const y = GROUND_Y + ry * sink + (h2 - 0.5) * H0 * 0.10;
c.globalAlpha = k * weight;
c.save();
c.setTransform(rx, 0, 0, ry, x, y);
c.fillStyle = fogPuff;
c.fillRect(-1, -1, 2, 2);
c.restore();
}
}
c.globalAlpha = was;
}
/**
* THE ARM THAT HOLDS THE TORCH, and it is a placeholder.
*
* Her standing drawing has both arms down, so the flame was pinned to the
* far hand at hip height and a burning torch sat at his crotch. This draws
* that same arm jointed, from the geometry the manifest publishes, so it can
* go up: two tapered bones and a fist, angles lerped by the raise.
*
* LIVE RATHER THAN BAKED TO FRAMES, because the raise is a smooth 250ms ramp
* and six frames would step through it. Delete the whole thing the day the
* raised-torch pose is drawn.
*
* It takes the passes the way the body does: flat black in the dark, and in
* the lit pass white with an ink line round it, which is how her drawing is
* built.
*/
/**
* A TAPERED LIMB, filled, the way gen_placeholders.js draws every other one.
*
* Stamped rather than stroked: a quad between each pair of spine points at
* their own half-widths, plus a disc at each point. The discs round the
* joints and paper over the pinch an offset curve gets at a sharp elbow, and
* since it is all one flat black fill the overlap costs nothing.
*
* A constant-width stroke was the first attempt and it read as a stick,
* because an arm is not a constant width: it is thick at the shoulder, bulges
* at the bicep, narrows at the elbow, bulges again at the forearm and ends
* thin at the wrist. That profile IS the difference between a limb and a line.
*/
function fillLimb(c, spine, w) {
for (let i = 0; i < spine.length - 1; i++) {
const [ax, ay] = spine[i];
const [bx, by] = spine[i + 1];
const dx = bx - ax, dy = by - ay;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len, ny = dx / len;
c.beginPath();
c.moveTo(ax + nx * w[i], ay + ny * w[i]);
c.lineTo(bx + nx * w[i + 1], by + ny * w[i + 1]);
c.lineTo(bx - nx * w[i + 1], by - ny * w[i + 1]);
c.lineTo(ax - nx * w[i], ay - ny * w[i]);
c.closePath();
c.fill();
}
for (let i = 0; i < spine.length; i++) {
c.beginPath();
c.arc(spine[i][0], spine[i][1], w[i], 0, Math.PI * 2);
c.fill();
}
}
function drawTorchArm(c, s, mode, px, camX, face, eased) {
const arm = s.torchArm;
if (!arm || eased <= 0.001) return;
const p = armPose(arm, eased);
const x0 = Math.round(px - camX);
const y0 = GROUND_Y;
const at = (o) => [x0 + face * o[0], y0 + o[1]];
const S = at(p.shoulder), E = at(p.elbow), Wr = at(p.wrist), Fi = at(p.fist);
// Midpoints carry the bicep and the forearm, bowed a little off the bone so
// the limb is not two straight runs meeting at a corner.
const bow = (a, b, k) => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const len = Math.hypot(dx, dy) || 1;
return [(a[0] + b[0]) / 2 - (dy / len) * k * face,
(a[1] + b[1]) / 2 + (dx / len) * k * face];
};
const spine = [S, bow(S, E, 2.5), E, bow(E, Wr, -2), Wr];
// shoulder, bicep, elbow, forearm, wrist -- HALF-widths, from the manifest.
const w = arm.widths;
c.save();
// FLAT BLACK IN BOTH PASSES. He is never lit by his own torch -- his body
// goes into the lit pass with its DARK art for exactly that reason -- and
// an arm painted white with an ink line round it was a pale stick glowing
// on a black silhouette. `mode` is taken and ignored on purpose, so the
// call sites read like every other draw in here.
c.fillStyle = '#000';
fillLimb(c, spine, w);
// THE BRAND, drawn here rather than baked into the fire sprite: the fire is
// a photograph of fire and the stick under it is ours, and they move on
// different rules -- the stick follows the wrist, the fire just sits on it.
const B = at(p.butt), Hd = at(p.head);
const ux = (Hd[0] - B[0]) / (arm.shaftLen + arm.bindLen);
const uy = (Hd[1] - B[1]) / (arm.shaftLen + arm.bindLen);
const sh = [B[0] + ux * arm.shaftLen, B[1] + uy * arm.shaftLen];
fillLimb(c, [B, sh], arm.shaftW);
fillLimb(c, [sh, Hd], [arm.bindW, arm.bindW]);
// The fist LAST, so his hand closes over the shaft instead of behind it.
const gx = Fi[0] - Wr[0], gy = Fi[1] - Wr[1];
c.translate(Fi[0], Fi[1]);
c.rotate(Math.atan2(gy, gx));
c.beginPath();
c.ellipse(0, 0, arm.hand * 1.3, arm.hand, 0, 0, Math.PI * 2);
c.fill();
c.restore();
}
/**
* The sky losing its light as he goes deeper.
*
* The canopy already closes on depthAt(). The sky behind it is pixel
* identical from the first step to the gate, so walking a hundred seconds
* into something never looks like walking into anything. One wash over the
* sky band, on the curve that already exists.
*/
function drawSkySink(c, worldX) {
const k = TONE.SKY_SINK;
if (!(k > 0)) return;
c.fillStyle = `rgba(6,10,18,${(depthAt(worldX) * k * 0.55).toFixed(3)})`;
c.fillRect(0, 0, W, GROUND_Y);
}
function drawFlash(c, v) {
if (!(v > 0.004)) return;
c.fillStyle = `rgba(${FLASH.RGB},${v.toFixed(3)})`;
c.fillRect(0, 0, W, H);
}
function drawSheet(c, s, mode, px, camX, face) {
const sheet = s.sheetClip;
const pose = s.prince.clip;
if (!sheet || !pose?.mapPivot || !s.sheaf?.up) return;
const raise = s.torch.raise;
if (raise < 0.02) return;
const ox = (pose.mapPivot[0] - pose.pivotX) * face;
const oy = pose.mapPivot[1] - pose.pivotY;
const sx = Math.round(px - camX + ox);
const sy = Math.round(GROUND_Y + oy);
// Held up as the arm comes up, rather than appearing whole.
const w = sheet.width * raise;
const h = sheet.height * raise;
// Mirrored with him, so it is always between him and the camera.
const left = face >= 0 ? sx : sx - w;
// ALWAYS THE BLANK FACE NOW. The other face has a street and five plots
// printed on it and is not drawn any more β see the note on `sheetFace`.
const faces = sheet.sheetFace ?? { map: 0, blank: 0 };
const sheetFrame = sheet.frames[faces.blank] ?? sheet.frames[0];
c.drawImage(sheetFrame[mode], left, sy - h, w, h);
if (mode !== 'lit' || raise < 0.98) return;
const x0 = left;
const y0 = sy - h;
const pg = s.page ? s.page() : null;
// HOW FAR DOWN THE PILE HE IS. Plain "3/7" in the corner, and it replaced a
// row of little marks along the bottom edge that read as a second index
// sitting under the first one. A player called that row confusing and was
// right: two rows of small marks on one sheet, meaning different things.
if (s.sheaf.pages.length) {
const fs = Math.round(sheet.height * 0.085);
c.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`;
c.textAlign = 'right';
c.textBaseline = 'top';
c.fillStyle = 'rgba(0,0,0,0.5)';
c.fillText(`${s.sheaf.index + 1}/${s.sheaf.pages.length}`,
x0 + sheet.width - 14, y0 + 12);
c.textAlign = 'left';
c.textBaseline = 'alphabetic';
}
// AN EMPTY PILE IS BLANK PAPER, which is the true answer to "what am I
// carrying" before he has picked anything up.
if (!pg) return;
// THE SHEET STAYS WHERE IT IS. WHAT IS DRAWN ON IT CHANGES. He is holding
// up a pile of loose paper in one hand, not opening a book, so the paper
// does not move β only the page on top of it does.
//
// The sheet carries the SHAPE and the band carries the WORDS. A letter is a
// whole page, a fragment is a torn one, and neither is legible at this size
// on purpose: reading happens in the band and the drawing only has to say
// which kind of thing he is holding.
drawSheetPage(c, s, sheet, x0, y0, pg);
drawFiledLine(c, s, sheet, x0, y0, pg);
drawLetterHouse(c, s, sheet, x0, y0, pg);
}
/**
* WHICH HOUSE THIS LETTER CAME OFF.
*
* There was never a puzzle in hiding it. A letter IS the house's β he took it
* off that wall β and the deduction is which FRAGMENT belongs under it. All
* five letters being one shape only made the player do bookkeeping: take
* three, and the pile is three identical pages with no way back to the door
* each came from.
*
* The torn pages stay identical, and that is the opposite case: telling those
* apart by their outline would hand over the answer without reading a word.
*/
function drawLetterHouse(c, s, sheet, x0, y0, pg) {
if (!pg || pg.kind !== 'letter') return;
const house = s.houses?.[pg.house];
// WHERE IT CAME FROM. The only letter he carries is his orders, and they
// came from the palace. House letters and the note on the tree stay where
// they were left, so they are never a page here β but a letter with a
// house behind it would still draw that house.
const hc = house ? s.houseClip : s.palaceClip;
if (!hc) return;
const frame = house ? house.variant : 0;
const bx = x0 + sheet.width / 2;
const base = y0 + sheet.height - 14;
const k = Math.min(58 / hc.width, 38 / (hc.height * 0.86));
const dw = hc.width * k;
const dh = hc.height * k;
c.drawImage(hc.frames[frame].dark, bx - dw / 2, base - 2 - dh, dw, dh);
c.fillStyle = '#000';
c.fillRect(Math.round(bx - 32), Math.round(base - 2), 64, 2);
}
/**
* WHETHER THIS PAGE IS FILED, AND WHERE, along the sheet's bottom edge.
*
* It used to be visible only on the map page, which meant paging past a
* fragment told you nothing about what you had already claimed with it. Now
* it travels with the page, because "have I used this one yet" is a question
* you ask WHILE scrolling.
*
* A filed page shows the house it went to, drawn from that house's own art.
* An unpinned fragment shows the bare patch β the same mark that is waiting
* beside every door he has taken a letter off. Still no verdict anywhere: it
* says where he put it, never whether that was right.
*/
function drawFiledLine(c, s, sheet, x0, y0, pg) {
if (!pg || pg.kind !== 'fragment') return;
if (s.canMark) return; // the destination is being drawn instead
const house = s.filedUnder ? s.filedUnder(pg) : null;
const bx = x0 + sheet.width / 2;
const base = y0 + sheet.height - 14;
if (house && s.houseClip) {
const hc = s.houseClip;
const k = Math.min(58 / hc.width, 38 / (hc.height * 0.86));
const dw = hc.width * k;
const dh = hc.height * k;
c.drawImage(hc.frames[house.variant].dark, bx - dw / 2, base - 2 - dh, dw, dh);
c.fillStyle = '#000';
c.fillRect(Math.round(bx - 32), Math.round(base - 2), 64, 2);
return;
}
// NOT PINNED ANYWHERE: the bare patch, exactly as it looks on a wall he
// has taken a letter off. It was a stroked box while the wall had a frame
// on it; the wall has a mark now, so this is a mark.
c.fillStyle = '#6f6553';
c.fillRect(Math.round(bx - 19), Math.round(base - 36), 38, 34);
c.fillStyle = '#000';
c.fillRect(Math.round(bx) - 1, Math.round(base - 36) + 2, 3, 4);
}
/**
* A letter or a fragment, drawn large in the middle of the sheet.
*
* A page he has not looked at is drawn as PAPER WITH NOTHING ON IT YET β the
* silhouette of the thing without its contents. It fills in when he has held
* it in his own light long enough to have read it, which is the same cost
* every other act of looking in this game charges.
*/
function drawSheetPage(c, s, sheet, x0, y0, pg) {
const whole = pg.kind === 'letter' || pg.kind === 'key';
const known = s.pageSeen ? s.pageSeen(pg) : true;
// A page he has not turned to is the SHAPE OF THE THING WITHOUT ITS
// CONTENTS β the same contour with nothing written inside it. Drawn from
// its own art rather than from the silhouette pass, which would put a solid
// black page on the paper and read as a hole in the sheet.
// A KEY IS IN HERE TOO. It carries nothing to read and never will, but a
// thing you are carrying that you cannot look at is a thing you do not
// know you have β which is exactly what happened: somebody collected four
// and only found out at the gate, by pressing the button to see.
const art = pg.kind === 'key' ? s.keyArt
: known ? (whole ? s.letterArt : s.kept) : s.pageBlank;
if (!art) return;
const i = pg.kind === 'key' ? 0
: known ? (pg.house ?? pg.kept ?? 0)
: (art.pageFace ? (whole ? art.pageFace.whole : art.pageFace.torn) : 0);
const frame = art.frames[i];
if (!frame) return;
// FITTED BY ITS INK, NOT BY ITS CANVAS. The page art uses about half of its
// 96x96 canvas, so scaling the canvas to the sheet drew a small page in a
// large empty frame. The loader measures where the drawing actually is.
//
// As big as the paper will take, then: it is the page he is holding up, not
// an icon of one, and the row of marks along the bottom edge is the only
// thing it has to stay clear of.
//
// AND IT MAKES ROOM WHEN THERE IS SOMEWHERE FOR IT TO GO. Stood at a lit
// door holding a page that can be filed, the sheet shrinks the page and
// draws that house's plot beside it: the paper itself says "this one,
// here", with no words and no verdict. It shows what pressing take would
// do, never whether doing it is right.
//
// It is the only moment the affordance can be taught, because it is the
// only moment the action exists.
const target = s.canMark && s.markHouse != null ? s.houses[s.markHouse] : null;
const room = target ? 0.56 : 1;
// AND IT GIVES UP THE BOTTOM OF THE SHEET when there is a filed line to
// draw there. Fitted to the full height, the page sat on top of the mark
// saying where it had gone.
const foot = !target && (pg.kind === 'fragment' || pg.kind === 'letter') ? 48 : 0;
const box = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
const k = Math.min((sheet.width - 30) * room / box.w,
(sheet.height - 34 - foot) / box.h);
const dw = art.width * k;
const dh = art.height * k;
// Centre the INK in the room it has, which is not the same as centring the
// canvas when the drawing sits off to one side of it.
const dx = x0 + 15 + ((sheet.width - 30) * room - box.w * k) / 2 - box.x * k;
const dy = y0 + (sheet.height - 14 - foot - box.h * k) / 2 - box.y * k;
c.drawImage(frame.lit, dx, dy, dw, dh);
if (target) drawDestination(c, s, sheet, x0, y0, target);
}
/**
* The plot this page would go under, drawn small beside it: the building, the
* street it stands on, and the empty slot below with a page waiting in it.
*
* A MINIATURE OF WHAT THE MAP WILL LOOK LIKE AFTERWARDS, which is why it
* needs no label. Same drawing, same sheet, one step ahead.
*/
function drawDestination(c, s, sheet, x0, y0, house) {
const pw = 56, ph = 38;
const cx = x0 + sheet.width * 0.775;
const top = y0 + (sheet.height - 14 - (ph + 44)) / 2;
// The house, from its own art, so it is the building he is standing at.
if (s.houseClip) {
const hc = s.houseClip;
const k = Math.min(pw / hc.width, ph / (hc.height * 0.86));
const dw = hc.width * k;
const dh = hc.height * k;
c.drawImage(hc.frames[house.variant].dark, cx - dw / 2, top + ph - dh, dw, dh);
}
c.fillStyle = '#000';
c.fillRect(Math.round(cx - pw / 2), Math.round(top + ph), pw, 2);
// And under it, THE BARE PATCH β the same mark that is on that house's
// wall right now, with a page going into it. The sheet and the wall say the
// same thing in the same drawing, which is what makes either readable.
const fy = top + ph + 12;
c.fillStyle = '#6f6553';
c.fillRect(Math.round(cx - 17), Math.round(fy), 34, 30);
c.fillStyle = '#000';
c.fillRect(Math.round(cx) - 1, Math.round(fy) + 2, 3, 4);
if (s.pageBlank?.pageFace) {
const torn = s.pageBlank.frames[s.pageBlank.pageFace.torn];
const ib = s.pageBlank.ink ?? { x: 0, y: 0, w: s.pageBlank.width, h: s.pageBlank.height };
if (torn) {
const k = Math.min(24 / ib.w, 22 / ib.h);
c.globalAlpha = 0.55;
c.drawImage(torn.lit,
cx - (ib.w * k) / 2 - ib.x * k,
fy + 15 - (ib.h * k) / 2 - ib.y * k,
s.pageBlank.width * k, s.pageBlank.height * k);
c.globalAlpha = 1;
}
}
}
/**
* THE LETTER ON THE DOOR, and it never comes down.
*
* Letters stay where they were left; fragments come off bodies. He does not
* pocket a stranger's mail, and that puts the deduction where it belongs:
* the question is nailed in front of him, the fragment is in his hand, and
* the door is where he judges.
*
* NO GLOW. The objects on the ground pulse because a dark street gives no
* other reason to walk over to one; a door is already that reason, and a glow
* bleeding through an unlit house would break the one rule the whole look
* rests on β that a building is a black mass until he lights it.
*/
// The same value a lit doorway is drawn at, and it has to be: a page held in
// that light is the same paper as the light falling out of the room.
const DOORLIGHT = '#d8c3a0';
/**
* THE SLOT, and it is what a deleted frame left behind.
*
* Their own paper hung on a second nail under the letter left for them, and
* kept the weather off the wall behind it. The fragment on a body is that
* paper, and the lighter patch is where it goes back.
*
* IT IS A VALUE, NOT AN OUTLINE. No border, no socket, no glow, and it must
* never read as UI. The palette makes that a narrow window: the wall sits at
* 26 and nothing may live between 30 and 90, so a patch that reads at all has
* to clear 90. This is just above that floor and far under the doorway's 197,
* which is the whole of the space the rule leaves.
*/
const PATCH = '#6f6553'; // lum 101 β over the forbidden zone, under the door
/** Where a rect on the wall lands on screen, for whatever is hanging in it. */
function nailBox(rect, x, y, art) {
if (!rect || !art) return null;
const [nx, ny, nw, nh] = rect;
const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
const k = Math.min(nw / ink.w, nh / ink.h);
const w = ink.w * k;
const h = ink.h * k;
return { k, w, h, px: x + nx + (nw - w) / 2, py: y + ny + (nh - h) / 2,
rx: x + nx, ry: y + ny, rw: nw, rh: nh, ink };
}
/**
* WHAT IS ON THE WALL BESIDE A DOOR, top to bottom: the letter somebody
* left, and under it the slot β the patch, the bare nail, and whatever he
* pins there. The question above the answer, and the eye reads down.
*
* LIT PASS ONLY, so all of it lives and dies with the torch like everything
* else in a doorway.
*
* THE TREE HAS NO SLOT (`slot: false`). It carries the note and nothing
* else: nothing is ever pinned back to a tree.
*/
function drawNail(c, s, clip, house, x, y, { slot = true } = {}) {
// 1. THE LETTER. Always: nothing takes it down.
const paper = s.letterArt;
const written = paper?.frames[house.index];
const lb = nailBox(clip.letterAt?.[house.variant], x, y, paper);
if (lb && written) {
// PAPER FIRST. The page art is contour and writing on nothing, so on a
// dark wall it came out dark-on-dark. It is a sheet of paper in
// torchlight, so it gets the value a lit doorway gets.
c.fillStyle = DOORLIGHT;
c.fillRect(Math.round(lb.px - 3), Math.round(lb.py - 3),
Math.round(lb.w + 6), Math.round(lb.h + 6));
c.drawImage(written.lit, lb.px - lb.ink.x * lb.k, lb.py - lb.ink.y * lb.k,
paper.width * lb.k, paper.height * lb.k);
}
if (!slot) return;
// 2. THE SLOT, UNDER IT. Flat, borderless, and slightly bigger than the
// page that made it, because paper curls and the weather got in at the
// edges. There from the first light, whether or not anything is in it.
const rect = clip.slotAt?.[house.variant];
if (!rect) return;
const [nx, ny, nw, nh] = rect;
const px0 = Math.round(x + nx);
const py0 = Math.round(y + ny);
// IT DOES NOT BREATHE. It used to, while it was empty: the patch only
// appeared once the letter came off, and nothing walked a player over to a
// door to find it. It is under the letter from the first light now, and a
// bare nail under a letter asks its own question. A pulse on a wall reads
// as UI, and the loom house's empty slot has to read as a letter nobody
// answered, not as a button nobody pressed.
//
// IT IS THE SHAPE OF WHAT GOES IN IT.
//
// A rectangle says "a place" without saying what for, and that is exactly
// how this failed: somebody lit a door, saw a slightly cleaner rectangle,
// and had no reason to connect it to the torn paper in their pocket. Cut
// to the page's own outline it says both things at once, with no words.
//
// The whole letter and the torn scrap share one contour but for the tear,
// so this doubles as the mark the letter left AND the hole the scrap fits.
if (s.patchArt && s.kept) {
const pk = s.kept;
const pink = pk.ink ?? { x: 0, y: 0, w: pk.width, h: pk.height };
const pkk = Math.min(nw / pink.w, nh / pink.h);
c.drawImage(s.patchArt,
px0 + (nw - pink.w * pkk) / 2 - pink.x * pkk,
py0 + (nh - pink.h * pkk) / 2 - pink.y * pkk,
pk.width * pkk, pk.height * pkk);
} else {
c.fillStyle = PATCH;
c.fillRect(px0, py0, nw, nh);
}
// The nail itself, bare now. Four pixels of iron, and the only reason it is
// drawn at all is that a patch with nothing at the top of it reads as a
// stain rather than as a place something hung.
c.fillStyle = '#000';
c.fillRect(Math.round(px0 + nw / 2) - 2, py0 + 8, 4, 5);
// 2b. AND IF HE IS STANDING HERE HOLDING A PAGE, THE WALL SHOWS WHERE IT
// WOULD GO. A ghost of the exact page in his hand, faint, in the patch.
//
// THIS IS WHY IT IS HERE. A ten-year-old played the level, picked the
// scraps up, and never worked out that they go on a wall β and she was
// right to miss it, because nothing said so. The patch was made
// deliberately quiet ("a value, not an outline, it must not read as UI")
// and it succeeded: it is a rectangle of slightly cleaner wall and it
// asks nothing. The only thing that had ever said "[E] PIN IT HERE" was
// the debug readout, which was on the whole time anybody tested it.
//
// NOT A NEW IDEA, AND NOT A PROMPT. The sheaf sheet has always drawn this
// exact ghost β a page at 0.55 alpha, going into a plot β to say "this
// one, here". It was on the paper in his hand instead of on the wall he
// was looking at. This puts it where his eyes are.
//
// AND IT IS NOT A VERDICT. It appears for the wrong page just as readily,
// because it is a preview of an act and not an opinion about it.
if (house.filed == null) {
if (!s.canPin || s.markHouse !== house.index) return;
const held = s.handPage;
const ghostArt = held?.kind === 'fragment' ? s.kept : null;
const gf = ghostArt?.frames[held.kept];
const gb = nailBox(rect, x, y, ghostArt);
if (!gf || !gb) return;
c.globalAlpha = 0.5;
c.drawImage(gf.lit, gb.px - gb.ink.x * gb.k, gb.py - gb.ink.y * gb.k,
ghostArt.width * gb.k, ghostArt.height * gb.k);
c.globalAlpha = 1;
return;
}
// 3. AND THE PAGE HE PINNED INTO IT.
//
// It is the only acknowledgement filing gets and it must stay one. The
// patch looks exactly the same whichever page went into it and whether or
// not that was the right one β all five pages are one shape by design, so
// the wall can never be read as a verdict. What answers is a person walking
// up the street, and that takes as long as it takes.
const art = s.kept;
const frame = art?.frames[house.filed];
const b = nailBox(rect, x, y, art);
if (!b || !frame) return;
c.drawImage(frame.lit, b.px - b.ink.x * b.k, b.py - b.ink.y * b.k,
art.width * b.k, art.height * b.k);
}
/**
* THE KEY ON ITS HOOK, and the whole two-door-states idea is in this one
* function.
*
* LIT is what the torch does: you can see in. OPEN is what the person does:
* you can reach in. Both states draw the same key in the same place, and the
* only difference is the halo β flat black on the lit doorway while the door
* is shut, and the warm breathing pool once somebody has come home to it.
*
* THAT HALO IS NOT A NEW IDEA. It is what a dropped fragment on the ground
* has always done, at the same numbers, and it already means exactly one
* thing in this game: you can pick this up. So a key that starts wearing it
* needs no message, no prompt and no drawn door β a player who has taken one
* page off the ground has already been taught what it says.
*
* WHICH IS ALSO WHY NO DOOR IS DRAWN. A shut leaf across the arch would
* cover the anvil or the loom or the rack, and the room's trade is the only
* thing that says which house this is. The picture of "open" is a person
* standing in the doorway who was not standing there a minute ago.
*/
function drawRoomKey(c, s, clip, house, x, y, clock) {
if (house.keyTaken || !clip.keyAt || !s.keyArt) return;
const rect = clip.keyAt[house.variant];
const art = s.keyArt;
const frame = art.frames[0];
if (!rect || !frame) return;
const [kx, ky, kw, kh] = rect;
const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
const k = Math.min(kw / ink.w, kh / ink.h);
const w = ink.w * k;
const h = ink.h * k;
const px = x + kx + (kw - w) / 2;
// IT HANGS ON ITS OWN PLATE OF LIGHT, and that is not decoration.
//
// The key is black line work hanging inside the doorway β where the anvil
// and the oven and the buckets and the loom also are, and those are black
// too. Measured: 48% of the key is drawn over the anvil, 87% over a bucket,
// 96% over the oven dome and over the loom. A black key on a black oven is
// not a dim key, it is no key, and the first people to play this never saw
// one at all.
//
// THE LETTER SOLVED THIS ALREADY: it paints its own patch of doorway light
// before drawing the page, "because on a dark wall it came out
// dark-on-dark". Same fix, same value. The key is now always a black shape
// on light, whatever it is hanging in front of.
//
// AND IT SWINGS WHEN IT IS YOURS. A warm halo is this game's word for "you
// can pick this up", but a warm halo on an already-bright doorway barely
// moves the picture β the same mistake as a white bolt on a white sky. What
// reads against any background is motion.
const swing = house.open
? Math.sin(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ) * 4
: 0;
const py = y + ky + (kh - h) / 2 + swing;
c.fillStyle = DOORLIGHT;
c.fillRect(Math.round(px - 7), Math.round(py - 7),
Math.round(w + 14), Math.round(h + 14));
if (house.open) {
// The same breath the dropped pages take, off the same tuning values, so
// the two can never drift into meaning different things.
const pulse = 1 - TORCH.KEPT_PULSE_DEPTH *
(0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ));
const cx = px + w / 2;
const cy = py + h / 2;
const r = Math.max(w, h) * TORCH.KEPT_GLOW_R;
const g = c.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * pulse).toFixed(3)})`);
g.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`);
const was = c.globalCompositeOperation;
c.globalCompositeOperation = 'lighter';
c.fillStyle = g;
c.fillRect(cx - r, cy - r, r * 2, r * 2);
c.globalCompositeOperation = was;
}
c.drawImage(frame.lit, px - ink.x * k, py - ink.y * k, art.width * k, art.height * k);
}
// The keys, in the order a hand finds them. Not a narrator: it names buttons,
// which is the one thing in this game that is allowed to be told rather than
// discovered, because a button that cannot be found does not exist.
//
// "E take" AND NOTHING ELSE. It said "E take/MARK" until MARK stopped being
// a thing: E is one verb now and what it does is decided by where he is
// standing, which is the whole point. A line naming a mode that no longer
// exists is worse than no line.
const HELP = 'A/D or arrows walk β hold SPACE torch β F swing β E take'
+ ' β hold Q inventory, arrows scroll β M mute β R reset β ` readout';
// Keyboards only. On a touch screen every one of these is a labelled button
// already, and a line about SPACE and backtick is noise on a device with
// neither.
const hasKeyboard = !(typeof matchMedia === 'function'
&& matchMedia('(hover: none) and (pointer: coarse)').matches);
/**
* ONE LINE, AT THE FOOT OF THE FRAME, and it gets out of the way of reading.
*
* The band is bottom-anchored and grows upward over this, so rather than
* fight it for the space they take turns: while there is a page open the keys
* fade out, because a player reading a letter is not looking for the swing
* button. Everything else in the game is discovered; this is the doorway in.
*/
function drawHelp(c, bandAlpha) {
const alpha = 0.55 * (1 - Math.min(1, bandAlpha * 1.6));
if (!hasKeyboard || alpha <= 0.02) return;
// MEASURED DOWN TO FIT, never wrapped. It is one line or it is not this
// line β the readout's help row ran 329px off the right edge once and the
// only part anybody needed was the part past the edge.
const room = W - 48;
let size = 21;
c.font = `${size}px ui-monospace, Menlo, Consolas, monospace`;
const wide = c.measureText(HELP).width;
if (wide > room) {
size = Math.floor(size * room / wide);
c.font = `${size}px ui-monospace, Menlo, Consolas, monospace`;
}
c.save();
c.globalAlpha = alpha;
c.fillStyle = 'rgba(226, 208, 178, 1)';
c.fillText(HELP, 24, H - 16);
c.restore();
}
/**
* A LINE OF THE READOUT, legible on anything underneath it.
*
* It was black at 65%, which is fine over the open sky at the start of the
* level and invisible by the end of it: the canopy closes as he walks in, and
* black text on black leaves is no text. White alone has the opposite
* problem β the sky runs to luminance 205 near the road.
*
* So: white, cut out of a dark stroke. Costs one extra pass per line, reads
* over sky, leaves, ground and the black gate alike, and this is the one
* instrument the game has.
*/
function readoutLine(c, text, x, y) {
// Saved, because this is the only place in the renderer that sets a wide
// lineWidth and everything after it would inherit one.
c.save();
// TOP, not baseline, so `y` is the top of the ink and the first line can sit
// hard against the edge of the frame. Measuring from a baseline leaves a
// quarter of the font's height of air above it that nobody asked for.
c.textBaseline = 'top';
c.lineJoin = 'round';
c.lineWidth = 4;
c.strokeStyle = 'rgba(0,0,0,0.85)';
c.strokeText(text, x, y);
c.fillStyle = 'rgba(255,255,255,0.94)';
c.fillText(text, x, y);
c.restore();
}
/** Screen y the ground TILE is drawn at, so its surface lands on GROUND_Y. */
function groundTop(clip) { return GROUND_Y - (clip.pivotY || 0); }
function drawGround(c, clip, mode, x0, x1, camX = 0) {
const tileW = clip.width;
const top = groundTop(clip);
// Tile in WORLD space so the torn edge stays pinned to the ground as the
// camera moves, rather than sliding along with the screen.
//
// Integer-aligned on purpose. The camera position is interpolated and so
// lands on fractions; drawing tiles at fractional x leaves a hairline seam
// between them where the smoothing does not quite meet. A sub-pixel camera
// is not perceptible at walking speed, a one-pixel gap in the ground is.
const cam = Math.round(camX);
const start = Math.floor((x0 + cam) / tileW) * tileW - cam;
const img = clip.frames[0][mode];
for (let x = start; x < x1; x += tileW) {
c.drawImage(img, x, top);
// The tile is shorter than the gap below the ground line, so the rest is
// filled flat. The art only has to carry the torn top edge.
if (top + clip.height < H) {
c.fillStyle = '#000';
c.fillRect(x, top + clip.height, tileW, H - top - clip.height);
}
}
}
function drawDemo(scene, alpha, stats) {
const s = scene.state;
const px = lerp(s.prince.prevX, s.prince.x, alpha);
const dx = lerp(s.drained.prevX, s.drained.x, alpha);
const radius = lerp(s.torch.prevRadius, s.torch.radius, alpha);
const cx = lerp(s.torch.prevCx, s.torch.cx, alpha);
const cy = lerp(s.torch.prevCy, s.torch.cy, alpha);
const handX = lerp(s.torch.prevHandX, s.torch.handX, alpha);
const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
const armEase = lerp(s.torch.prevEased ?? 0, s.torch.eased ?? 0, alpha);
const fade = lerp(s.prevFade, s.fade, alpha);
const torchUp = !s.prince.hasSword;
ctx.setTransform(1, 0, 0, 1, 0, 0);
// --- Pass 1: the dark world -----------------------------------------
ctx.fillStyle = BG_DARK;
ctx.fillRect(0, 0, W, H);
drawGround(ctx, s.ground, 'dark', 0, W);
if (s.drained.active) drawClip(ctx, s.drained.clip, s.drained.frame, 'dark', dx, GROUND_Y);
drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, 0, 1, s.prince.eyeDim);
drawTorchArm(ctx, s, 'dark', px, 0, 1, armEase);
if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY);
// --- Pass 2: the lit reveal ------------------------------------------
if (radius > 0.5) {
const x0 = Math.max(0, Math.floor(cx - radius) - 2);
const y0 = Math.max(0, Math.floor(cy - radius) - 2);
const x1 = Math.min(W, Math.ceil(cx + radius) + 2);
const y1 = Math.min(H, Math.ceil(cy + radius) + 2);
const rw = x1 - x0;
const rh = y1 - y0;
if (rw > 0 && rh > 0) {
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.save();
octx.beginPath();
octx.rect(x0, y0, rw, rh);
octx.clip();
octx.clearRect(x0, y0, rw, rh);
octx.fillStyle = BG_LIT;
octx.fillRect(x0, y0, rw, rh);
if (y1 > GROUND_Y) drawGround(octx, s.ground, 'lit', x0, x1);
// The Drained is the only thing with lit art in v1.
if (s.drained.active) drawClip(octx, s.drained.clip, s.drained.frame, 'lit', dx, GROUND_Y);
// The prince is not lit by his own torch. His body stays silhouette for
// the whole demo. (spec 5.3)
drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, 0, 1, s.prince.eyeDim);
drawTorchArm(octx, s, 'lit', px, 0, 1, armEase);
if (torchUp) drawClip(octx, s.flame.clip, s.flame.frame, 'lit', handX, handY);
octx.globalCompositeOperation = 'destination-in';
octx.setTransform(radius, 0, 0, radius, cx, cy);
octx.fillStyle = maskGradient;
octx.fillRect(-1, -1, 2, 2);
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.globalCompositeOperation = 'source-over';
octx.restore();
ctx.drawImage(off, x0, y0, rw, rh, x0, y0, rw, rh);
}
// The only warmth in the demo. Keep it subtle.
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.setTransform(radius * GLOW_SPREAD, 0, 0, radius * GLOW_SPREAD, cx, cy);
ctx.fillStyle = glowGradient;
ctx.fillRect(-1, -1, 2, 2);
ctx.restore();
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
if (fade > 0) {
ctx.fillStyle = `rgba(0,0,0,${fade})`;
ctx.fillRect(0, 0, W, H);
}
if (showDebug) {
ctx.font = '20px ui-monospace, Menlo, Consolas, monospace';
const lines = [
`${stats.fps} fps draw ${stats.drawMs.toFixed(2)}ms panics ${stats.panics}`,
`phase ${s.phase} loop ${s.loopElapsed.toFixed(1)}s` +
(s.lastLoopLength ? ` last ${s.lastLoopLength.toFixed(1)}s` : ''),
`torch ${(s.torch.raise * 100).toFixed(0)}% r ${radius.toFixed(1)} sword ${s.prince.hasSword ? 'out' : 'away'}`,
`drained ${s.drained.revealed ? 'REVEALED' : 'unseen'} ${s.drained.speed.toFixed(0)}px/s` +
(s.drained.stun > 0 ? ' knocked back' : '') +
(s.prince.attacking ? ' swinging' : ''),
'hold left = torch Β· tap right = swing (SPACE / F / R reset / backtick hides this)',
];
lines.forEach((line, i) => readoutLine(ctx, line, 24, 8 + i * 26));
}
}
// --- the level -----------------------------------------------------------
// Same two passes, same gradients, same offscreen. The only new idea is that
// world coordinates are converted to screen once, here, using the camera.
function drawLevel(scene, alpha, stats) {
const s = scene.state;
const camX = lerp(s.camera.prevX, s.camera.x, alpha);
const px = lerp(s.prince.prevX, s.prince.x, alpha);
const radius = lerp(s.torch.prevRadius, s.torch.radius, alpha);
const cx = lerp(s.torch.prevCx, s.torch.cx, alpha) - camX;
const cy = lerp(s.torch.prevCy, s.torch.cy, alpha);
const handX = lerp(s.torch.prevHandX, s.torch.handX, alpha);
const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
const armEase = lerp(s.torch.prevEased ?? 0, s.torch.eased ?? 0, alpha);
const face = s.prince.facing;
const torchUp = !s.prince.hasSword;
// The weight going out of the bad leg. Half a limp is the rhythm, which the
// scene does by hurrying one half of the cycle; the other half is this β
// he drops a few px while that foot is down.
const hurt = Math.max(0, s.prince.hits - TUNING.LIMP_FROM);
const limpDip = (hurt > 0 && s.prince.moving &&
s.prince.frame >= s.prince.clip.count / 2)
? Math.min(TUNING.LIMP_DIP_PX, TUNING.LIMP_DIP_PX * (hurt / 3)) : 0;
ctx.setTransform(1, 0, 0, 1, 0, 0);
// --- Pass 1: the dark world ------------------------------------------
// Measured separately from pass 2. The dirty-rect win that took the demo
// from 34 to 60fps was measured on a STATIC pass 1; a scrolling five-layer
// background redraws the whole frame every tick, so that result does not
// carry over and this build has to state its own numbers.
const t1 = performance.now();
// Before either pass draws a watcher. Both passes read the same spans, so
// the torch cannot show a watcher the dark pass faded.
s.background.keepClear?.(camX, laneSpans, backLaneSpans(s, camX, alpha), s.clock ?? 0);
// The sky IS the background now, not a flat fill. It is the only thing on
// screen above luminance 160, and without it a silhouette has nothing to
// read against.
s.background.drawSky(ctx, camX, W);
// Shafts sit ON the sky and fade as the canopy eats it β the same curve
// that drives the canopy and the watcher density. The light runs out.
depth.drawShafts(ctx, camX, W, GROUND_Y, 1 - depthAt(px));
depth.drawParticlesBehind(ctx, camX, W, H, s.clock);
stats.bgDrawn = s.background.drawFar(ctx, camX, W, H, 'dark', px);
drawSkySink(ctx, s.prince.x);
drawGround(ctx, s.ground, 'dark', 0, W, camX);
drawBolt(ctx, s);
drawVillage(ctx, s, camX, 'dark');
drawTree(ctx, s, camX, 'dark');
drawFlash(ctx, s.flash ?? 0);
drawAllDrained(ctx, s, 'dark', camX, alpha);
drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
drawSheet(ctx, s, 'dark', px, camX, face);
drawTorchArm(ctx, s, 'dark', px, camX, face, armEase);
if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
drawFog(ctx, camX, s.clock ?? 0);
stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
depth.drawParticlesFront(ctx, camX, W, H, s.clock);
stats.pass1Ms += (performance.now() - t1 - stats.pass1Ms) * 0.1;
const t2 = performance.now();
// --- Pass 2: the lit reveal -------------------------------------------
if (radius > 0.5) {
const x0 = Math.max(0, Math.floor(cx - radius) - 2);
const y0 = Math.max(0, Math.floor(cy - radius) - 2);
const x1 = Math.min(W, Math.ceil(cx + radius) + 2);
const y1 = Math.min(H, Math.ceil(cy + radius) + 2);
const rw = x1 - x0;
const rh = y1 - y0;
if (rw > 0 && rh > 0) {
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.save();
octx.beginPath();
octx.rect(x0, y0, rw, rh);
octx.clip();
octx.clearRect(x0, y0, rw, rh);
// Sky first, unchanged, so the circle passing over it does nothing β
// a torch does not light the sky.
s.background.drawSky(octx, camX, W);
octx.fillStyle = BG_LIT;
const gTop = groundTop(s.ground) - 4;
octx.fillRect(x0, gTop, rw, H - gTop);
// Each layer decides what it hands the lit pass. The far ones hand over
// their dark art, so the circle passes across them and changes nothing.
s.background.drawFar(octx, camX, W, H, 'lit', px);
if (y1 > GROUND_Y) drawGround(octx, s.ground, 'lit', x0, x1, camX);
// And here the doors open. This is the one thing in the level the
// torch reveals that is not a person.
drawVillage(octx, s, camX, 'lit');
drawTree(octx, s, camX, 'lit');
// He is never lit by his own torch, so he goes in with his DARK art β
// the same trick the far layers use.
// The Drained are the thing the torch is for.
drawAllDrained(octx, s, 'lit', camX, alpha);
drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
// He is never lit by his own torch. The SHEET is β it is the one thing
// he carries that the light is for.
drawSheet(octx, s, 'lit', px, camX, face);
drawTorchArm(octx, s, 'lit', px, camX, face, armEase);
if (torchUp) drawClip(octx, s.flame.clip, s.flame.frame, 'lit', handX, handY, camX, face);
// The same bank of puffs, in the same places, in both passes. Without
// this the torch punches a clean round hole in the mist, which reads as
// a bug β and a torch is exactly where real mist shows up most.
drawFog(octx, camX, s.clock ?? 0);
s.background.drawNear(octx, camX, W, H, 'lit', px);
octx.globalCompositeOperation = 'destination-in';
octx.setTransform(radius, 0, 0, radius, cx, cy);
octx.fillStyle = maskGradient;
octx.fillRect(-1, -1, 2, 2);
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.globalCompositeOperation = 'source-over';
octx.restore();
ctx.drawImage(off, x0, y0, rw, rh, x0, y0, rw, rh);
}
// Dim everything the light is not on. Four rects for the world outside
// the circle's box, and the falloff gradient inside it β far cheaper than
// masking a full-screen overlay, and it is the same shape as the light so
// the two edges agree.
const dimA = TORCH.DIM * (s.torch.raise);
if (dimA > 0.002) {
const R = radius * GLOW_SPREAD;
const bx0 = cx - R, by0 = cy - R, bx1 = cx + R, by1 = cy + R;
ctx.fillStyle = `rgba(${TORCH.DIM_RGB},${dimA})`;
if (by0 > 0) ctx.fillRect(0, 0, W, Math.min(by0, H));
if (by1 < H) ctx.fillRect(0, Math.max(by1, 0), W, H - Math.max(by1, 0));
const ty = Math.max(0, by0), th = Math.min(H, by1) - ty;
if (th > 0) {
if (bx0 > 0) ctx.fillRect(0, ty, Math.min(bx0, W), th);
if (bx1 < W) ctx.fillRect(Math.max(bx1, 0), ty, W - Math.max(bx1, 0), th);
}
ctx.save();
ctx.globalAlpha = s.torch.raise;
ctx.setTransform(R, 0, 0, R, cx, cy);
ctx.fillStyle = dimGradient;
ctx.fillRect(-1, -1, 2, 2);
ctx.restore();
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.setTransform(radius * GLOW_SPREAD, 0, 0, radius * GLOW_SPREAD, cx, cy);
ctx.fillStyle = glowGradient;
ctx.fillRect(-1, -1, 2, 2);
ctx.restore();
}
stats.pass2Ms += (performance.now() - t2 - stats.pass2Ms) * 0.1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
// Over the finished frame, torch included. This is the cue most likely to
// spend budget invisibly: it touches every pixel, including the quarter of
// the frame that is pure black, and noise over black lands some of it
// above 30.
// Vignette before grain: the grain is a film artefact and film grain sits on
// top of the exposure, not under it.
drawVignette(ctx);
depth.drawGrain(ctx, W, H);
// The words. Over the vignette and the grain, because it is a page being
// held up in front of the world rather than a thing in it β and under the
// fade, because the ending takes everything with it.
drawBand(ctx, s.band?.text, s.band?.alpha ?? 0);
// AND THE KEYS, ALWAYS. This was the last line of the debug readout, which
// meant the one thing a new player actually needs was behind the same
// toggle as the frame timings β and off by default on a phone, where
// `?debug=0` is the only way to get rid of the rest of it.
drawHelp(ctx, s.band?.alpha ?? 0);
// Through the gate and the playthrough is over. No prompt, no score screen,
// and no mention of the empty slot β the gate wanted four and it got four.
if (s.fade > 0) {
ctx.fillStyle = `rgba(0,0,0,${Math.min(1, s.fade)})`;
ctx.fillRect(0, 0, W, H);
}
if (showDebug) {
// LEGIBLE ON A PHONE. The canvas is a fixed 1920 scaled to fit, so a 20px
// line comes out about four pixels across on a handset β which made the
// one instrument this game has useless on exactly the device that has no
// console and no backtick key to toggle it with.
//
// AND IT HAS TO STAY OUT OF THE PICTURE. Thirteen lines at 20px and 1.3
// line height reached 40% down the frame β the readout was sitting on the
// thing it was reporting on. 16px at 1.14 keeps the same information in
// roughly half the height.
const shrink = ctx.canvas.clientWidth ? W / ctx.canvas.clientWidth : 1;
const fs = Math.round(16 * Math.min(2.0, Math.max(1, shrink * 0.5)));
const step = fs * 1.1;
ctx.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`;
// Against the GATE, not the road. Measuring him against LEVEL_LENGTH_PX
// reads past 100% for most of the level now that the road is an approach.
const pct = (s.prince.x / (s.gateStopX || TUNING.LEVEL_LENGTH_PX) * 100).toFixed(1);
const standing = s.village ? s.village.filter((d) => d.state !== 'DOWNED').length : 0;
const doors = (s.houses ?? []).map((h) => (h.seen ? h.kind : 'Β·')).join(' ');
const nearKept = s.drained.some((d) => d.keptDropped && !d.keptTaken &&
Math.abs(d.keptX - s.prince.x) <= TUNING.PICKUP_REACH);
[
`${stats.fps} fps pass1 ${stats.pass1Ms.toFixed(2)}ms pass2 ${stats.pass2Ms.toFixed(2)}ms trees ${stats.bgDrawn}`,
`x ${s.prince.x.toFixed(0)} / ${s.gateStopX ?? TUNING.LEVEL_LENGTH_PX} (${pct}%) cam ${camX.toFixed(0)} canopy ${(depthAt(s.prince.x) * 100).toFixed(0)}%`,
// The numbers V1 is judging, on screen while you judge them.
// The village's width and crossing time used to be spelled out here every
// frame. They are constants β a fact you read once, not an instrument.
`${s.prince.x < s.bowlX0 ? 'road' : 'village'}` +
` shapes ${standing}/${s.village ? s.village.length : 0} up, ${s.villageGap ?? 0}px apart`,
// NO HEALTH BAR ON SCREEN. This line is for judging the build, not for
// playing it β the player is meant to read it off how he is standing.
`hits ${s.prince.hits}/${TUNING.PRINCE_HITS}` +
(s.prince.hits >= TUNING.LIMP_FROM ? ' limping' : '') +
` swing x${TUNING.SWING_SLOW[Math.min(s.prince.hits, TUNING.SWING_SLOW.length - 1)].toFixed(2)}` +
(s.run ? ` run ${s.run.seconds.toFixed(0)}s fights ${s.run.fights}` +
` took ${s.run.taken} hit ${s.run.hitsTaken} deaths ${s.run.deaths}` +
` WATCHING ${s.run.watching.toFixed(1)}s` : ''),
`keys ${s.prince.keys ?? 0} in hand` +
` home ${s.houses ? s.houses.filter((h) => h.open).length : 0}/4` +
` walking ${s.drained ? s.drained.filter((d) => d.homing).length : 0} ` +
`gate ${s.gate ? `${s.gate.filled}/${TUNING.GATE_WANTS}` : 'β'}` +
(s.gate?.open >= 1 ? ' OPEN' : ' shut') +
(s.gate?.through ? ' β through' : '') +
` doors lit: ${doors || 'β'}` +
(s.sheaf?.up ? ` INVENTORY ${s.sheaf.index + 1}/${s.sheaf.pages.length}`
: ` [Q] inventory (${s.sheaf?.pages.length ?? 0})`),
`${face > 0 ? 'β' : 'β'} ${s.prince.moving ? 'walking' : 'still'} sword ${s.prince.hasSword ? 'out' : 'away'}` +
` torch ${(s.torch.raise * 100).toFixed(0)}% r ${radius.toFixed(1)}` +
` ${s.prince.clip.name} f${s.prince.frame}` +
(s.prince.attacking ? ' SWINGING' : ''),
s.drained.filter((d) => d.answers)
.map((d) => `${d.state[0]}${d.hits}:${d.answers}`).join(' ') +
(s.loss.active ? ' β LOSS' : ''),
// An object taken in the dark is listed as "?". He has it; he never
// looked at it, so he does not know whose it was.
`carrying ${s.prince.inventory.length}: ` +
(s.prince.inventory.length
? s.prince.inventory.map((it) => (it.seen ? it.answers : '?')).join(', ')
: 'β') +
(s.canPin ? ' [E] PIN IT HERE'
: s.canMark ? ' [E] take it back down'
: s.canPlaceKey ? ` [E] put a key in (${s.prince.keys} in hand)`
: s.canTakeKey ? ' [E] take the key'
: nearKept ? ' [E] take' : '') +
// ONLY WHEN IT IS NOT WORKING. Spelled out because a phone has no
// console and no backtick key β "running / playback" and still silent
// on an iPhone means the ringer switch, not the game. Once it is
// playing there is nothing to say, and it was the longest line here.
(s.audio && !(s.audio.enabled && s.audio.ready)
? ` sound ${s.audio.enabled ? 'waiting for a keypress' : 'MUTED'}` +
` [${s.audio.state}/${s.audio.session}]` : ''),
// WHAT HE HAS CLAIMED, against what he is carrying. Its own line β
// fillText draws one per array entry and swallows a newline inside one.
// Five slots, and the empty one is the finding.
`filed ${s.houses.map((hh) => (hh.filed == null ? 'β' : `${hh.kind}<${hh.filed}`)).join(' ')}`,
// Only when somebody is actually tuning it from the address bar.
DEPTH.label ? `depth: ${DEPTH.label}` +
(DEPTH.OCC > 1 ? ` occ ${DEPTH.OCC}x` : '') +
(DEPTH.PART > 0 ? ` part ${DEPTH.PART}${DEPTH.PART_HARD ? ' hard' : ' soft'} (${depth.stats.particles} drawn)` : '') +
(DEPTH.BLUR > 0 ? ` blur ${DEPTH.BLUR}` : '') +
(DEPTH.GRAIN > 0 ? ` grain ${DEPTH.GRAIN}` : '') +
(DEPTH.SHAFT > 0 ? ` shafts ${depth.stats.shafts}` : '') : '',
]
// WRAPPED, BECAUSE ONE OF THESE RAN OFF THE SCREEN AND IT WAS THE ONE
// THAT MATTERED. The help line overflowed the right edge by 329px, so
// "arrows page" β the only thing in the game that says how to turn a
// page β was drawn past the end of the canvas and nobody ever saw it.
//
// The readout grows with the build and its font grows on a small
// display, so a line that fits today does not stay fitting. Wrapping on
// the separators it already uses costs nothing and closes the whole
// class of it.
.flatMap((line) => {
const max = W - 48;
if (ctx.measureText(line).width <= max) return [line];
const out = [];
let cur = '';
for (const part of String(line).split(' β ')) {
const next = cur ? `${cur} β ${part}` : part;
if (cur && ctx.measureText(next).width > max) { out.push(cur); cur = part; }
else cur = next;
}
out.push(cur);
return out;
})
.filter((line) => line !== '')
.forEach((line, i) => readoutLine(ctx, line, 20, 4 + i * step));
}
}
return {
drawDemo,
drawLevel,
depth,
/** Measuring code calls this. Never leave it off for the player. */
setDebug(v) { showDebug = !!v; },
/** For checks.js, which composes a backdrop-only frame. */
drawVignette,
get debugShown() { return showDebug; },
};
}
|