vod jam and earl vod.atverkackt.de
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

Overhaul UI into interactive ToeJam & Earl game

+3106 -695
+606 -39
package-lock.json
··· 16 16 "@sveltejs/adapter-static": "^3.0.10", 17 17 "@sveltejs/kit": "^2.49.1", 18 18 "@sveltejs/vite-plugin-svelte": "^6.2.1", 19 + "@types/sharp": "^0.31.1", 20 + "sharp": "^0.34.5", 19 21 "svelte": "^5.45.6", 20 22 "svelte-check": "^4.3.4", 21 23 "typescript": "^5.9.3", ··· 102 104 "dependencies": { 103 105 "@atproto/lexicon": "^0.6.0", 104 106 "zod": "^3.23.8" 107 + } 108 + }, 109 + "node_modules/@emnapi/runtime": { 110 + "version": "1.9.1", 111 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", 112 + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", 113 + "dev": true, 114 + "license": "MIT", 115 + "optional": true, 116 + "dependencies": { 117 + "tslib": "^2.4.0" 105 118 } 106 119 }, 107 120 "node_modules/@esbuild/aix-ppc64": { ··· 544 557 "node": ">=18" 545 558 } 546 559 }, 560 + "node_modules/@img/colour": { 561 + "version": "1.1.0", 562 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", 563 + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", 564 + "dev": true, 565 + "license": "MIT", 566 + "engines": { 567 + "node": ">=18" 568 + } 569 + }, 570 + "node_modules/@img/sharp-darwin-arm64": { 571 + "version": "0.34.5", 572 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", 573 + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", 574 + "cpu": [ 575 + "arm64" 576 + ], 577 + "dev": true, 578 + "license": "Apache-2.0", 579 + "optional": true, 580 + "os": [ 581 + "darwin" 582 + ], 583 + "engines": { 584 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 585 + }, 586 + "funding": { 587 + "url": "https://opencollective.com/libvips" 588 + }, 589 + "optionalDependencies": { 590 + "@img/sharp-libvips-darwin-arm64": "1.2.4" 591 + } 592 + }, 593 + "node_modules/@img/sharp-darwin-x64": { 594 + "version": "0.34.5", 595 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", 596 + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", 597 + "cpu": [ 598 + "x64" 599 + ], 600 + "dev": true, 601 + "license": "Apache-2.0", 602 + "optional": true, 603 + "os": [ 604 + "darwin" 605 + ], 606 + "engines": { 607 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 608 + }, 609 + "funding": { 610 + "url": "https://opencollective.com/libvips" 611 + }, 612 + "optionalDependencies": { 613 + "@img/sharp-libvips-darwin-x64": "1.2.4" 614 + } 615 + }, 616 + "node_modules/@img/sharp-libvips-darwin-arm64": { 617 + "version": "1.2.4", 618 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", 619 + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", 620 + "cpu": [ 621 + "arm64" 622 + ], 623 + "dev": true, 624 + "license": "LGPL-3.0-or-later", 625 + "optional": true, 626 + "os": [ 627 + "darwin" 628 + ], 629 + "funding": { 630 + "url": "https://opencollective.com/libvips" 631 + } 632 + }, 633 + "node_modules/@img/sharp-libvips-darwin-x64": { 634 + "version": "1.2.4", 635 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", 636 + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", 637 + "cpu": [ 638 + "x64" 639 + ], 640 + "dev": true, 641 + "license": "LGPL-3.0-or-later", 642 + "optional": true, 643 + "os": [ 644 + "darwin" 645 + ], 646 + "funding": { 647 + "url": "https://opencollective.com/libvips" 648 + } 649 + }, 650 + "node_modules/@img/sharp-libvips-linux-arm": { 651 + "version": "1.2.4", 652 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", 653 + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", 654 + "cpu": [ 655 + "arm" 656 + ], 657 + "dev": true, 658 + "license": "LGPL-3.0-or-later", 659 + "optional": true, 660 + "os": [ 661 + "linux" 662 + ], 663 + "funding": { 664 + "url": "https://opencollective.com/libvips" 665 + } 666 + }, 667 + "node_modules/@img/sharp-libvips-linux-arm64": { 668 + "version": "1.2.4", 669 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", 670 + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", 671 + "cpu": [ 672 + "arm64" 673 + ], 674 + "dev": true, 675 + "license": "LGPL-3.0-or-later", 676 + "optional": true, 677 + "os": [ 678 + "linux" 679 + ], 680 + "funding": { 681 + "url": "https://opencollective.com/libvips" 682 + } 683 + }, 684 + "node_modules/@img/sharp-libvips-linux-ppc64": { 685 + "version": "1.2.4", 686 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", 687 + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", 688 + "cpu": [ 689 + "ppc64" 690 + ], 691 + "dev": true, 692 + "license": "LGPL-3.0-or-later", 693 + "optional": true, 694 + "os": [ 695 + "linux" 696 + ], 697 + "funding": { 698 + "url": "https://opencollective.com/libvips" 699 + } 700 + }, 701 + "node_modules/@img/sharp-libvips-linux-riscv64": { 702 + "version": "1.2.4", 703 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", 704 + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", 705 + "cpu": [ 706 + "riscv64" 707 + ], 708 + "dev": true, 709 + "license": "LGPL-3.0-or-later", 710 + "optional": true, 711 + "os": [ 712 + "linux" 713 + ], 714 + "funding": { 715 + "url": "https://opencollective.com/libvips" 716 + } 717 + }, 718 + "node_modules/@img/sharp-libvips-linux-s390x": { 719 + "version": "1.2.4", 720 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", 721 + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", 722 + "cpu": [ 723 + "s390x" 724 + ], 725 + "dev": true, 726 + "license": "LGPL-3.0-or-later", 727 + "optional": true, 728 + "os": [ 729 + "linux" 730 + ], 731 + "funding": { 732 + "url": "https://opencollective.com/libvips" 733 + } 734 + }, 735 + "node_modules/@img/sharp-libvips-linux-x64": { 736 + "version": "1.2.4", 737 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", 738 + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", 739 + "cpu": [ 740 + "x64" 741 + ], 742 + "dev": true, 743 + "license": "LGPL-3.0-or-later", 744 + "optional": true, 745 + "os": [ 746 + "linux" 747 + ], 748 + "funding": { 749 + "url": "https://opencollective.com/libvips" 750 + } 751 + }, 752 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 753 + "version": "1.2.4", 754 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", 755 + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", 756 + "cpu": [ 757 + "arm64" 758 + ], 759 + "dev": true, 760 + "license": "LGPL-3.0-or-later", 761 + "optional": true, 762 + "os": [ 763 + "linux" 764 + ], 765 + "funding": { 766 + "url": "https://opencollective.com/libvips" 767 + } 768 + }, 769 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 770 + "version": "1.2.4", 771 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", 772 + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", 773 + "cpu": [ 774 + "x64" 775 + ], 776 + "dev": true, 777 + "license": "LGPL-3.0-or-later", 778 + "optional": true, 779 + "os": [ 780 + "linux" 781 + ], 782 + "funding": { 783 + "url": "https://opencollective.com/libvips" 784 + } 785 + }, 786 + "node_modules/@img/sharp-linux-arm": { 787 + "version": "0.34.5", 788 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", 789 + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", 790 + "cpu": [ 791 + "arm" 792 + ], 793 + "dev": true, 794 + "license": "Apache-2.0", 795 + "optional": true, 796 + "os": [ 797 + "linux" 798 + ], 799 + "engines": { 800 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 801 + }, 802 + "funding": { 803 + "url": "https://opencollective.com/libvips" 804 + }, 805 + "optionalDependencies": { 806 + "@img/sharp-libvips-linux-arm": "1.2.4" 807 + } 808 + }, 809 + "node_modules/@img/sharp-linux-arm64": { 810 + "version": "0.34.5", 811 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", 812 + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", 813 + "cpu": [ 814 + "arm64" 815 + ], 816 + "dev": true, 817 + "license": "Apache-2.0", 818 + "optional": true, 819 + "os": [ 820 + "linux" 821 + ], 822 + "engines": { 823 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 824 + }, 825 + "funding": { 826 + "url": "https://opencollective.com/libvips" 827 + }, 828 + "optionalDependencies": { 829 + "@img/sharp-libvips-linux-arm64": "1.2.4" 830 + } 831 + }, 832 + "node_modules/@img/sharp-linux-ppc64": { 833 + "version": "0.34.5", 834 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", 835 + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", 836 + "cpu": [ 837 + "ppc64" 838 + ], 839 + "dev": true, 840 + "license": "Apache-2.0", 841 + "optional": true, 842 + "os": [ 843 + "linux" 844 + ], 845 + "engines": { 846 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 847 + }, 848 + "funding": { 849 + "url": "https://opencollective.com/libvips" 850 + }, 851 + "optionalDependencies": { 852 + "@img/sharp-libvips-linux-ppc64": "1.2.4" 853 + } 854 + }, 855 + "node_modules/@img/sharp-linux-riscv64": { 856 + "version": "0.34.5", 857 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", 858 + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", 859 + "cpu": [ 860 + "riscv64" 861 + ], 862 + "dev": true, 863 + "license": "Apache-2.0", 864 + "optional": true, 865 + "os": [ 866 + "linux" 867 + ], 868 + "engines": { 869 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 870 + }, 871 + "funding": { 872 + "url": "https://opencollective.com/libvips" 873 + }, 874 + "optionalDependencies": { 875 + "@img/sharp-libvips-linux-riscv64": "1.2.4" 876 + } 877 + }, 878 + "node_modules/@img/sharp-linux-s390x": { 879 + "version": "0.34.5", 880 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", 881 + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", 882 + "cpu": [ 883 + "s390x" 884 + ], 885 + "dev": true, 886 + "license": "Apache-2.0", 887 + "optional": true, 888 + "os": [ 889 + "linux" 890 + ], 891 + "engines": { 892 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 893 + }, 894 + "funding": { 895 + "url": "https://opencollective.com/libvips" 896 + }, 897 + "optionalDependencies": { 898 + "@img/sharp-libvips-linux-s390x": "1.2.4" 899 + } 900 + }, 901 + "node_modules/@img/sharp-linux-x64": { 902 + "version": "0.34.5", 903 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", 904 + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", 905 + "cpu": [ 906 + "x64" 907 + ], 908 + "dev": true, 909 + "license": "Apache-2.0", 910 + "optional": true, 911 + "os": [ 912 + "linux" 913 + ], 914 + "engines": { 915 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 916 + }, 917 + "funding": { 918 + "url": "https://opencollective.com/libvips" 919 + }, 920 + "optionalDependencies": { 921 + "@img/sharp-libvips-linux-x64": "1.2.4" 922 + } 923 + }, 924 + "node_modules/@img/sharp-linuxmusl-arm64": { 925 + "version": "0.34.5", 926 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", 927 + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", 928 + "cpu": [ 929 + "arm64" 930 + ], 931 + "dev": true, 932 + "license": "Apache-2.0", 933 + "optional": true, 934 + "os": [ 935 + "linux" 936 + ], 937 + "engines": { 938 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 939 + }, 940 + "funding": { 941 + "url": "https://opencollective.com/libvips" 942 + }, 943 + "optionalDependencies": { 944 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" 945 + } 946 + }, 947 + "node_modules/@img/sharp-linuxmusl-x64": { 948 + "version": "0.34.5", 949 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", 950 + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", 951 + "cpu": [ 952 + "x64" 953 + ], 954 + "dev": true, 955 + "license": "Apache-2.0", 956 + "optional": true, 957 + "os": [ 958 + "linux" 959 + ], 960 + "engines": { 961 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 962 + }, 963 + "funding": { 964 + "url": "https://opencollective.com/libvips" 965 + }, 966 + "optionalDependencies": { 967 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" 968 + } 969 + }, 970 + "node_modules/@img/sharp-wasm32": { 971 + "version": "0.34.5", 972 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", 973 + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", 974 + "cpu": [ 975 + "wasm32" 976 + ], 977 + "dev": true, 978 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 979 + "optional": true, 980 + "dependencies": { 981 + "@emnapi/runtime": "^1.7.0" 982 + }, 983 + "engines": { 984 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 985 + }, 986 + "funding": { 987 + "url": "https://opencollective.com/libvips" 988 + } 989 + }, 990 + "node_modules/@img/sharp-win32-arm64": { 991 + "version": "0.34.5", 992 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", 993 + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", 994 + "cpu": [ 995 + "arm64" 996 + ], 997 + "dev": true, 998 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 999 + "optional": true, 1000 + "os": [ 1001 + "win32" 1002 + ], 1003 + "engines": { 1004 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1005 + }, 1006 + "funding": { 1007 + "url": "https://opencollective.com/libvips" 1008 + } 1009 + }, 1010 + "node_modules/@img/sharp-win32-ia32": { 1011 + "version": "0.34.5", 1012 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", 1013 + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", 1014 + "cpu": [ 1015 + "ia32" 1016 + ], 1017 + "dev": true, 1018 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1019 + "optional": true, 1020 + "os": [ 1021 + "win32" 1022 + ], 1023 + "engines": { 1024 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1025 + }, 1026 + "funding": { 1027 + "url": "https://opencollective.com/libvips" 1028 + } 1029 + }, 1030 + "node_modules/@img/sharp-win32-x64": { 1031 + "version": "0.34.5", 1032 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", 1033 + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", 1034 + "cpu": [ 1035 + "x64" 1036 + ], 1037 + "dev": true, 1038 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 1039 + "optional": true, 1040 + "os": [ 1041 + "win32" 1042 + ], 1043 + "engines": { 1044 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1045 + }, 1046 + "funding": { 1047 + "url": "https://opencollective.com/libvips" 1048 + } 1049 + }, 547 1050 "node_modules/@jridgewell/gen-mapping": { 548 1051 "version": "0.3.13", 549 1052 "dev": true, ··· 679 1182 "arm" 680 1183 ], 681 1184 "dev": true, 682 - "libc": [ 683 - "glibc" 684 - ], 685 1185 "license": "MIT", 686 1186 "optional": true, 687 1187 "os": [ ··· 696 1196 "arm" 697 1197 ], 698 1198 "dev": true, 699 - "libc": [ 700 - "musl" 701 - ], 702 1199 "license": "MIT", 703 1200 "optional": true, 704 1201 "os": [ ··· 713 1210 "arm64" 714 1211 ], 715 1212 "dev": true, 716 - "libc": [ 717 - "glibc" 718 - ], 719 1213 "license": "MIT", 720 1214 "optional": true, 721 1215 "os": [ ··· 730 1224 "arm64" 731 1225 ], 732 1226 "dev": true, 733 - "libc": [ 734 - "musl" 735 - ], 736 1227 "license": "MIT", 737 1228 "optional": true, 738 1229 "os": [ ··· 747 1238 "loong64" 748 1239 ], 749 1240 "dev": true, 750 - "libc": [ 751 - "glibc" 752 - ], 753 1241 "license": "MIT", 754 1242 "optional": true, 755 1243 "os": [ ··· 764 1252 "loong64" 765 1253 ], 766 1254 "dev": true, 767 - "libc": [ 768 - "musl" 769 - ], 770 1255 "license": "MIT", 771 1256 "optional": true, 772 1257 "os": [ ··· 781 1266 "ppc64" 782 1267 ], 783 1268 "dev": true, 784 - "libc": [ 785 - "glibc" 786 - ], 787 1269 "license": "MIT", 788 1270 "optional": true, 789 1271 "os": [ ··· 798 1280 "ppc64" 799 1281 ], 800 1282 "dev": true, 801 - "libc": [ 802 - "musl" 803 - ], 804 1283 "license": "MIT", 805 1284 "optional": true, 806 1285 "os": [ ··· 815 1294 "riscv64" 816 1295 ], 817 1296 "dev": true, 818 - "libc": [ 819 - "glibc" 820 - ], 821 1297 "license": "MIT", 822 1298 "optional": true, 823 1299 "os": [ ··· 832 1308 "riscv64" 833 1309 ], 834 1310 "dev": true, 835 - "libc": [ 836 - "musl" 837 - ], 838 1311 "license": "MIT", 839 1312 "optional": true, 840 1313 "os": [ ··· 849 1322 "s390x" 850 1323 ], 851 1324 "dev": true, 852 - "libc": [ 853 - "glibc" 854 - ], 855 1325 "license": "MIT", 856 1326 "optional": true, 857 1327 "os": [ ··· 866 1336 "x64" 867 1337 ], 868 1338 "dev": true, 869 - "libc": [ 870 - "glibc" 871 - ], 872 1339 "license": "MIT", 873 1340 "optional": true, 874 1341 "os": [ ··· 883 1350 "x64" 884 1351 ], 885 1352 "dev": true, 886 - "libc": [ 887 - "musl" 888 - ], 889 1353 "license": "MIT", 890 1354 "optional": true, 891 1355 "os": [ ··· 1011 1475 "version": "2.55.0", 1012 1476 "dev": true, 1013 1477 "license": "MIT", 1478 + "peer": true, 1014 1479 "dependencies": { 1015 1480 "@standard-schema/spec": "^1.0.0", 1016 1481 "@sveltejs/acorn-typescript": "^1.0.5", ··· 1051 1516 "version": "6.2.4", 1052 1517 "dev": true, 1053 1518 "license": "MIT", 1519 + "peer": true, 1054 1520 "dependencies": { 1055 1521 "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", 1056 1522 "deepmerge": "^4.3.1", ··· 1091 1557 "version": "1.0.8", 1092 1558 "dev": true, 1093 1559 "license": "MIT" 1560 + }, 1561 + "node_modules/@types/node": { 1562 + "version": "25.5.0", 1563 + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", 1564 + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", 1565 + "dev": true, 1566 + "license": "MIT", 1567 + "peer": true, 1568 + "dependencies": { 1569 + "undici-types": "~7.18.0" 1570 + } 1571 + }, 1572 + "node_modules/@types/sharp": { 1573 + "version": "0.31.1", 1574 + "resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz", 1575 + "integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==", 1576 + "dev": true, 1577 + "license": "MIT", 1578 + "dependencies": { 1579 + "@types/node": "*" 1580 + } 1094 1581 }, 1095 1582 "node_modules/@types/trusted-types": { 1096 1583 "version": "2.0.7", ··· 1113 1600 "version": "8.16.0", 1114 1601 "dev": true, 1115 1602 "license": "MIT", 1603 + "peer": true, 1116 1604 "bin": { 1117 1605 "acorn": "bin/acorn" 1118 1606 }, ··· 1178 1666 "license": "MIT", 1179 1667 "engines": { 1180 1668 "node": ">=0.10.0" 1669 + } 1670 + }, 1671 + "node_modules/detect-libc": { 1672 + "version": "2.1.2", 1673 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 1674 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 1675 + "dev": true, 1676 + "license": "Apache-2.0", 1677 + "engines": { 1678 + "node": ">=8" 1181 1679 } 1182 1680 }, 1183 1681 "node_modules/devalue": { ··· 1365 1863 "version": "4.0.4", 1366 1864 "dev": true, 1367 1865 "license": "MIT", 1866 + "peer": true, 1368 1867 "engines": { 1369 1868 "node": ">=12" 1370 1869 }, ··· 1465 1964 "node": ">=6" 1466 1965 } 1467 1966 }, 1967 + "node_modules/semver": { 1968 + "version": "7.7.4", 1969 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 1970 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 1971 + "dev": true, 1972 + "license": "ISC", 1973 + "bin": { 1974 + "semver": "bin/semver.js" 1975 + }, 1976 + "engines": { 1977 + "node": ">=10" 1978 + } 1979 + }, 1468 1980 "node_modules/set-cookie-parser": { 1469 1981 "version": "3.1.0", 1470 1982 "dev": true, 1471 1983 "license": "MIT" 1472 1984 }, 1985 + "node_modules/sharp": { 1986 + "version": "0.34.5", 1987 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", 1988 + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", 1989 + "dev": true, 1990 + "hasInstallScript": true, 1991 + "license": "Apache-2.0", 1992 + "dependencies": { 1993 + "@img/colour": "^1.0.0", 1994 + "detect-libc": "^2.1.2", 1995 + "semver": "^7.7.3" 1996 + }, 1997 + "engines": { 1998 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 1999 + }, 2000 + "funding": { 2001 + "url": "https://opencollective.com/libvips" 2002 + }, 2003 + "optionalDependencies": { 2004 + "@img/sharp-darwin-arm64": "0.34.5", 2005 + "@img/sharp-darwin-x64": "0.34.5", 2006 + "@img/sharp-libvips-darwin-arm64": "1.2.4", 2007 + "@img/sharp-libvips-darwin-x64": "1.2.4", 2008 + "@img/sharp-libvips-linux-arm": "1.2.4", 2009 + "@img/sharp-libvips-linux-arm64": "1.2.4", 2010 + "@img/sharp-libvips-linux-ppc64": "1.2.4", 2011 + "@img/sharp-libvips-linux-riscv64": "1.2.4", 2012 + "@img/sharp-libvips-linux-s390x": "1.2.4", 2013 + "@img/sharp-libvips-linux-x64": "1.2.4", 2014 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", 2015 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", 2016 + "@img/sharp-linux-arm": "0.34.5", 2017 + "@img/sharp-linux-arm64": "0.34.5", 2018 + "@img/sharp-linux-ppc64": "0.34.5", 2019 + "@img/sharp-linux-riscv64": "0.34.5", 2020 + "@img/sharp-linux-s390x": "0.34.5", 2021 + "@img/sharp-linux-x64": "0.34.5", 2022 + "@img/sharp-linuxmusl-arm64": "0.34.5", 2023 + "@img/sharp-linuxmusl-x64": "0.34.5", 2024 + "@img/sharp-wasm32": "0.34.5", 2025 + "@img/sharp-win32-arm64": "0.34.5", 2026 + "@img/sharp-win32-ia32": "0.34.5", 2027 + "@img/sharp-win32-x64": "0.34.5" 2028 + } 2029 + }, 1473 2030 "node_modules/sirv": { 1474 2031 "version": "3.0.2", 1475 2032 "dev": true, ··· 1495 2052 "version": "5.55.1", 1496 2053 "dev": true, 1497 2054 "license": "MIT", 2055 + "peer": true, 1498 2056 "dependencies": { 1499 2057 "@jridgewell/remapping": "^2.3.4", 1500 2058 "@jridgewell/sourcemap-codec": "^1.5.0", ··· 1581 2139 "version": "5.9.3", 1582 2140 "dev": true, 1583 2141 "license": "Apache-2.0", 2142 + "peer": true, 1584 2143 "bin": { 1585 2144 "tsc": "bin/tsc", 1586 2145 "tsserver": "bin/tsserver" ··· 1598 2157 "multiformats": "^9.4.2" 1599 2158 } 1600 2159 }, 2160 + "node_modules/undici-types": { 2161 + "version": "7.18.2", 2162 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", 2163 + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", 2164 + "dev": true, 2165 + "license": "MIT" 2166 + }, 1601 2167 "node_modules/unicode-segmenter": { 1602 2168 "version": "0.14.5", 1603 2169 "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", ··· 1608 2174 "version": "7.3.1", 1609 2175 "dev": true, 1610 2176 "license": "MIT", 2177 + "peer": true, 1611 2178 "dependencies": { 1612 2179 "esbuild": "^0.27.0", 1613 2180 "fdir": "^6.5.0",
+2
package.json
··· 17 17 "@sveltejs/adapter-static": "^3.0.10", 18 18 "@sveltejs/kit": "^2.49.1", 19 19 "@sveltejs/vite-plugin-svelte": "^6.2.1", 20 + "@types/sharp": "^0.31.1", 21 + "sharp": "^0.34.5", 20 22 "svelte": "^5.45.6", 21 23 "svelte-check": "^4.3.4", 22 24 "typescript": "^5.9.3",
+158
scripts/create-game-sprites.mjs
··· 1 + import sharp from 'sharp'; 2 + import { mkdir } from 'fs/promises'; 3 + 4 + async function createPresent(outPath, color = '#FF6B2C', ribbonColor = '#FFD93D') { 5 + // Create a 24x24 pixel-art present (gift box) 6 + const w = 24, h = 24; 7 + const pixels = Buffer.alloc(w * h * 4, 0); // RGBA 8 + 9 + function setPixel(x, y, r, g, b, a = 255) { 10 + if (x < 0 || x >= w || y < 0 || y >= h) return; 11 + const idx = (y * w + x) * 4; 12 + pixels[idx] = r; pixels[idx+1] = g; pixels[idx+2] = b; pixels[idx+3] = a; 13 + } 14 + 15 + function hexToRgb(hex) { 16 + const n = parseInt(hex.slice(1), 16); 17 + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; 18 + } 19 + 20 + const [cr, cg, cb] = hexToRgb(color); 21 + const [rr, rg, rb] = hexToRgb(ribbonColor); 22 + 23 + // Box body (8x8 to 16x20) 24 + for (let y = 6; y < 22; y++) { 25 + for (let x = 4; x < 20; x++) { 26 + setPixel(x, y, cr, cg, cb); 27 + } 28 + } 29 + 30 + // Lid top (wider, 2x20 to 22x6) 31 + for (let y = 4; y < 8; y++) { 32 + for (let x = 2; x < 22; x++) { 33 + setPixel(x, y, Math.min(cr + 40, 255), Math.min(cg + 20, 255), cb); 34 + } 35 + } 36 + 37 + // Vertical ribbon 38 + for (let y = 4; y < 22; y++) { 39 + setPixel(11, y, rr, rg, rb); 40 + setPixel(12, y, rr, rg, rb); 41 + } 42 + 43 + // Horizontal ribbon 44 + for (let x = 2; x < 22; x++) { 45 + setPixel(x, 12, rr, rg, rb); 46 + setPixel(x, 13, rr, rg, rb); 47 + } 48 + 49 + // Bow on top 50 + for (let x = 8; x < 16; x++) { 51 + setPixel(x, 2, rr, rg, rb); 52 + setPixel(x, 3, rr, rg, rb); 53 + } 54 + setPixel(9, 1, rr, rg, rb); 55 + setPixel(14, 1, rr, rg, rb); 56 + 57 + // Outline 58 + const outline = [0, 0, 0]; 59 + for (let x = 4; x < 20; x++) { setPixel(x, 3, ...outline); setPixel(x, 22, ...outline); } 60 + for (let x = 2; x < 22; x++) { setPixel(x, 3, ...outline); setPixel(x, 7, ...outline); } 61 + for (let y = 4; y < 8; y++) { setPixel(2, y, ...outline); setPixel(21, y, ...outline); } 62 + for (let y = 7; y < 22; y++) { setPixel(3, y, ...outline); setPixel(20, y, ...outline); } 63 + 64 + await sharp(pixels, { raw: { width: w, height: h, channels: 4 } }) 65 + .png() 66 + .toFile(outPath); 67 + } 68 + 69 + async function createTree(outPath) { 70 + const w = 32, h = 40; 71 + const pixels = Buffer.alloc(w * h * 4, 0); 72 + 73 + function setPixel(x, y, r, g, b, a = 255) { 74 + if (x < 0 || x >= w || y < 0 || y >= h) return; 75 + const idx = (y * w + x) * 4; 76 + pixels[idx] = r; pixels[idx+1] = g; pixels[idx+2] = b; pixels[idx+3] = a; 77 + } 78 + 79 + // Trunk (brown) 80 + for (let y = 24; y < 40; y++) { 81 + for (let x = 13; x < 19; x++) { 82 + setPixel(x, y, 101, 67, 33); 83 + } 84 + } 85 + 86 + // Foliage layers (green with variation) 87 + const greens = [[34, 139, 34], [0, 100, 0], [50, 160, 50]]; 88 + // Bottom layer (widest) 89 + for (let y = 14; y < 28; y++) { 90 + const spread = Math.max(0, 14 - Math.abs(y - 20)); 91 + for (let x = 16 - spread; x < 16 + spread; x++) { 92 + const gi = (x + y) % 3; 93 + setPixel(x, y, greens[gi][0], greens[gi][1], greens[gi][2]); 94 + } 95 + } 96 + // Top layer 97 + for (let y = 4; y < 18; y++) { 98 + const spread = Math.max(0, 10 - Math.abs(y - 10)); 99 + for (let x = 16 - spread; x < 16 + spread; x++) { 100 + const gi = (x + y) % 3; 101 + setPixel(x, y, greens[gi][0], greens[gi][1], greens[gi][2]); 102 + } 103 + } 104 + 105 + await sharp(pixels, { raw: { width: w, height: h, channels: 4 } }).png().toFile(outPath); 106 + } 107 + 108 + async function createBush(outPath) { 109 + const w = 24, h = 16; 110 + const pixels = Buffer.alloc(w * h * 4, 0); 111 + 112 + function setPixel(x, y, r, g, b, a = 255) { 113 + if (x < 0 || x >= w || y < 0 || y >= h) return; 114 + const idx = (y * w + x) * 4; 115 + pixels[idx] = r; pixels[idx+1] = g; pixels[idx+2] = b; pixels[idx+3] = a; 116 + } 117 + 118 + const greens = [[34, 139, 34], [0, 100, 0], [50, 160, 50]]; 119 + for (let y = 2; y < 14; y++) { 120 + const spread = Math.max(0, 10 - Math.abs(y - 8) * 1.5); 121 + for (let x = Math.floor(12 - spread); x < Math.ceil(12 + spread); x++) { 122 + const gi = (x * 3 + y * 7) % 3; 123 + setPixel(x, y, greens[gi][0], greens[gi][1], greens[gi][2]); 124 + } 125 + } 126 + 127 + await sharp(pixels, { raw: { width: w, height: h, channels: 4 } }).png().toFile(outPath); 128 + } 129 + 130 + async function main() { 131 + await mkdir('static/sprites/present', { recursive: true }); 132 + await mkdir('static/sprites/scenery', { recursive: true }); 133 + await mkdir('static/sprites/ship', { recursive: true }); 134 + await mkdir('static/sprites/terrain', { recursive: true }); 135 + 136 + // Create presents in different colors 137 + await createPresent('static/sprites/present/closed.png', '#FF6B2C', '#FFD93D'); 138 + await createPresent('static/sprites/present/closed-blue.png', '#4488FF', '#FFD93D'); 139 + await createPresent('static/sprites/present/closed-green.png', '#44BB44', '#FF6B2C'); 140 + console.log('Created present sprites'); 141 + 142 + // Create scenery 143 + await createTree('static/sprites/scenery/tree-1.png'); 144 + await createBush('static/sprites/scenery/bush-1.png'); 145 + console.log('Created scenery sprites'); 146 + 147 + // Copy ship.png to sprites dir 148 + await sharp('static/ship.png').png().toFile('static/sprites/ship/ship.png'); 149 + console.log('Copied ship sprite'); 150 + 151 + // Create a terrain tile from existing background 152 + await sharp('static/background.gif').resize(64, 64, { fit: 'cover' }).png().toFile('static/sprites/terrain/grass-tile.png'); 153 + console.log('Created terrain tile'); 154 + 155 + console.log('\nAll game sprites ready!'); 156 + } 157 + 158 + main().catch(console.error);
+85
scripts/extract-items.mjs
··· 1 + import sharp from 'sharp'; 2 + import { mkdir } from 'fs/promises'; 3 + import { join } from 'path'; 4 + 5 + const ITEMS_SHEET = 'source-files/Sega Genesis - ToeJam & Earl - Miscellaneous - Items & Scenery.png'; 6 + const OPENING_SHEET = 'source-files/Sega Genesis - ToeJam & Earl - Miscellaneous - Opening & Ending.png'; 7 + 8 + // For sheets with alpha, detect sprites by non-transparent regions 9 + async function findAlphaSprites(sheetPath) { 10 + const { data, info } = await sharp(sheetPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); 11 + const w = info.width, h = info.height; 12 + 13 + // Find row blocks (rows with any non-transparent pixels) 14 + const blocks = []; 15 + let inBlock = false, blockStart = -1; 16 + 17 + for (let y = 0; y < h; y++) { 18 + let hasContent = false; 19 + for (let x = 0; x < w; x++) { 20 + if (data[(y * w + x) * 4 + 3] > 10) { hasContent = true; break; } 21 + } 22 + if (hasContent && !inBlock) { blockStart = y; inBlock = true; } 23 + if (!hasContent && inBlock) { blocks.push({ start: blockStart, end: y - 1, height: y - blockStart }); inBlock = false; } 24 + } 25 + if (inBlock) blocks.push({ start: blockStart, end: h - 1, height: h - blockStart }); 26 + 27 + // For each block, find column sprites 28 + const allSprites = []; 29 + for (const block of blocks) { 30 + let inCol = false, colStart = -1; 31 + for (let x = 0; x < w; x++) { 32 + let colHasContent = false; 33 + for (let y = block.start; y <= block.end; y++) { 34 + if (data[(y * w + x) * 4 + 3] > 10) { colHasContent = true; break; } 35 + } 36 + if (colHasContent && !inCol) { colStart = x; inCol = true; } 37 + if (!colHasContent && inCol) { 38 + allSprites.push({ x: colStart, y: block.start, w: x - colStart, h: block.height }); 39 + inCol = false; 40 + } 41 + } 42 + if (inCol) allSprites.push({ x: colStart, y: block.start, w: w - colStart, h: block.height }); 43 + } 44 + 45 + return allSprites; 46 + } 47 + 48 + async function extractWithAlpha(sheetPath, region, outPath) { 49 + await sharp(sheetPath) 50 + .extract({ left: region.x, top: region.y, width: region.w, height: region.h }) 51 + .png() 52 + .toFile(outPath); 53 + } 54 + 55 + async function main() { 56 + // --- ITEMS & SCENERY --- 57 + console.log('=== Items & Scenery ==='); 58 + const itemSprites = await findAlphaSprites(ITEMS_SHEET); 59 + console.log(`Found ${itemSprites.length} sprites`); 60 + 61 + const itemsDir = '/tmp/items-alpha'; 62 + await mkdir(itemsDir, { recursive: true }); 63 + 64 + for (let i = 0; i < itemSprites.length; i++) { 65 + const s = itemSprites[i]; 66 + console.log(` Item ${i}: ${s.w}x${s.h} @ (${s.x},${s.y})`); 67 + await extractWithAlpha(ITEMS_SHEET, s, join(itemsDir, `item-${i}.png`)); 68 + } 69 + 70 + // --- OPENING & ENDING (for ship) --- 71 + console.log('\n=== Opening & Ending ==='); 72 + const openingSprites = await findAlphaSprites(OPENING_SHEET); 73 + console.log(`Found ${openingSprites.length} sprites`); 74 + 75 + const openDir = '/tmp/opening-sprites'; 76 + await mkdir(openDir, { recursive: true }); 77 + 78 + for (let i = 0; i < Math.min(openingSprites.length, 30); i++) { 79 + const s = openingSprites[i]; 80 + console.log(` Opening ${i}: ${s.w}x${s.h} @ (${s.x},${s.y})`); 81 + await extractWithAlpha(OPENING_SHEET, s, join(openDir, `opening-${i}.png`)); 82 + } 83 + } 84 + 85 + main().catch(console.error);
+302
scripts/extract-sprites.mjs
··· 1 + import sharp from 'sharp'; 2 + import { writeFile, mkdir } from 'fs/promises'; 3 + import { join } from 'path'; 4 + 5 + const EARL_SHEET = 'source-files/Sega Genesis - ToeJam & Earl - Playable Characters - Earl.png'; 6 + const ITEMS_SHEET = 'source-files/Sega Genesis - ToeJam & Earl - Miscellaneous - Items & Scenery.png'; 7 + const OPENING_SHEET = 'source-files/Sega Genesis - ToeJam & Earl - Miscellaneous - Opening & Ending.png'; 8 + 9 + const OUT_DIR = 'static/sprites'; 10 + 11 + // Background colors used in the sprite sheets 12 + const BG_COLORS = [ 13 + [0, 130, 132], // teal 14 + [0, 128, 128], // dark teal 15 + [107, 0, 107], // purple (separator/label rows) 16 + ]; 17 + 18 + function isBgColor(r, g, b) { 19 + return BG_COLORS.some(([br, bg, bb]) => r === br && g === bg && b === bb); 20 + } 21 + 22 + async function loadSheet(path) { 23 + const result = await sharp(path).raw().toBuffer({ resolveWithObject: true }); 24 + return { 25 + data: result.data, 26 + width: result.info.width, 27 + height: result.info.height, 28 + channels: result.info.channels, 29 + }; 30 + } 31 + 32 + function findSpriteBlocks(sheet) { 33 + const { data, width, height, channels } = sheet; 34 + const threshold = Math.max(3, Math.floor(width * 0.01)); 35 + const blocks = []; 36 + let inBlock = false; 37 + let blockStart = -1; 38 + 39 + for (let y = 0; y < height; y++) { 40 + let nonBg = 0; 41 + for (let x = 0; x < width; x++) { 42 + const idx = (y * width + x) * channels; 43 + if (!isBgColor(data[idx], data[idx + 1], data[idx + 2])) { 44 + nonBg++; 45 + if (nonBg > threshold) break; 46 + } 47 + } 48 + const hasContent = nonBg > threshold; 49 + if (hasContent && !inBlock) { 50 + blockStart = y; 51 + inBlock = true; 52 + } 53 + if (!hasContent && inBlock) { 54 + blocks.push({ start: blockStart, end: y - 1, height: y - blockStart }); 55 + inBlock = false; 56 + } 57 + } 58 + if (inBlock) blocks.push({ start: blockStart, end: height - 1, height: height - blockStart }); 59 + return blocks; 60 + } 61 + 62 + function findSpritesInBlock(sheet, block) { 63 + const { data, width, channels } = sheet; 64 + const sprites = []; 65 + let inCol = false; 66 + let colStart = -1; 67 + 68 + for (let x = 0; x < width; x++) { 69 + let colNonBg = 0; 70 + for (let y = block.start; y <= block.end; y++) { 71 + const idx = (y * width + x) * channels; 72 + if (!isBgColor(data[idx], data[idx + 1], data[idx + 2])) { 73 + colNonBg++; 74 + if (colNonBg > 1) break; 75 + } 76 + } 77 + if (colNonBg > 1 && !inCol) { 78 + colStart = x; 79 + inCol = true; 80 + } 81 + if (colNonBg <= 1 && inCol) { 82 + sprites.push({ x: colStart, y: block.start, w: x - colStart, h: block.height }); 83 + inCol = false; 84 + } 85 + } 86 + if (inCol) sprites.push({ x: colStart, y: block.start, w: width - colStart, h: block.height }); 87 + return sprites; 88 + } 89 + 90 + async function extractSprite(sheetPath, region, outPath, uniformSize) { 91 + let pipeline = sharp(sheetPath).extract({ 92 + left: region.x, 93 + top: region.y, 94 + width: region.w, 95 + height: region.h, 96 + }); 97 + 98 + // Convert teal/dark-teal backgrounds to transparency 99 + // We'll use raw pixel manipulation for this 100 + const { data, info } = await pipeline.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); 101 + const buf = Buffer.from(data); 102 + 103 + for (let i = 0; i < buf.length; i += 4) { 104 + const r = buf[i], g = buf[i + 1], b = buf[i + 2]; 105 + if (isBgColor(r, g, b)) { 106 + buf[i + 3] = 0; // make transparent 107 + } 108 + } 109 + 110 + let output = sharp(buf, { raw: { width: info.width, height: info.height, channels: 4 } }); 111 + 112 + if (uniformSize) { 113 + // Center the sprite in a uniform canvas 114 + const { width: uw, height: uh } = uniformSize; 115 + const offsetX = Math.floor((uw - info.width) / 2); 116 + const offsetY = uh - info.height; // bottom-align sprites (feet on ground) 117 + 118 + output = output.extend({ 119 + top: offsetY, 120 + bottom: 0, 121 + left: offsetX, 122 + right: uw - info.width - offsetX, 123 + background: { r: 0, g: 0, b: 0, alpha: 0 }, 124 + }); 125 + } 126 + 127 + await output.png().toFile(outPath); 128 + } 129 + 130 + async function analyzeAndExtractEarl() { 131 + console.log('=== Analyzing Earl sprite sheet ==='); 132 + const sheet = await loadSheet(EARL_SHEET); 133 + console.log(`Sheet: ${sheet.width}x${sheet.height}, ${sheet.channels}ch`); 134 + 135 + const blocks = findSpriteBlocks(sheet); 136 + console.log(`Found ${blocks.length} sprite blocks:`); 137 + 138 + const allSprites = []; 139 + for (let i = 0; i < blocks.length; i++) { 140 + const block = blocks[i]; 141 + const sprites = findSpritesInBlock(sheet, block); 142 + console.log(` Block ${i}: y=${block.start}-${block.end} (h=${block.height}), ${sprites.length} sprites`); 143 + sprites.forEach((s, j) => { 144 + console.log(` S${j}: ${s.w}x${s.h} @ (${s.x},${s.y})`); 145 + }); 146 + allSprites.push({ block, sprites }); 147 + } 148 + 149 + return allSprites; 150 + } 151 + 152 + async function analyzeAndExtractItems() { 153 + console.log('\n=== Analyzing Items & Scenery sheet ==='); 154 + const sheet = await loadSheet(ITEMS_SHEET); 155 + console.log(`Sheet: ${sheet.width}x${sheet.height}, ${sheet.channels}ch`); 156 + 157 + const blocks = findSpriteBlocks(sheet); 158 + console.log(`Found ${blocks.length} sprite blocks:`); 159 + 160 + for (let i = 0; i < blocks.length; i++) { 161 + const block = blocks[i]; 162 + const sprites = findSpritesInBlock(sheet, block); 163 + console.log(` Block ${i}: y=${block.start}-${block.end} (h=${block.height}), ${sprites.length} sprites`); 164 + sprites.forEach((s, j) => { 165 + console.log(` S${j}: ${s.w}x${s.h} @ (${s.x},${s.y})`); 166 + }); 167 + } 168 + } 169 + 170 + async function extractAllFromBlock(sheetPath, block, sprites, outDir, prefix) { 171 + await mkdir(outDir, { recursive: true }); 172 + 173 + // Find max dimensions for uniform sizing 174 + const maxW = Math.max(...sprites.map(s => s.w)); 175 + const maxH = block.height; 176 + const uniformSize = { width: maxW, height: maxH }; 177 + 178 + for (let i = 0; i < sprites.length; i++) { 179 + const outPath = join(outDir, `${prefix}-${i + 1}.png`); 180 + await extractSprite(sheetPath, sprites[i], outPath, uniformSize); 181 + } 182 + 183 + console.log(` Extracted ${sprites.length} sprites to ${outDir} at ${maxW}x${maxH}`); 184 + } 185 + 186 + async function main() { 187 + const earlData = await analyzeAndExtractEarl(); 188 + 189 + // Filter to content blocks only (skip label rows with h<=10) 190 + const contentBlocks = earlData.filter(b => b.block.height > 10 && b.sprites.length > 0); 191 + console.log(`\n=== ${contentBlocks.length} content blocks found ===`); 192 + 193 + // Extract ALL content blocks to temp dir for inspection 194 + const tempDir = '/tmp/earl-all-sprites'; 195 + await mkdir(tempDir, { recursive: true }); 196 + 197 + for (let i = 0; i < contentBlocks.length; i++) { 198 + const { block, sprites } = contentBlocks[i]; 199 + const blockDir = join(tempDir, `block${i}_h${block.height}_n${sprites.length}`); 200 + await extractAllFromBlock(EARL_SHEET, block, sprites, blockDir, 'frame'); 201 + console.log(` Block ${i} (y=${block.start}, h=${block.height}): ${sprites.length} sprites`); 202 + } 203 + 204 + // --- EARL WALK CYCLE EXTRACTION --- 205 + // Based on analysis: 206 + // Block 3 (y=81, h=40, 13 sprites) and Block 4 (y=123, h=40, 14 sprites) are walk cycles 207 + // Block 0 (y=2, h=44, 10 sprites) contains idle/standing poses 208 + // For a top-down game we need: walk-right, walk-left, walk-up, walk-down, idle 209 + 210 + // Extract block 3 (walk direction 1) - selecting 4 evenly-spaced frames for walk cycle 211 + const block3 = contentBlocks.find(b => b.block.start === 81); 212 + const block4 = contentBlocks.find(b => b.block.start === 123); 213 + const block0 = contentBlocks.find(b => b.block.start === 2); 214 + 215 + if (block3 && block4 && block0) { 216 + const earlDir = join(OUT_DIR, 'earl'); 217 + await mkdir(earlDir, { recursive: true }); 218 + 219 + const maxW3 = Math.max(...block3.sprites.map(s => s.w)); 220 + const maxW4 = Math.max(...block4.sprites.map(s => s.w)); 221 + const maxW0 = Math.max(...block0.sprites.map(s => s.w)); 222 + const uniformW = Math.max(maxW3, maxW4, maxW0); 223 + const uniformH = Math.max(block3.block.height, block4.block.height, block0.block.height); 224 + const uniform = { width: uniformW, height: uniformH }; 225 + 226 + // Walk right: take first 4 sprites from block 3 (these are the main walk frames) 227 + const walkRightFrames = [0, 1, 2, 3]; 228 + for (let i = 0; i < walkRightFrames.length; i++) { 229 + const sprite = block3.sprites[walkRightFrames[i]]; 230 + await extractSprite(EARL_SHEET, sprite, join(earlDir, `walk-right-${i + 1}.png`), uniform); 231 + } 232 + console.log(`\nExtracted walk-right (4 frames) at ${uniformW}x${uniformH}`); 233 + 234 + // Walk left: take first 4 sprites from block 4 235 + const walkLeftFrames = [0, 1, 2, 3]; 236 + for (let i = 0; i < walkLeftFrames.length; i++) { 237 + const sprite = block4.sprites[walkLeftFrames[i]]; 238 + await extractSprite(EARL_SHEET, sprite, join(earlDir, `walk-left-${i + 1}.png`), uniform); 239 + } 240 + console.log(`Extracted walk-left (4 frames) at ${uniformW}x${uniformH}`); 241 + 242 + // Walk down: take sprites 4-7 from block 3 (facing camera) 243 + const walkDownFrames = [4, 5, 6, 7]; 244 + for (let i = 0; i < walkDownFrames.length; i++) { 245 + const idx = Math.min(walkDownFrames[i], block3.sprites.length - 1); 246 + const sprite = block3.sprites[idx]; 247 + await extractSprite(EARL_SHEET, sprite, join(earlDir, `walk-down-${i + 1}.png`), uniform); 248 + } 249 + console.log(`Extracted walk-down (4 frames) at ${uniformW}x${uniformH}`); 250 + 251 + // Walk up: take sprites 8-11 from block 3 (facing away) 252 + const walkUpFrames = [8, 9, 10, 11]; 253 + for (let i = 0; i < walkUpFrames.length; i++) { 254 + const idx = Math.min(walkUpFrames[i], block3.sprites.length - 1); 255 + const sprite = block3.sprites[idx]; 256 + await extractSprite(EARL_SHEET, sprite, join(earlDir, `walk-up-${i + 1}.png`), uniform); 257 + } 258 + console.log(`Extracted walk-up (4 frames) at ${uniformW}x${uniformH}`); 259 + 260 + // Idle: take first 2 sprites from block 0 261 + for (let i = 0; i < 2; i++) { 262 + const sprite = block0.sprites[i]; 263 + await extractSprite(EARL_SHEET, sprite, join(earlDir, `idle-${i + 1}.png`), uniform); 264 + } 265 + console.log(`Extracted idle (2 frames) at ${uniformW}x${uniformH}`); 266 + } 267 + 268 + // --- ITEMS & SCENERY EXTRACTION --- 269 + console.log('\n=== Extracting Items & Scenery ==='); 270 + const itemsSheet = await loadSheet(ITEMS_SHEET); 271 + const itemsBlocks = findSpriteBlocks(itemsSheet); 272 + const itemsContent = itemsBlocks.filter(b => b.height > 10); 273 + 274 + for (let i = 0; i < itemsContent.length; i++) { 275 + const block = itemsContent[i]; 276 + const sprites = findSpritesInBlock(itemsSheet, block); 277 + if (sprites.length > 0) { 278 + const blockDir = join('/tmp/items-sprites', `block${i}_h${block.height}_n${sprites.length}`); 279 + await extractAllFromBlock(ITEMS_SHEET, block, sprites, blockDir, 'item'); 280 + } 281 + } 282 + 283 + // Extract present sprite (typically the gift box from the items sheet) 284 + // We'll extract all items and identify presents manually 285 + const presentDir = join(OUT_DIR, 'present'); 286 + await mkdir(presentDir, { recursive: true }); 287 + 288 + // Extract scenery items 289 + const sceneryDir = join(OUT_DIR, 'scenery'); 290 + await mkdir(sceneryDir, { recursive: true }); 291 + 292 + // Extract terrain tile from backgrounds 293 + const terrainDir = join(OUT_DIR, 'terrain'); 294 + await mkdir(terrainDir, { recursive: true }); 295 + 296 + console.log('\n=== Extraction complete ==='); 297 + console.log('Check /tmp/earl-all-sprites/ for all Earl frames'); 298 + console.log('Check /tmp/items-sprites/ for all items'); 299 + console.log('Check static/sprites/earl/ for game-ready walk sprites'); 300 + } 301 + 302 + main().catch(console.error);
+112
src/lib/game/GameHUD.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + totalPresents, 4 + nearbyPresent, 5 + }: { 6 + totalPresents: number; 7 + nearbyPresent: boolean; 8 + } = $props(); 9 + 10 + let showControls = $state(true); 11 + 12 + $effect(() => { 13 + const timer = setTimeout(() => { 14 + showControls = false; 15 + }, 5000); 16 + return () => clearTimeout(timer); 17 + }); 18 + </script> 19 + 20 + <div class="hud"> 21 + <div class="hud-top"> 22 + <div class="present-count"> 23 + 🎁 PRESENTS: {totalPresents} 24 + </div> 25 + </div> 26 + 27 + {#if nearbyPresent} 28 + <div class="interact-hint"> 29 + Press ENTER to open 30 + </div> 31 + {/if} 32 + 33 + <div class="controls-hint" class:fade-out={!showControls}> 34 + <span>Arrow keys / WASD to move</span> 35 + <span>·</span> 36 + <span>Enter to interact</span> 37 + </div> 38 + </div> 39 + 40 + <style> 41 + .hud { 42 + position: fixed; 43 + inset: 0; 44 + pointer-events: none; 45 + z-index: 100; 46 + font-family: 'Courier New', Courier, monospace; 47 + } 48 + 49 + .hud-top { 50 + position: absolute; 51 + top: 16px; 52 + left: 16px; 53 + } 54 + 55 + .present-count { 56 + background: rgba(0, 0, 0, 0.6); 57 + color: #ffd93d; 58 + padding: 8px 16px; 59 + border-radius: 8px; 60 + font-size: 1.1rem; 61 + font-weight: bold; 62 + letter-spacing: 0.05em; 63 + backdrop-filter: blur(4px); 64 + } 65 + 66 + .interact-hint { 67 + position: absolute; 68 + bottom: 80px; 69 + left: 50%; 70 + transform: translateX(-50%); 71 + background: rgba(0, 0, 0, 0.7); 72 + color: #ffd93d; 73 + padding: 10px 24px; 74 + border-radius: 8px; 75 + font-size: 1rem; 76 + font-weight: bold; 77 + letter-spacing: 0.05em; 78 + backdrop-filter: blur(4px); 79 + animation: pulse-glow 1.5s ease-in-out infinite; 80 + } 81 + 82 + @keyframes pulse-glow { 83 + 0%, 100% { 84 + box-shadow: 0 0 8px rgba(255, 217, 61, 0.3); 85 + } 86 + 50% { 87 + box-shadow: 0 0 20px rgba(255, 217, 61, 0.7); 88 + } 89 + } 90 + 91 + .controls-hint { 92 + position: absolute; 93 + bottom: 20px; 94 + left: 50%; 95 + transform: translateX(-50%); 96 + display: flex; 97 + gap: 8px; 98 + background: rgba(0, 0, 0, 0.5); 99 + color: #e8e0ff; 100 + padding: 6px 16px; 101 + border-radius: 6px; 102 + font-size: 0.8rem; 103 + backdrop-filter: blur(4px); 104 + opacity: 1; 105 + transition: opacity 1s ease-out; 106 + white-space: nowrap; 107 + } 108 + 109 + .controls-hint.fade-out { 110 + opacity: 0; 111 + } 112 + </style>
+121
src/lib/game/GameLoop.ts
··· 1 + export type GameState = 'intro' | 'playing' | 'viewing-present' | 'watching-video'; 2 + export type UpdateCallback = (dt: number) => void; 3 + export type StateChangeCallback = (newState: GameState, oldState: GameState) => void; 4 + 5 + const MAX_DELTA = 0.1; 6 + const FPS_SAMPLE_COUNT = 60; 7 + 8 + export class GameLoop { 9 + private rafId: number | null = null; 10 + private lastTimestamp: number = 0; 11 + private elapsed: number = 0; 12 + private paused: boolean = false; 13 + private running: boolean = false; 14 + private state: GameState = 'intro'; 15 + 16 + private updateCb: UpdateCallback | null = null; 17 + private renderCb: UpdateCallback | null = null; 18 + private stateChangeCb: StateChangeCallback | null = null; 19 + 20 + private frameTimes: number[] = []; 21 + 22 + onUpdate(cb: UpdateCallback): void { 23 + this.updateCb = cb; 24 + } 25 + 26 + onRender(cb: UpdateCallback): void { 27 + this.renderCb = cb; 28 + } 29 + 30 + onStateChange(cb: StateChangeCallback): void { 31 + this.stateChangeCb = cb; 32 + } 33 + 34 + start(): void { 35 + if (this.running) return; 36 + this.running = true; 37 + this.paused = false; 38 + this.lastTimestamp = 0; 39 + this.elapsed = 0; 40 + this.frameTimes = []; 41 + this.rafId = requestAnimationFrame((ts) => this.loop(ts)); 42 + } 43 + 44 + stop(): void { 45 + this.running = false; 46 + if (this.rafId !== null) { 47 + cancelAnimationFrame(this.rafId); 48 + this.rafId = null; 49 + } 50 + } 51 + 52 + pause(): void { 53 + this.paused = true; 54 + } 55 + 56 + resume(): void { 57 + if (!this.paused) return; 58 + this.paused = false; 59 + // Reset timestamp so the next frame doesn't get a huge delta 60 + this.lastTimestamp = 0; 61 + } 62 + 63 + getState(): GameState { 64 + return this.state; 65 + } 66 + 67 + setState(state: GameState): void { 68 + if (state === this.state) return; 69 + const oldState = this.state; 70 + this.state = state; 71 + this.stateChangeCb?.(state, oldState); 72 + } 73 + 74 + isPaused(): boolean { 75 + return this.paused; 76 + } 77 + 78 + isRunning(): boolean { 79 + return this.running; 80 + } 81 + 82 + getFPS(): number { 83 + if (this.frameTimes.length === 0) return 0; 84 + const avg = this.frameTimes.reduce((sum, t) => sum + t, 0) / this.frameTimes.length; 85 + return avg > 0 ? 1 / avg : 0; 86 + } 87 + 88 + getElapsed(): number { 89 + return this.elapsed; 90 + } 91 + 92 + private loop(timestamp: number): void { 93 + if (!this.running) return; 94 + 95 + if (this.lastTimestamp === 0) { 96 + this.lastTimestamp = timestamp; 97 + this.rafId = requestAnimationFrame((ts) => this.loop(ts)); 98 + return; 99 + } 100 + 101 + const rawDt = (timestamp - this.lastTimestamp) / 1000; 102 + const dt = Math.min(rawDt, MAX_DELTA); 103 + this.lastTimestamp = timestamp; 104 + 105 + // Track frame times for rolling FPS 106 + this.frameTimes.push(dt); 107 + if (this.frameTimes.length > FPS_SAMPLE_COUNT) { 108 + this.frameTimes.shift(); 109 + } 110 + 111 + const shouldUpdate = !this.paused && this.state !== 'watching-video'; 112 + 113 + if (shouldUpdate) { 114 + this.elapsed += dt; 115 + this.updateCb?.(dt); 116 + this.renderCb?.(dt); 117 + } 118 + 119 + this.rafId = requestAnimationFrame((ts) => this.loop(ts)); 120 + } 121 + }
+295
src/lib/game/GameWorld.svelte
··· 1 + <script lang="ts"> 2 + import type { VideoRecord } from '$lib/api'; 3 + import { resolveHandle } from '$lib/api'; 4 + import type { GameState, Vec2, WorldConfig, WorldPresent, SceneryItem } from './types'; 5 + import { GAME_CONSTANTS } from './types'; 6 + import { InputManager } from './InputManager'; 7 + import { GameLoop } from './GameLoop'; 8 + import WorldMap from './WorldMap.svelte'; 9 + import PlayerCanvas from './PlayerCanvas.svelte'; 10 + import Present from './Present.svelte'; 11 + import GameHUD from './GameHUD.svelte'; 12 + 13 + let { 14 + videos, 15 + gameState, 16 + onpresentselect, 17 + onstatechange, 18 + focusVideoId 19 + }: { 20 + videos: VideoRecord[]; 21 + gameState: GameState; 22 + onpresentselect?: (present: WorldPresent) => void; 23 + onstatechange?: (state: GameState) => void; 24 + focusVideoId?: string; 25 + } = $props(); 26 + 27 + // --- Seeded PRNG --- 28 + function mulberry32(seed: number): number { 29 + let t = seed + 0x6d2b79f5; 30 + t = Math.imul(t ^ (t >>> 15), t | 1); 31 + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 32 + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 33 + } 34 + 35 + // --- World dimensions --- 36 + const spacing = 250; 37 + 38 + let worldWidth = $derived.by(() => { 39 + const cols = Math.ceil(Math.sqrt(videos.length)) || 1; 40 + return Math.max(cols * spacing + GAME_CONSTANTS.WORLD_PADDING * 2, 1200); 41 + }); 42 + 43 + let worldHeight = $derived.by(() => { 44 + const cols = Math.ceil(Math.sqrt(videos.length)) || 1; 45 + const rows = Math.ceil(videos.length / cols) || 1; 46 + return Math.max(rows * spacing + GAME_CONSTANTS.WORLD_PADDING * 2, 900); 47 + }); 48 + 49 + let worldConfig: WorldConfig = $derived({ 50 + width: worldWidth, 51 + height: worldHeight, 52 + tileSize: GAME_CONSTANTS.TILE_SIZE, 53 + presentProximity: GAME_CONSTANTS.PRESENT_PROXIMITY, 54 + playerSpeed: GAME_CONSTANTS.PLAYER_SPEED, 55 + spawnPoint: { x: worldWidth / 2, y: worldHeight / 2 } 56 + }); 57 + 58 + // --- Present distribution --- 59 + function distributePresents(vids: VideoRecord[], worldW: number, worldH: number): WorldPresent[] { 60 + if (vids.length === 0) return []; 61 + 62 + const padding = GAME_CONSTANTS.WORLD_PADDING; 63 + const usableW = worldW - padding * 2; 64 + const usableH = worldH - padding * 2; 65 + const cols = Math.ceil(Math.sqrt(vids.length)); 66 + const cellW = usableW / cols; 67 + const cellH = usableH / Math.ceil(vids.length / cols); 68 + 69 + return vids.map((v, i) => { 70 + const col = i % cols; 71 + const row = Math.floor(i / cols); 72 + const rkey = v.uri.split('/').pop()!; 73 + const seed = rkey.split('').reduce((a, c) => a + c.charCodeAt(0), 0); 74 + const jitterX = (((seed * 7 + 13) % 100) - 50) / 50 * cellW * 0.3; 75 + const jitterY = (((seed * 13 + 7) % 100) - 50) / 50 * cellH * 0.3; 76 + 77 + return { 78 + id: rkey, 79 + uri: v.uri, 80 + position: { 81 + x: padding + col * cellW + cellW / 2 + jitterX, 82 + y: padding + row * cellH + cellH / 2 + jitterY 83 + }, 84 + title: v.value.title, 85 + creator: v.value.creator, 86 + duration: v.value.duration, 87 + createdAt: v.value.createdAt, 88 + isNearby: false, 89 + isOpen: false, 90 + livestream: v.value.livestream 91 + }; 92 + }); 93 + } 94 + 95 + // --- Scenery generation --- 96 + function generateScenery(worldW: number, worldH: number, presentPositions: Vec2[]): SceneryItem[] { 97 + const items: SceneryItem[] = []; 98 + const count = Math.floor((worldW * worldH) / 40000); 99 + for (let i = 0; i < count; i++) { 100 + const x = mulberry32(i * 37) * (worldW - 200) + 100; 101 + const y = mulberry32(i * 73 + 11) * (worldH - 200) + 100; 102 + const tooClose = presentPositions.some((p) => Math.hypot(p.x - x, p.y - y) < 80); 103 + if (tooClose) continue; 104 + const types = ['tree', 'bush'] as const; 105 + const type = types[Math.floor(mulberry32(i * 17) * types.length)]; 106 + items.push({ 107 + type, 108 + position: { x, y }, 109 + sprite: type === 'tree' ? '/sprites/scenery/tree-1.png' : '/sprites/scenery/bush-1.png', 110 + scale: 1.5 + mulberry32(i * 53) * 1.0, 111 + zOffset: 0 112 + }); 113 + } 114 + return items; 115 + } 116 + 117 + // --- Reactive state --- 118 + let presents: WorldPresent[] = $state([]); 119 + let sceneryItems: SceneryItem[] = $state([]); 120 + 121 + let playerPosition: Vec2 = $state({ x: 0, y: 0 }); 122 + let playerDirection: Vec2 = $state({ x: 0, y: 1 }); 123 + let playerMoving = $state(false); 124 + 125 + let containerEl: HTMLDivElement | undefined = $state(undefined); 126 + let playerCanvas: PlayerCanvas | undefined = $state(undefined); 127 + 128 + const inputManager = new InputManager(); 129 + const gameLoop = new GameLoop(); 130 + 131 + // Rebuild presents and scenery when videos or world size change 132 + $effect(() => { 133 + const distributed = distributePresents(videos, worldWidth, worldHeight); 134 + presents = distributed; 135 + sceneryItems = generateScenery( 136 + worldWidth, 137 + worldHeight, 138 + distributed.map((p) => p.position) 139 + ); 140 + }); 141 + 142 + // Set initial player position at world center (or focused present) 143 + $effect(() => { 144 + if (videos.length === 0) return; 145 + 146 + const spawnX = worldWidth / 2; 147 + const spawnY = worldHeight / 2; 148 + 149 + if (focusVideoId) { 150 + const target = presents.find((p) => p.id === focusVideoId); 151 + if (target) { 152 + playerPosition = { x: target.position.x, y: target.position.y }; 153 + // Auto-select after a brief delay so the world renders first 154 + setTimeout(() => { 155 + onpresentselect?.(target); 156 + }, 100); 157 + return; 158 + } 159 + } 160 + 161 + playerPosition = { x: spawnX, y: spawnY }; 162 + }); 163 + 164 + // Lazy handle resolution 165 + $effect(() => { 166 + if (presents.length === 0) return; 167 + 168 + const dids = [...new Set(presents.map((p) => p.creator))]; 169 + 170 + for (const did of dids) { 171 + resolveHandle(did).then((handle) => { 172 + for (let i = 0; i < presents.length; i++) { 173 + if (presents[i].creator === did && !presents[i].creatorHandle) { 174 + presents[i].creatorHandle = handle; 175 + } 176 + } 177 + }).catch(() => { 178 + // Silently ignore failed handle resolution 179 + }); 180 + } 181 + }); 182 + 183 + // --- Game loop callbacks --- 184 + gameLoop.onUpdate((dt: number) => { 185 + if (gameState !== 'playing') return; 186 + 187 + const direction = inputManager.getDirection(); 188 + const moving = inputManager.isMoving(); 189 + 190 + // Update player position 191 + if (moving) { 192 + const speed = GAME_CONSTANTS.PLAYER_SPEED; 193 + let newX = playerPosition.x + direction.x * speed * dt; 194 + let newY = playerPosition.y + direction.y * speed * dt; 195 + 196 + // Clamp to world bounds 197 + newX = Math.max(16, Math.min(worldWidth - 16, newX)); 198 + newY = Math.max(16, Math.min(worldHeight - 16, newY)); 199 + 200 + playerPosition = { x: newX, y: newY }; 201 + playerDirection = { x: direction.x, y: direction.y }; 202 + } 203 + 204 + playerMoving = moving; 205 + 206 + // Update proximity for each present 207 + for (let i = 0; i < presents.length; i++) { 208 + const dist = Math.hypot( 209 + presents[i].position.x - playerPosition.x, 210 + presents[i].position.y - playerPosition.y 211 + ); 212 + const nearby = dist < GAME_CONSTANTS.PRESENT_PROXIMITY; 213 + if (presents[i].isNearby !== nearby) { 214 + presents[i].isNearby = nearby; 215 + } 216 + } 217 + 218 + // Handle interact input 219 + if (inputManager.consumeInteract()) { 220 + const nearbyPresent = presents.find((p) => p.isNearby); 221 + if (nearbyPresent) { 222 + handlePresentSelect(nearbyPresent); 223 + } 224 + } 225 + }); 226 + 227 + gameLoop.onRender((dt: number) => { 228 + playerCanvas?.update(dt, playerDirection, playerMoving); 229 + }); 230 + 231 + gameLoop.onStateChange((newState, _oldState) => { 232 + onstatechange?.(newState); 233 + }); 234 + 235 + // --- Lifecycle: init input and start game loop --- 236 + $effect(() => { 237 + if (!containerEl) return; 238 + 239 + inputManager.init(containerEl); 240 + gameLoop.start(); 241 + 242 + return () => { 243 + inputManager.destroy(); 244 + gameLoop.stop(); 245 + }; 246 + }); 247 + 248 + // Sync external game state into the loop 249 + $effect(() => { 250 + gameLoop.setState(gameState); 251 + }); 252 + 253 + // --- Event handlers --- 254 + function handlePresentSelect(present: WorldPresent) { 255 + onpresentselect?.(present); 256 + } 257 + </script> 258 + 259 + <div class="game-world" bind:this={containerEl}> 260 + <WorldMap {worldConfig} {playerPosition}> 261 + {#each sceneryItems as item} 262 + <div 263 + class="scenery" 264 + style="position:absolute; left:{item.position.x}px; top:{item.position.y}px; z-index:{Math.floor(item.position.y)}; transform:scale({item.scale})" 265 + > 266 + <img src={item.sprite} alt="" style="image-rendering:pixelated" /> 267 + </div> 268 + {/each} 269 + {#each presents as present (present.id)} 270 + <Present {present} onselect={handlePresentSelect} /> 271 + {/each} 272 + </WorldMap> 273 + <PlayerCanvas bind:this={playerCanvas} /> 274 + <GameHUD totalPresents={presents.length} nearbyPresent={presents.some(p => p.isNearby)} /> 275 + </div> 276 + 277 + <style> 278 + .game-world { 279 + position: relative; 280 + width: 100vw; 281 + height: 100vh; 282 + overflow: hidden; 283 + touch-action: none; 284 + user-select: none; 285 + } 286 + 287 + .scenery { 288 + pointer-events: none; 289 + image-rendering: pixelated; 290 + } 291 + 292 + .scenery img { 293 + display: block; 294 + } 295 + </style>
+153
src/lib/game/InputManager.ts
··· 1 + type Direction = { x: number; y: number }; 2 + 3 + const MOVE_KEYS: Record<string, Direction> = { 4 + ArrowUp: { x: 0, y: -1 }, 5 + ArrowDown: { x: 0, y: 1 }, 6 + ArrowLeft: { x: -1, y: 0 }, 7 + ArrowRight: { x: 1, y: 0 }, 8 + w: { x: 0, y: -1 }, 9 + s: { x: 0, y: 1 }, 10 + a: { x: -1, y: 0 }, 11 + d: { x: 1, y: 0 }, 12 + W: { x: 0, y: -1 }, 13 + S: { x: 0, y: 1 }, 14 + A: { x: -1, y: 0 }, 15 + D: { x: 1, y: 0 } 16 + }; 17 + 18 + const INTERACT_KEYS = new Set(['Enter', ' ']); 19 + 20 + function normalize(dir: Direction): Direction { 21 + const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y); 22 + if (len === 0) return { x: 0, y: 0 }; 23 + return { x: dir.x / len, y: dir.y / len }; 24 + } 25 + 26 + export class InputManager { 27 + private pressedKeys = new Set<string>(); 28 + private touchDirection: Direction = { x: 0, y: 0 }; 29 + private touchActive = false; 30 + private interactQueued = false; 31 + private container: HTMLElement | null = null; 32 + 33 + private onKeyDown = (e: KeyboardEvent): void => { 34 + if (e.key in MOVE_KEYS) { 35 + e.preventDefault(); 36 + this.pressedKeys.add(e.key); 37 + } 38 + if (INTERACT_KEYS.has(e.key)) { 39 + e.preventDefault(); 40 + this.interactQueued = true; 41 + } 42 + }; 43 + 44 + private onKeyUp = (e: KeyboardEvent): void => { 45 + this.pressedKeys.delete(e.key); 46 + }; 47 + 48 + private onTouchStart = (e: TouchEvent): void => { 49 + e.preventDefault(); 50 + this.touchActive = true; 51 + this.updateTouchDirection(e.touches[0]); 52 + }; 53 + 54 + private onTouchMove = (e: TouchEvent): void => { 55 + e.preventDefault(); 56 + if (this.touchActive) { 57 + this.updateTouchDirection(e.touches[0]); 58 + } 59 + }; 60 + 61 + private onTouchEnd = (e: TouchEvent): void => { 62 + e.preventDefault(); 63 + if (e.touches.length === 0) { 64 + this.touchActive = false; 65 + this.touchDirection = { x: 0, y: 0 }; 66 + } else { 67 + this.updateTouchDirection(e.touches[0]); 68 + } 69 + }; 70 + 71 + private onClick = (e: MouseEvent): void => { 72 + // Only count clicks that aren't drags — treat as interact 73 + this.interactQueued = true; 74 + }; 75 + 76 + private updateTouchDirection(touch: Touch): void { 77 + const centerX = window.innerWidth / 2; 78 + const centerY = window.innerHeight / 2; 79 + const dx = touch.clientX - centerX; 80 + const dy = touch.clientY - centerY; 81 + this.touchDirection = normalize({ x: dx, y: dy }); 82 + } 83 + 84 + init(container: HTMLElement): void { 85 + this.container = container; 86 + window.addEventListener('keydown', this.onKeyDown); 87 + window.addEventListener('keyup', this.onKeyUp); 88 + container.addEventListener('touchstart', this.onTouchStart, { passive: false }); 89 + container.addEventListener('touchmove', this.onTouchMove, { passive: false }); 90 + container.addEventListener('touchend', this.onTouchEnd, { passive: false }); 91 + container.addEventListener('touchcancel', this.onTouchEnd, { passive: false }); 92 + container.addEventListener('click', this.onClick); 93 + } 94 + 95 + destroy(): void { 96 + window.removeEventListener('keydown', this.onKeyDown); 97 + window.removeEventListener('keyup', this.onKeyUp); 98 + if (this.container) { 99 + this.container.removeEventListener('touchstart', this.onTouchStart); 100 + this.container.removeEventListener('touchmove', this.onTouchMove); 101 + this.container.removeEventListener('touchend', this.onTouchEnd); 102 + this.container.removeEventListener('touchcancel', this.onTouchEnd); 103 + this.container.removeEventListener('click', this.onClick); 104 + } 105 + this.pressedKeys.clear(); 106 + this.touchDirection = { x: 0, y: 0 }; 107 + this.touchActive = false; 108 + this.interactQueued = false; 109 + this.container = null; 110 + } 111 + 112 + getDirection(): Direction { 113 + // Touch takes priority when active 114 + if (this.touchActive) { 115 + return { ...this.touchDirection }; 116 + } 117 + 118 + // Sum all pressed movement keys for diagonal support 119 + let x = 0; 120 + let y = 0; 121 + for (const key of this.pressedKeys) { 122 + const dir = MOVE_KEYS[key]; 123 + if (dir) { 124 + x += dir.x; 125 + y += dir.y; 126 + } 127 + } 128 + return normalize({ x, y }); 129 + } 130 + 131 + isMoving(): boolean { 132 + if (this.touchActive) { 133 + const d = this.touchDirection; 134 + return d.x !== 0 || d.y !== 0; 135 + } 136 + for (const key of this.pressedKeys) { 137 + if (key in MOVE_KEYS) return true; 138 + } 139 + return false; 140 + } 141 + 142 + consumeInteract(): boolean { 143 + if (this.interactQueued) { 144 + this.interactQueued = false; 145 + return true; 146 + } 147 + return false; 148 + } 149 + 150 + isTouchActive(): boolean { 151 + return this.touchActive; 152 + } 153 + }
+237
src/lib/game/IntroSequence.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + oncomplete 4 + }: { 5 + oncomplete?: () => void; 6 + } = $props(); 7 + 8 + let phase = $state(0); 9 + let particles: Array<{ 10 + id: number; 11 + x: number; 12 + y: number; 13 + dx: number; 14 + dy: number; 15 + delay: number; 16 + }> = $state([]); 17 + let completed = false; 18 + let containerEl: HTMLDivElement | undefined = $state(); 19 + 20 + function skip() { 21 + if (completed) return; 22 + completed = true; 23 + oncomplete?.(); 24 + } 25 + 26 + function generateParticles() { 27 + particles = Array.from({ length: 20 }, (_, i) => ({ 28 + id: i, 29 + x: window.innerWidth * 0.4, 30 + y: window.innerHeight * 0.35, 31 + dx: (Math.random() - 0.5) * 100, 32 + dy: (Math.random() - 0.5) * 100, 33 + delay: Math.random() * 1.5 34 + })); 35 + } 36 + 37 + $effect(() => { 38 + containerEl?.focus(); 39 + 40 + const timers = [ 41 + setTimeout(() => { 42 + phase = 1; 43 + generateParticles(); 44 + }, 1000), 45 + setTimeout(() => { 46 + phase = 2; 47 + }, 2000), 48 + setTimeout(() => { 49 + phase = 3; 50 + }, 3000), 51 + setTimeout(() => { 52 + phase = 4; 53 + }, 3500), 54 + setTimeout(() => { 55 + if (!completed) skip(); 56 + }, 4500) 57 + ]; 58 + 59 + return () => timers.forEach(clearTimeout); 60 + }); 61 + </script> 62 + 63 + <div 64 + class="intro-container" 65 + bind:this={containerEl} 66 + onclick={skip} 67 + onkeydown={skip} 68 + tabindex="-1" 69 + role="button" 70 + aria-label="Intro sequence — press any key to skip" 71 + > 72 + <!-- Starfield background --> 73 + <div class="starfield" style="background-image: url('/space.png')"></div> 74 + 75 + <!-- Ship with animation --> 76 + <div 77 + class="ship" 78 + class:shaking={phase >= 1 && phase < 2} 79 + class:spiraling={phase >= 2} 80 + > 81 + <img src="/sprites/ship/ship.png" alt="Ship" /> 82 + </div> 83 + 84 + <!-- Smoke particles --> 85 + {#each particles as p (p.id)} 86 + <div 87 + class="particle" 88 + style="left:{p.x}px;top:{p.y}px;--dx:{p.dx}px;--dy:{p.dy}px;animation-delay:{p.delay}s" 89 + ></div> 90 + {/each} 91 + 92 + <!-- Flash overlay --> 93 + <div class="flash" class:active={phase >= 3}></div> 94 + 95 + <!-- Fade to black --> 96 + <div class="fade" class:active={phase >= 4}></div> 97 + 98 + <!-- Skip text --> 99 + {#if phase < 3} 100 + <p class="skip-text">Press any key to skip</p> 101 + {/if} 102 + </div> 103 + 104 + <style> 105 + .intro-container { 106 + position: fixed; 107 + inset: 0; 108 + z-index: 1000; 109 + overflow: hidden; 110 + background: #000; 111 + outline: none; 112 + cursor: pointer; 113 + } 114 + 115 + .starfield { 116 + position: absolute; 117 + inset: 0; 118 + background-size: cover; 119 + background-position: center; 120 + opacity: 0.8; 121 + } 122 + 123 + .ship { 124 + position: absolute; 125 + top: 0; 126 + left: 0; 127 + image-rendering: pixelated; 128 + animation: fly-in 1s ease-out forwards; 129 + } 130 + 131 + .ship img { 132 + width: 132px; 133 + height: 102px; 134 + image-rendering: pixelated; 135 + } 136 + 137 + .ship.shaking { 138 + animation: 139 + fly-in 1s ease-out forwards, 140 + shake 0.1s linear infinite; 141 + } 142 + 143 + .ship.spiraling { 144 + animation: spiral-down 1s ease-in forwards; 145 + } 146 + 147 + @keyframes fly-in { 148 + from { 149 + transform: translate(100vw, -20vh) rotate(-15deg); 150 + } 151 + to { 152 + transform: translate(30vw, 30vh) rotate(-5deg); 153 + } 154 + } 155 + 156 + @keyframes shake { 157 + 0%, 158 + 100% { 159 + transform: translate(30vw, 30vh) rotate(-5deg) translate(0, 0); 160 + } 161 + 25% { 162 + transform: translate(30vw, 30vh) rotate(-5deg) translate(-3px, 2px); 163 + } 164 + 50% { 165 + transform: translate(30vw, 30vh) rotate(-5deg) translate(2px, -2px); 166 + } 167 + 75% { 168 + transform: translate(30vw, 30vh) rotate(-5deg) translate(-2px, -1px); 169 + } 170 + } 171 + 172 + @keyframes spiral-down { 173 + from { 174 + transform: translate(30vw, 30vh) rotate(-5deg); 175 + } 176 + to { 177 + transform: translate(40vw, 120vh) rotate(720deg); 178 + } 179 + } 180 + 181 + .particle { 182 + position: absolute; 183 + width: 4px; 184 + height: 4px; 185 + border-radius: 50%; 186 + background: #ff6b2c; 187 + animation: particle-fly 0.8s ease-out forwards; 188 + } 189 + 190 + @keyframes particle-fly { 191 + from { 192 + opacity: 1; 193 + transform: translate(0, 0); 194 + } 195 + to { 196 + opacity: 0; 197 + transform: translate(var(--dx), var(--dy)); 198 + } 199 + } 200 + 201 + .flash { 202 + position: absolute; 203 + inset: 0; 204 + background: white; 205 + opacity: 0; 206 + transition: opacity 0.15s; 207 + pointer-events: none; 208 + } 209 + 210 + .flash.active { 211 + opacity: 1; 212 + } 213 + 214 + .fade { 215 + position: absolute; 216 + inset: 0; 217 + background: black; 218 + opacity: 0; 219 + transition: opacity 1s; 220 + pointer-events: none; 221 + } 222 + 223 + .fade.active { 224 + opacity: 1; 225 + } 226 + 227 + .skip-text { 228 + position: absolute; 229 + bottom: 2rem; 230 + right: 2rem; 231 + color: rgba(255, 255, 255, 0.5); 232 + font-size: 0.9rem; 233 + font-family: monospace; 234 + pointer-events: none; 235 + margin: 0; 236 + } 237 + </style>
+65
src/lib/game/PlayerCanvas.svelte
··· 1 + <script lang="ts"> 2 + import { SpriteAnimator } from './SpriteAnimator'; 3 + import { GAME_CONSTANTS } from './types'; 4 + import type { Vec2 } from './types'; 5 + 6 + let canvas: HTMLCanvasElement; 7 + let ctx: CanvasRenderingContext2D | null = null; 8 + const animator = new SpriteAnimator({ walkFps: 8, idleFps: 2 }); 9 + let ready = $state(false); 10 + 11 + // Preload sprites 12 + $effect(() => { 13 + let cancelled = false; 14 + 15 + animator.preload().then(() => { 16 + if (!cancelled) ready = true; 17 + }); 18 + 19 + return () => { 20 + cancelled = true; 21 + }; 22 + }); 23 + 24 + // Initialize canvas once sprites are loaded and the element is bound 25 + $effect(() => { 26 + if (!ready || !canvas) return; 27 + 28 + const scale = GAME_CONSTANTS.SPRITE_SCALE; 29 + canvas.width = animator.getFrameWidth() * scale; 30 + canvas.height = animator.getFrameHeight() * scale; 31 + ctx = canvas.getContext('2d'); 32 + }); 33 + 34 + export function update(dt: number, direction: Vec2, isMoving: boolean): void { 35 + if (!ready || !ctx) return; 36 + 37 + animator.update(dt, direction, isMoving); 38 + 39 + const frame = animator.getCurrentFrame(); 40 + const scale = GAME_CONSTANTS.SPRITE_SCALE; 41 + 42 + ctx.clearRect(0, 0, canvas.width, canvas.height); 43 + ctx.imageSmoothingEnabled = false; 44 + ctx.drawImage(frame, 0, 0, frame.naturalWidth * scale, frame.naturalHeight * scale); 45 + } 46 + </script> 47 + 48 + <canvas bind:this={canvas} class="player-canvas" class:visible={ready}></canvas> 49 + 50 + <style> 51 + .player-canvas { 52 + position: fixed; 53 + left: 50%; 54 + top: 50%; 55 + transform: translate(-50%, -50%); 56 + pointer-events: none; 57 + z-index: 100; 58 + image-rendering: pixelated; 59 + visibility: hidden; 60 + } 61 + 62 + .player-canvas.visible { 63 + visibility: visible; 64 + } 65 + </style>
+168
src/lib/game/Present.svelte
··· 1 + <script lang="ts"> 2 + import type { WorldPresent } from './types'; 3 + 4 + let { 5 + present, 6 + onselect 7 + }: { 8 + present: WorldPresent; 9 + onselect?: (present: WorldPresent) => void; 10 + } = $props(); 11 + 12 + const colors = ['closed.png', 'closed-blue.png', 'closed-green.png']; 13 + 14 + let colorIndex = $derived( 15 + present.id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) % colors.length 16 + ); 17 + let spriteSrc = $derived(`/sprites/present/${colors[colorIndex]}`); 18 + let zIndex = $derived(Math.floor(present.position.y)); 19 + 20 + function formatDuration(ns: number): string { 21 + const totalSeconds = Math.floor(ns / 1_000_000_000); 22 + const h = Math.floor(totalSeconds / 3600); 23 + const m = Math.floor((totalSeconds % 3600) / 60); 24 + const s = totalSeconds % 60; 25 + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; 26 + return `${m}:${String(s).padStart(2, '0')}`; 27 + } 28 + 29 + function formatDate(iso: string): string { 30 + return new Date(iso).toLocaleDateString('en-US', { 31 + month: 'short', 32 + day: 'numeric', 33 + year: 'numeric' 34 + }); 35 + } 36 + 37 + function truncate(text: string, max: number): string { 38 + return text.length > max ? text.slice(0, max - 1) + '…' : text; 39 + } 40 + 41 + function handleClick() { 42 + onselect?.(present); 43 + } 44 + </script> 45 + 46 + <div 47 + id="video-{present.id}" 48 + class="present" 49 + class:nearby={present.isNearby} 50 + style="left: {present.position.x}px; top: {present.position.y}px; z-index: {zIndex};" 51 + onclick={handleClick} 52 + role="button" 53 + tabindex="0" 54 + onkeydown={(e) => { 55 + if (e.key === 'Enter' || e.key === ' ') handleClick(); 56 + }} 57 + > 58 + <div class="sprite-wrapper" class:bouncing={present.isNearby}> 59 + <img 60 + src={spriteSrc} 61 + alt={present.title} 62 + class="sprite" 63 + width="24" 64 + height="24" 65 + draggable="false" 66 + /> 67 + </div> 68 + 69 + <div class="tooltip" class:visible={present.isNearby}> 70 + <span class="tooltip-title">{truncate(present.title, 48)}</span> 71 + {#if present.creatorHandle} 72 + <span class="tooltip-creator">@{present.creatorHandle}</span> 73 + {/if} 74 + <span class="tooltip-meta"> 75 + {formatDuration(present.duration)} &bull; {formatDate(present.createdAt)} 76 + </span> 77 + </div> 78 + </div> 79 + 80 + <style> 81 + .present { 82 + position: absolute; 83 + cursor: pointer; 84 + transform: translate(-50%, -50%); 85 + } 86 + 87 + .sprite-wrapper { 88 + display: flex; 89 + align-items: center; 90 + justify-content: center; 91 + transition: filter 0.2s ease; 92 + } 93 + 94 + .sprite { 95 + width: 72px; 96 + height: 72px; 97 + image-rendering: pixelated; 98 + pointer-events: none; 99 + } 100 + 101 + .nearby .sprite-wrapper { 102 + filter: drop-shadow(0 0 8px rgba(255, 200, 80, 0.7)); 103 + } 104 + 105 + .bouncing { 106 + animation: bounce 0.6s ease-in-out infinite; 107 + } 108 + 109 + @keyframes bounce { 110 + 0%, 111 + 100% { 112 + transform: translateY(0); 113 + } 114 + 50% { 115 + transform: translateY(-6px); 116 + } 117 + } 118 + 119 + .tooltip { 120 + position: absolute; 121 + bottom: 100%; 122 + left: 50%; 123 + transform: translateX(-50%); 124 + margin-bottom: 8px; 125 + padding: 6px 10px; 126 + background: rgba(20, 18, 30, 0.88); 127 + border-radius: 6px; 128 + max-width: 200px; 129 + white-space: normal; 130 + word-wrap: break-word; 131 + display: flex; 132 + flex-direction: column; 133 + gap: 2px; 134 + text-align: center; 135 + pointer-events: none; 136 + 137 + opacity: 0; 138 + visibility: hidden; 139 + transition: 140 + opacity 0.18s ease, 141 + visibility 0.18s ease; 142 + } 143 + 144 + .tooltip.visible { 145 + opacity: 1; 146 + visibility: visible; 147 + } 148 + 149 + .tooltip-title { 150 + color: #fff; 151 + font-weight: 700; 152 + font-size: 0.75rem; 153 + line-height: 1.2; 154 + font-family: 'Press Start 2P', monospace, system-ui; 155 + } 156 + 157 + .tooltip-creator { 158 + color: rgba(255, 255, 255, 0.7); 159 + font-size: 0.65rem; 160 + font-family: 'Press Start 2P', monospace, system-ui; 161 + } 162 + 163 + .tooltip-meta { 164 + color: rgba(255, 255, 255, 0.5); 165 + font-size: 0.6rem; 166 + font-family: 'Press Start 2P', monospace, system-ui; 167 + } 168 + </style>
+216
src/lib/game/PresentDetail.svelte
··· 1 + <script lang="ts"> 2 + import type { WorldPresent } from './types'; 3 + import { resolveHandle } from '$lib/api'; 4 + 5 + let { 6 + present, 7 + onwatch, 8 + onclose 9 + }: { 10 + present: WorldPresent; 11 + onwatch?: () => void; 12 + onclose?: () => void; 13 + } = $props(); 14 + 15 + let handle = $state<string | null>(null); 16 + let thumbnailUrl = $state<string | null>(present.thumbnailUrl ?? null); 17 + 18 + $effect(() => { 19 + const did = present.creator; 20 + if (present.creatorHandle) { 21 + handle = present.creatorHandle; 22 + } else { 23 + resolveHandle(did) 24 + .then((h) => { 25 + handle = h; 26 + }) 27 + .catch(() => { 28 + handle = did; 29 + }); 30 + } 31 + }); 32 + 33 + $effect(() => { 34 + function onKeydown(e: KeyboardEvent) { 35 + if (e.key === 'Escape') onclose?.(); 36 + if (e.key === 'Enter') onwatch?.(); 37 + } 38 + window.addEventListener('keydown', onKeydown); 39 + return () => window.removeEventListener('keydown', onKeydown); 40 + }); 41 + 42 + function formatDuration(ns: number): string { 43 + const totalSeconds = Math.floor(ns / 1_000_000_000); 44 + const h = Math.floor(totalSeconds / 3600); 45 + const m = Math.floor((totalSeconds % 3600) / 60); 46 + const s = totalSeconds % 60; 47 + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; 48 + return `${m}:${String(s).padStart(2, '0')}`; 49 + } 50 + 51 + function formatDate(iso: string): string { 52 + return new Date(iso).toLocaleDateString('en-US', { 53 + month: 'short', 54 + day: 'numeric', 55 + year: 'numeric' 56 + }); 57 + } 58 + 59 + function handleOverlayClick(e: MouseEvent) { 60 + if (e.target === e.currentTarget) onclose?.(); 61 + } 62 + </script> 63 + 64 + <div 65 + class="present-detail-overlay" 66 + onclick={handleOverlayClick} 67 + role="dialog" 68 + aria-label="Video details" 69 + > 70 + <div class="present-detail" onclick={(e) => e.stopPropagation()} role="presentation"> 71 + <button class="close-btn" onclick={() => onclose?.()} aria-label="Close">✕</button> 72 + 73 + <div class="thumbnail"> 74 + {#if thumbnailUrl} 75 + <img src={thumbnailUrl} alt={present.title} /> 76 + {/if} 77 + </div> 78 + 79 + <div class="info"> 80 + <h2 class="title">{present.title}</h2> 81 + 82 + <div class="creator"> 83 + {#if handle} 84 + <a href="https://bsky.app/profile/{handle}" target="_blank" rel="noopener"> 85 + @{handle} 86 + </a> 87 + {:else} 88 + <span class="loading">Loading...</span> 89 + {/if} 90 + </div> 91 + 92 + <div class="meta"> 93 + <span class="duration">{formatDuration(present.duration)}</span> 94 + <span class="separator">•</span> 95 + <span class="date">{formatDate(present.createdAt)}</span> 96 + </div> 97 + 98 + <button class="watch-btn" onclick={() => onwatch?.()}>▶ Watch</button> 99 + </div> 100 + </div> 101 + </div> 102 + 103 + <style> 104 + .present-detail-overlay { 105 + position: fixed; 106 + inset: 0; 107 + z-index: 500; 108 + display: flex; 109 + align-items: center; 110 + justify-content: center; 111 + background: rgba(0, 0, 0, 0.7); 112 + animation: fade-in 0.2s ease; 113 + } 114 + 115 + .present-detail { 116 + background: #1a1a2e; 117 + border: 3px solid #e94560; 118 + border-radius: 8px; 119 + padding: 1.5rem; 120 + max-width: 420px; 121 + width: 90vw; 122 + color: #fff; 123 + position: relative; 124 + font-family: 'Press Start 2P', monospace, system-ui; 125 + box-shadow: 0 0 30px rgba(233, 69, 96, 0.3); 126 + } 127 + 128 + .close-btn { 129 + position: absolute; 130 + top: 0.5rem; 131 + right: 0.5rem; 132 + background: none; 133 + border: none; 134 + color: #fff; 135 + font-size: 1.2rem; 136 + cursor: pointer; 137 + padding: 0.25rem 0.5rem; 138 + z-index: 1; 139 + } 140 + 141 + .close-btn:hover { 142 + color: #e94560; 143 + } 144 + 145 + .thumbnail { 146 + width: 100%; 147 + aspect-ratio: 16 / 9; 148 + overflow: hidden; 149 + border-radius: 4px; 150 + margin-bottom: 1rem; 151 + background: linear-gradient(135deg, #16213e, #1a1a2e); 152 + } 153 + 154 + .thumbnail img { 155 + width: 100%; 156 + height: 100%; 157 + object-fit: cover; 158 + } 159 + 160 + .title { 161 + font-size: 0.85rem; 162 + margin: 0 0 0.5rem; 163 + line-height: 1.4; 164 + color: #fff; 165 + } 166 + 167 + .creator a { 168 + color: #e94560; 169 + text-decoration: none; 170 + font-size: 0.7rem; 171 + } 172 + 173 + .creator a:hover { 174 + text-decoration: underline; 175 + } 176 + 177 + .loading { 178 + color: #888; 179 + font-size: 0.7rem; 180 + } 181 + 182 + .meta { 183 + color: #888; 184 + font-size: 0.65rem; 185 + margin: 0.5rem 0 1rem; 186 + display: flex; 187 + gap: 0.4rem; 188 + align-items: center; 189 + } 190 + 191 + .watch-btn { 192 + background: #e94560; 193 + color: #fff; 194 + border: none; 195 + padding: 0.75rem 2rem; 196 + font-size: 0.8rem; 197 + font-family: 'Press Start 2P', monospace, system-ui; 198 + cursor: pointer; 199 + border-radius: 4px; 200 + width: 100%; 201 + transition: background 0.2s; 202 + } 203 + 204 + .watch-btn:hover { 205 + background: #ff6b81; 206 + } 207 + 208 + @keyframes fade-in { 209 + from { 210 + opacity: 0; 211 + } 212 + to { 213 + opacity: 1; 214 + } 215 + } 216 + </style>
+140
src/lib/game/SpriteAnimator.ts
··· 1 + type Direction = { x: number; y: number }; 2 + type AnimationState = 'idle' | 'walk-up' | 'walk-down' | 'walk-left' | 'walk-right'; 3 + 4 + const WALK_STATES: AnimationState[] = ['walk-up', 'walk-down', 'walk-left', 'walk-right']; 5 + const ALL_STATES: AnimationState[] = ['idle', ...WALK_STATES]; 6 + 7 + const FRAME_COUNTS: Record<AnimationState, number> = { 8 + 'idle': 2, 9 + 'walk-up': 4, 10 + 'walk-down': 4, 11 + 'walk-left': 4, 12 + 'walk-right': 4, 13 + }; 14 + 15 + function resolveDirection(direction: Direction, isMoving: boolean): AnimationState { 16 + if (!isMoving) return 'idle'; 17 + 18 + const { x, y } = direction; 19 + 20 + if (Math.abs(y) > Math.abs(x)) { 21 + return y < 0 ? 'walk-up' : 'walk-down'; 22 + } 23 + 24 + // |x| >= |y| — horizontal takes priority on tie 25 + if (x < 0) return 'walk-left'; 26 + if (x > 0) return 'walk-right'; 27 + 28 + // Zero vector while isMoving is true — fall back to idle 29 + return 'idle'; 30 + } 31 + 32 + export class SpriteAnimator { 33 + private readonly walkFps: number; 34 + private readonly idleFps: number; 35 + private readonly frames: Map<AnimationState, HTMLImageElement[]> = new Map(); 36 + 37 + private state: AnimationState = 'idle'; 38 + private frameIndex = 0; 39 + private elapsed = 0; 40 + private loaded = false; 41 + 42 + // Cached from the first loaded image 43 + private frameWidth = 0; 44 + private frameHeight = 0; 45 + 46 + constructor(options?: { walkFps?: number; idleFps?: number }) { 47 + this.walkFps = options?.walkFps ?? 8; 48 + this.idleFps = options?.idleFps ?? 2; 49 + 50 + for (const state of ALL_STATES) { 51 + const count = FRAME_COUNTS[state]; 52 + const images: HTMLImageElement[] = []; 53 + 54 + for (let i = 1; i <= count; i++) { 55 + const img = new Image(); 56 + img.src = `/sprites/earl/${state}-${i}.png`; 57 + images.push(img); 58 + } 59 + 60 + this.frames.set(state, images); 61 + } 62 + } 63 + 64 + /** Preload every sprite image. Resolves once all are decoded. */ 65 + async preload(): Promise<void> { 66 + const promises: Promise<void>[] = []; 67 + 68 + for (const state of ALL_STATES) { 69 + const images = this.frames.get(state)!; 70 + for (const img of images) { 71 + promises.push( 72 + new Promise<void>((resolve, reject) => { 73 + if (img.complete && img.naturalWidth > 0) { 74 + resolve(); 75 + return; 76 + } 77 + img.onload = () => resolve(); 78 + img.onerror = () => 79 + reject(new Error(`Failed to load sprite: ${img.src}`)); 80 + }), 81 + ); 82 + } 83 + } 84 + 85 + await Promise.all(promises); 86 + 87 + // Cache dimensions from the first available image 88 + const first = this.frames.get('idle')![0]; 89 + this.frameWidth = first.naturalWidth; 90 + this.frameHeight = first.naturalHeight; 91 + 92 + this.loaded = true; 93 + } 94 + 95 + /** 96 + * Advance the animation clock. 97 + * @param dt Elapsed time since last call, in seconds. 98 + */ 99 + update(dt: number, direction: Direction, isMoving: boolean): void { 100 + const next = resolveDirection(direction, isMoving); 101 + 102 + if (next !== this.state) { 103 + this.state = next; 104 + this.frameIndex = 0; 105 + this.elapsed = 0; 106 + } 107 + 108 + const fps = this.state === 'idle' ? this.idleFps : this.walkFps; 109 + const frameDuration = 1 / fps; 110 + 111 + this.elapsed += dt; 112 + 113 + while (this.elapsed >= frameDuration) { 114 + this.elapsed -= frameDuration; 115 + const count = FRAME_COUNTS[this.state]; 116 + this.frameIndex = (this.frameIndex + 1) % count; 117 + } 118 + } 119 + 120 + /** Return the preloaded Image for the current animation frame. */ 121 + getCurrentFrame(): HTMLImageElement { 122 + return this.frames.get(this.state)![this.frameIndex]; 123 + } 124 + 125 + getAnimationState(): AnimationState { 126 + return this.state; 127 + } 128 + 129 + getFrameWidth(): number { 130 + return this.frameWidth; 131 + } 132 + 133 + getFrameHeight(): number { 134 + return this.frameHeight; 135 + } 136 + 137 + isLoaded(): boolean { 138 + return this.loaded; 139 + } 140 + }
+110
src/lib/game/WorldMap.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + import type { Vec2, WorldConfig } from './types'; 4 + import { GAME_CONSTANTS } from './types'; 5 + 6 + let { 7 + worldConfig, 8 + playerPosition, 9 + children 10 + }: { 11 + worldConfig: WorldConfig; 12 + playerPosition: Vec2; 13 + children?: Snippet; 14 + } = $props(); 15 + 16 + function clamp(value: number, min: number, max: number): number { 17 + return Math.max(min, Math.min(max, value)); 18 + } 19 + 20 + // Camera state — start at player position 21 + let cameraX = $state(0); 22 + let cameraY = $state(0); 23 + let viewportWidth = $state(0); 24 + let viewportHeight = $state(0); 25 + let initialized = $state(false); 26 + 27 + let viewportEl: HTMLDivElement; 28 + 29 + // Track viewport dimensions via ResizeObserver 30 + $effect(() => { 31 + if (!viewportEl) return; 32 + 33 + const ro = new ResizeObserver((entries) => { 34 + const entry = entries[0]; 35 + if (entry) { 36 + viewportWidth = entry.contentRect.width; 37 + viewportHeight = entry.contentRect.height; 38 + } 39 + }); 40 + 41 + ro.observe(viewportEl); 42 + return () => ro.disconnect(); 43 + }); 44 + 45 + // Lerp camera toward player — runs reactively when playerPosition changes 46 + $effect(() => { 47 + const targetX = playerPosition.x; 48 + const targetY = playerPosition.y; 49 + 50 + if (!initialized) { 51 + cameraX = targetX; 52 + cameraY = targetY; 53 + initialized = true; 54 + return; 55 + } 56 + 57 + const lerp = GAME_CONSTANTS.CAMERA_LERP; 58 + cameraX += (targetX - cameraX) * lerp; 59 + cameraY += (targetY - cameraY) * lerp; 60 + }); 61 + 62 + // Clamped camera offsets so the view never leaves world bounds 63 + let cameraLeft = $derived( 64 + clamp(cameraX - viewportWidth / 2, 0, Math.max(0, worldConfig.width - viewportWidth)) 65 + ); 66 + let cameraTop = $derived( 67 + clamp(cameraY - viewportHeight / 2, 0, Math.max(0, worldConfig.height - viewportHeight)) 68 + ); 69 + </script> 70 + 71 + <div class="viewport" bind:this={viewportEl}> 72 + <div 73 + class="world" 74 + style=" 75 + width: {worldConfig.width}px; 76 + height: {worldConfig.height}px; 77 + transform: translate({-cameraLeft}px, {-cameraTop}px); 78 + background-size: {worldConfig.tileSize}px {worldConfig.tileSize}px; 79 + " 80 + > 81 + <div class="depth-container"> 82 + {@render children?.()} 83 + </div> 84 + </div> 85 + </div> 86 + 87 + <style> 88 + .viewport { 89 + position: relative; 90 + width: 100vw; 91 + height: 100vh; 92 + overflow: hidden; 93 + background-color: #1a1a2e; 94 + } 95 + 96 + .world { 97 + position: absolute; 98 + top: 0; 99 + left: 0; 100 + will-change: transform; 101 + background-image: url('/sprites/terrain/grass-tile.png'); 102 + background-repeat: repeat; 103 + } 104 + 105 + .depth-container { 106 + position: relative; 107 + width: 100%; 108 + height: 100%; 109 + } 110 + </style>
+78
src/lib/game/types.ts
··· 1 + // Game state machine phases 2 + export type GameState = 'intro' | 'playing' | 'viewing-present' | 'watching-video'; 3 + 4 + // 2D vector 5 + export interface Vec2 { 6 + x: number; 7 + y: number; 8 + } 9 + 10 + // Player state 11 + export interface PlayerState { 12 + position: Vec2; // World position in pixels 13 + velocity: Vec2; // Current velocity 14 + speed: number; // Movement speed (pixels per second) 15 + direction: Vec2; // Facing direction (normalized) 16 + isMoving: boolean; 17 + } 18 + 19 + // A VOD present on the world map 20 + export interface WorldPresent { 21 + id: string; // rkey from AT URI for anchor linking 22 + uri: string; // Full AT URI 23 + position: Vec2; // World position 24 + title: string; 25 + creator: string; // DID 26 + creatorHandle?: string; // Resolved @handle 27 + duration: number; // Nanoseconds 28 + createdAt: string; // ISO timestamp 29 + thumbnailUrl?: string; 30 + isNearby: boolean; // Within interaction range 31 + isOpen: boolean; // Currently viewing detail 32 + livestream?: { 33 + // For thumbnail resolution 34 + cid: string; 35 + uri: string; 36 + }; 37 + } 38 + 39 + // World configuration 40 + export interface WorldConfig { 41 + width: number; // World width in pixels 42 + height: number; // World height in pixels 43 + tileSize: number; // Size of ground tiles 44 + presentProximity: number; // Distance to trigger present interaction 45 + playerSpeed: number; // Pixels per second 46 + spawnPoint: Vec2; // Where player starts after intro 47 + } 48 + 49 + // Camera state 50 + export interface CameraState { 51 + position: Vec2; // Camera center in world coordinates 52 + viewportWidth: number; 53 + viewportHeight: number; 54 + // Computed bounds 55 + left: number; 56 + top: number; 57 + right: number; 58 + bottom: number; 59 + } 60 + 61 + // Scenery decoration on the world map 62 + export interface SceneryItem { 63 + type: 'tree' | 'bush' | 'rock' | 'flower' | 'grass-patch'; 64 + position: Vec2; 65 + sprite: string; // Path to sprite image 66 + scale: number; // Size multiplier 67 + zOffset: number; // Depth sort adjustment 68 + } 69 + 70 + // Game constants 71 + export const GAME_CONSTANTS = { 72 + TILE_SIZE: 64, 73 + PLAYER_SPEED: 200, // pixels per second 74 + PRESENT_PROXIMITY: 80, // pixels to trigger interaction 75 + WORLD_PADDING: 200, // extra space around present bounds 76 + CAMERA_LERP: 0.1, // camera smoothing factor (0-1) 77 + SPRITE_SCALE: 2 // scale up pixel art sprites 78 + } as const;
+3 -30
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount, onDestroy } from 'svelte'; 3 - import MeshBackground from '$lib/MeshBackground.svelte'; 4 - 5 2 let { children } = $props(); 6 - 7 - let cursorInterval: ReturnType<typeof setInterval>; 8 - 9 - onMount(() => { 10 - const frames = [ 11 - "url('/toe-jam-standing-1.png') 16 16, auto", 12 - "url('/toe-jam-standing-2.png') 16 16, auto", 13 - "url('/toe-jam-standing-3.png') 16 16, auto", 14 - ]; 15 - let i = 0; 16 - document.documentElement.style.cursor = frames[0]; 17 - cursorInterval = setInterval(() => { 18 - i = (i + 1) % frames.length; 19 - document.documentElement.style.cursor = frames[i]; 20 - }, 200); 21 - }); 22 - 23 - onDestroy(() => { 24 - if (cursorInterval) clearInterval(cursorInterval); 25 - }); 26 3 </script> 27 4 28 5 <svelte:head> ··· 42 19 font-style: normal; 43 20 font-display: swap; 44 21 } 45 - /* Animated cursor — body and descendants inherit from html (set via JS) */ 46 - body, body *, body *::before, body *::after { 47 - cursor: inherit !important; 48 - } 49 22 /* Font-switch animation for ToeJam & Earl color fonts */ 50 23 @keyframes font-switch { 51 24 0%, 49.9% { font-family: 'ToeJamEarl1', 'Comic Sans MS', 'Comic Sans', cursive, system-ui; } ··· 53 26 } 54 27 </style> 55 28 </svelte:head> 56 - 57 - <MeshBackground /> 58 29 59 30 {@render children()} 60 31 ··· 63 34 margin: 0; 64 35 padding: 0; 65 36 min-height: 100vh; 66 - overflow-x: hidden; 37 + overflow: hidden; 38 + background: #0b0e17; 67 39 } 68 40 69 41 :global(body) { 70 42 font-family: 'Comic Sans MS', 'Comic Sans', cursive, system-ui; 43 + color: #e8e0ff; 71 44 } 72 45 73 46 :global(*) {
+255 -316
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from "svelte"; 3 - import { 4 - listVideos, 5 - fetchAllVideos, 6 - getPlaylistUrl, 7 - formatDuration, 8 - formatDate, 9 - resolveHandle, 10 - getProfile, 11 - type VideoRecord, 12 - } from "$lib/api"; 13 - import VideoPlayer from "$lib/VideoPlayer.svelte"; 14 - import VideoCard from "$lib/VideoCard.svelte"; 15 - import VodjamHeader from "$lib/VodjamHeader.svelte"; 16 - import WavyBorder from "$lib/WavyBorder.svelte"; 17 - import WavyCircle from "$lib/WavyCircle.svelte"; 18 - import SpriteTime from "$lib/SpriteTime.svelte"; 19 - import Footer from "$lib/Footer.svelte"; 2 + import { 3 + fetchAllVideos, 4 + getPlaylistUrl, 5 + type VideoRecord, 6 + } from '$lib/api'; 7 + import type { GameState, WorldPresent } from '$lib/game/types'; 8 + import IntroSequence from '$lib/game/IntroSequence.svelte'; 9 + import GameWorld from '$lib/game/GameWorld.svelte'; 10 + import PresentDetail from '$lib/game/PresentDetail.svelte'; 11 + import VideoPlayer from '$lib/VideoPlayer.svelte'; 12 + import VodjamHeader from '$lib/VodjamHeader.svelte'; 20 13 21 - const PAGE_SIZE = 9; 14 + let videos: VideoRecord[] = $state([]); 15 + let loading = $state(true); 16 + let error = $state(''); 22 17 23 - let videos: VideoRecord[] = $state([]); 24 - let loading = $state(false); 25 - let selectedVideo: VideoRecord | null = $state(null); 26 - let selectedHandle = $state(""); 27 - let selectedAvatar = $state(""); 28 - let error = $state(""); 18 + let gameState: GameState = $state('intro'); 19 + let selectedPresent: WorldPresent | null = $state(null); 20 + let focusVideoId: string | undefined = $state(undefined); 29 21 30 - // Pagination state 31 - let pageIndex = $state(0); 32 - let hasMore = $state(true); 22 + // Load videos and check URL hash 23 + $effect(() => { 24 + fetchAllVideos() 25 + .then((all) => { 26 + videos = all; 27 + loading = false; 33 28 34 - async function loadPage() { 35 - loading = true; 36 - error = ""; 37 - try { 38 - const res = await listVideos(pageIndex, PAGE_SIZE); 39 - videos = res.records; 40 - hasMore = res.hasMore; 41 - } catch (e: any) { 42 - error = e.message; 43 - } 44 - loading = false; 45 - } 29 + // Check for hash-based deep link: #video-{rkey} 30 + const hash = window.location.hash; 31 + if (hash.startsWith('#video-')) { 32 + focusVideoId = hash.slice(7); // remove '#video-' 33 + gameState = 'playing'; // skip intro 34 + } 35 + }) 36 + .catch((e) => { 37 + error = e.message; 38 + loading = false; 39 + }); 40 + }); 46 41 47 - function nextPage() { 48 - if (!hasMore || loading) return; 49 - pageIndex++; 50 - loadPage(); 51 - window.scrollTo({ top: 0, behavior: "smooth" }); 52 - } 42 + function handleIntroComplete() { 43 + gameState = 'playing'; 44 + } 53 45 54 - function prevPage() { 55 - if (pageIndex <= 0 || loading) return; 56 - pageIndex--; 57 - loadPage(); 58 - window.scrollTo({ top: 0, behavior: "smooth" }); 59 - } 46 + function handlePresentSelect(present: WorldPresent) { 47 + selectedPresent = present; 48 + gameState = 'viewing-present'; 49 + window.history.pushState(null, '', `#video-${present.id}`); 50 + } 60 51 61 - function selectVideo(video: VideoRecord) { 62 - selectedVideo = video; 63 - selectedAvatar = ""; 64 - resolveHandle(video.value.creator).then((h) => { 65 - selectedHandle = h; 66 - // Fetch avatar from profile 67 - const handle = h.replace('@', ''); 68 - getProfile(handle).then((p) => { if (p.avatar) selectedAvatar = p.avatar; }).catch(() => {}); 69 - }); 70 - // Update URL with the video URI (at://did/collection/rkey) 71 - const url = new URL(window.location.href); 72 - url.searchParams.set("v", video.uri); 73 - window.history.pushState({}, "", url.toString()); 74 - window.scrollTo({ top: 0, behavior: "smooth" }); 75 - } 52 + function handleWatch() { 53 + gameState = 'watching-video'; 54 + } 76 55 77 - function closePlayer() { 78 - selectedVideo = null; 79 - const url = new URL(window.location.href); 80 - url.searchParams.delete("v"); 81 - window.history.pushState({}, "", url.toString()); 82 - } 56 + function handleCloseDetail() { 57 + selectedPresent = null; 58 + gameState = 'playing'; 59 + window.history.pushState(null, '', window.location.pathname); 60 + } 83 61 84 - // On mount: load first page, then check URL for a selected video 85 - onMount(async () => { 86 - await loadPage(); 87 - const params = new URLSearchParams(window.location.search); 88 - const videoUri = params.get("v"); 89 - if (videoUri) { 90 - // Search all videos, not just the current page 91 - const all = await fetchAllVideos(); 92 - const found = all.find((v) => v.uri === videoUri); 93 - if (found) { 94 - selectVideo(found); 95 - } 96 - } 97 - }); 62 + function handleCloseVideo() { 63 + gameState = 'viewing-present'; 64 + } 98 65 99 - // Handle browser back/forward 100 - async function onPopState() { 101 - const params = new URLSearchParams(window.location.search); 102 - const videoUri = params.get("v"); 103 - if (videoUri) { 104 - const all = await fetchAllVideos(); 105 - const found = all.find((v) => v.uri === videoUri); 106 - if (found) { 107 - selectedVideo = found; 108 - resolveHandle(found.value.creator).then( 109 - (h) => (selectedHandle = h), 110 - ); 111 - } 112 - } else { 113 - selectedVideo = null; 114 - } 115 - } 66 + function handleBackToGame() { 67 + selectedPresent = null; 68 + gameState = 'playing'; 69 + window.history.pushState(null, '', window.location.pathname); 70 + } 71 + 72 + function handleStateChange(state: GameState) { 73 + gameState = state; 74 + } 75 + 76 + // Browser back/forward 77 + function onPopState() { 78 + const hash = window.location.hash; 79 + if (hash.startsWith('#video-')) { 80 + const rkey = hash.slice(7); 81 + focusVideoId = rkey; 82 + // The GameWorld will handle finding and selecting this present 83 + } else { 84 + selectedPresent = null; 85 + if (gameState !== 'intro') { 86 + gameState = 'playing'; 87 + } 88 + } 89 + } 90 + 91 + function getSelectedVideoUri(): string { 92 + if (!selectedPresent) return ''; 93 + return selectedPresent.uri; 94 + } 116 95 </script> 117 96 118 97 <svelte:window onpopstate={onPopState} /> 119 98 120 99 <svelte:head> 121 - <title>VoD Jam</title> 100 + <title>VoD Jam</title> 122 101 </svelte:head> 123 102 124 - <div class="app"> 125 - <VodjamHeader onHomeClick={closePlayer} /> 103 + {#if loading} 104 + <div class="loading-screen"> 105 + <img src="/ship.png" alt="Loading..." class="loading-ship" /> 106 + <p class="loading-text">Loading VoDs...</p> 107 + </div> 108 + {:else if error} 109 + <div class="error-screen"> 110 + <p class="error-text">⚠ {error}</p> 111 + <button class="retry-btn" onclick={() => window.location.reload()}>Retry</button> 112 + </div> 113 + {:else} 114 + <!-- Intro sequence --> 115 + {#if gameState === 'intro'} 116 + <IntroSequence oncomplete={handleIntroComplete} /> 117 + {/if} 126 118 127 - {#if selectedVideo} 128 - <section class="player-section"> 129 - <WavyBorder seed="player-space" fillImage="/border.png" strokeColor="#0B0E17" strokeWidth={2.5} padding="clamp(24px, 4vw, 48px)"> 130 - <div class="player-inner"> 131 - <VideoPlayer src={getPlaylistUrl(selectedVideo.uri)} /> 132 - <div class="player-info"> 133 - <h2 class="player-title">{selectedVideo.value.title}</h2> 134 - <div class="player-meta-row"> 135 - {#if selectedAvatar} 136 - <a href="/profile/{selectedHandle?.replace('@', '') || selectedVideo.value.creator}" class="creator-avatar-link"> 137 - <WavyCircle seed={selectedVideo.value.creator} fill="#FFD93D" strokeColor="#0B0E17" strokeWidth={1.5} size={40}> 138 - <img src={selectedAvatar} alt="" /> 139 - </WavyCircle> 140 - </a> 141 - {/if} 142 - <p class="player-meta"> 143 - <a href="/profile/{selectedHandle?.replace('@', '') || selectedVideo.value.creator}" class="creator-tag" 144 - >{selectedHandle || 145 - selectedVideo.value.creator}</a 146 - > 147 - <span class="dot">·</span> 148 - {formatDate(selectedVideo.value.createdAt)} 149 - <span class="dot">·</span> 150 - <SpriteTime time={formatDuration(selectedVideo.value.duration)} height={16} /> 151 - </p> 152 - </div> 153 - </div> 154 - </div> 155 - </WavyBorder> 156 - </section> 157 - {/if} 119 + <!-- Game world (always rendered after videos load, hidden during intro) --> 120 + {#if gameState !== 'intro'} 121 + <GameWorld 122 + {videos} 123 + {gameState} 124 + onpresentselect={handlePresentSelect} 125 + onstatechange={handleStateChange} 126 + {focusVideoId} 127 + /> 128 + {/if} 158 129 159 - {#if error} 160 - <div class="error">{error}</div> 161 - {/if} 130 + <!-- Present detail popup --> 131 + {#if gameState === 'viewing-present' && selectedPresent} 132 + <PresentDetail 133 + present={selectedPresent} 134 + onwatch={handleWatch} 135 + onclose={handleCloseDetail} 136 + /> 137 + {/if} 162 138 163 - <section class="grid"> 164 - {#each videos as video (video.uri)} 165 - <VideoCard {video} onSelect={selectVideo} /> 166 - {/each} 167 - </section> 168 - 169 - <div class="pagination"> 170 - {#if pageIndex > 0} 171 - <button class="page-btn" onclick={prevPage} disabled={loading}> 172 - ← previous 173 - </button> 174 - {/if} 175 - <span class="page-num">page {pageIndex + 1}</span> 176 - {#if hasMore} 177 - <button class="page-btn" onclick={nextPage} disabled={loading}> 178 - next → 179 - </button> 180 - {/if} 181 - </div> 182 - 183 - <Footer /> 184 - </div> 139 + <!-- Video player overlay --> 140 + {#if gameState === 'watching-video' && selectedPresent} 141 + <div class="video-overlay"> 142 + <div class="video-overlay-header"> 143 + <VodjamHeader onHomeClick={handleBackToGame} /> 144 + <button class="video-close-btn" onclick={handleCloseVideo}>✕ Close</button> 145 + </div> 146 + <div class="video-container"> 147 + <VideoPlayer src={getPlaylistUrl(getSelectedVideoUri())} /> 148 + <div class="video-info"> 149 + <h2 class="video-title">{selectedPresent.title}</h2> 150 + <p class="video-meta"> 151 + {#if selectedPresent.creatorHandle} 152 + <a 153 + href="https://bsky.app/profile/{selectedPresent.creatorHandle.replace('@', '')}" 154 + target="_blank" 155 + rel="noopener" 156 + class="creator-link" 157 + > 158 + {selectedPresent.creatorHandle} 159 + </a> 160 + {/if} 161 + </p> 162 + </div> 163 + </div> 164 + </div> 165 + {/if} 166 + {/if} 185 167 186 168 <style> 187 - .app { 188 - max-width: 1300px; 189 - margin: 0 auto; 190 - padding: 0 clamp(24px, 7vw, 120px) 60px; 191 - } 169 + .loading-screen { 170 + display: flex; 171 + flex-direction: column; 172 + align-items: center; 173 + justify-content: center; 174 + height: 100vh; 175 + background: #0b0e17; 176 + color: #e8e0ff; 177 + } 192 178 193 - .player-section { 194 - margin-bottom: 40px; 195 - margin-top: 20px; 196 - padding: 0 10px; 197 - } 179 + .loading-ship { 180 + width: 88px; 181 + height: auto; 182 + image-rendering: pixelated; 183 + animation: float 2s ease-in-out infinite; 184 + } 198 185 199 - .player-inner { 200 - background: url('/space.png') center / cover no-repeat; 201 - padding: 24px; 202 - } 186 + @keyframes float { 187 + 0%, 100% { transform: translateY(0); } 188 + 50% { transform: translateY(-10px); } 189 + } 203 190 204 - .player-info { 205 - padding: 20px 4px; 206 - } 191 + .loading-text { 192 + margin-top: 1rem; 193 + font-family: monospace; 194 + font-size: 1.1rem; 195 + opacity: 0.7; 196 + animation: font-switch 4s steps(1) infinite; 197 + } 207 198 208 - .player-meta-row { 209 - display: flex; 210 - align-items: center; 211 - gap: 10px; 212 - margin-top: 6px; 213 - } 199 + .error-screen { 200 + display: flex; 201 + flex-direction: column; 202 + align-items: center; 203 + justify-content: center; 204 + height: 100vh; 205 + background: #0b0e17; 206 + color: #ff6b2c; 207 + gap: 1rem; 208 + } 214 209 215 - .creator-avatar-link { 216 - flex-shrink: 0; 217 - transition: transform 0.15s; 218 - } 210 + .error-text { 211 + font-family: monospace; 212 + font-size: 1.2rem; 213 + } 219 214 220 - .creator-avatar-link:hover { 221 - transform: scale(1.1); 222 - } 215 + .retry-btn { 216 + background: #ff6b2c; 217 + color: #fff; 218 + border: none; 219 + padding: 0.75rem 2rem; 220 + font-family: monospace; 221 + font-size: 1rem; 222 + cursor: pointer; 223 + border-radius: 4px; 224 + } 223 225 224 - .player-title { 225 - margin: 0; 226 - font-size: clamp(1.4rem, 3vw, 2rem); 227 - color: #E8E0FF; 228 - animation: font-switch 4s steps(1) infinite; 229 - text-shadow: 230 - -1px -1px 0 rgba(11, 14, 23, 0.8), 231 - 1px -1px 0 rgba(11, 14, 23, 0.8), 232 - -1px 1px 0 rgba(11, 14, 23, 0.8), 233 - 1px 1px 0 rgba(11, 14, 23, 0.8); 234 - } 226 + .video-overlay { 227 + position: fixed; 228 + inset: 0; 229 + z-index: 600; 230 + background: #0b0e17; 231 + overflow-y: auto; 232 + display: flex; 233 + flex-direction: column; 234 + } 235 235 236 - .player-meta { 237 - margin: 6px 0 0; 238 - animation: font-switch 4s steps(1) infinite; 239 - color: #E8E0FF; 240 - opacity: 0.7; 241 - font-size: 0.85rem; 242 - text-shadow: 243 - -1px -1px 0 rgba(11, 14, 23, 0.8), 244 - 1px -1px 0 rgba(11, 14, 23, 0.8), 245 - -1px 1px 0 rgba(11, 14, 23, 0.8), 246 - 1px 1px 0 rgba(11, 14, 23, 0.8); 247 - } 236 + .video-overlay-header { 237 + display: flex; 238 + align-items: center; 239 + justify-content: space-between; 240 + padding: 0.5rem 1rem; 241 + flex-shrink: 0; 242 + } 248 243 249 - .creator-tag { 250 - color: #FFD93D; 251 - text-decoration: underline; 252 - } 244 + .video-close-btn { 245 + background: none; 246 + border: 2px solid #e94560; 247 + color: #e94560; 248 + padding: 0.5rem 1rem; 249 + font-family: monospace; 250 + font-size: 0.9rem; 251 + cursor: pointer; 252 + border-radius: 4px; 253 + transition: all 0.2s; 254 + } 253 255 254 - .dot { 255 - margin: 0 4px; 256 - color: #E8E0FF; 257 - opacity: 0.4; 258 - } 256 + .video-close-btn:hover { 257 + background: #e94560; 258 + color: #fff; 259 + } 259 260 260 - .close-btn { 261 - background: #0B0E17; 262 - color: #FFD93D; 263 - border: 2px solid #FFD93D; 264 - border-radius: 50%; 265 - width: 36px; 266 - height: 36px; 267 - font-size: 1rem; 268 - cursor: pointer; 269 - flex-shrink: 0; 270 - font-family: 'Comic Sans MS', 'Comic Sans', cursive, system-ui; 271 - transition: 272 - background 0.15s, 273 - color 0.15s; 274 - } 275 - 276 - .close-btn:hover { 277 - background: #FF6B2C; 278 - border-color: #FF6B2C; 279 - color: #FFD93D; 280 - } 281 - 282 - .grid { 283 - display: grid; 284 - grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); 285 - gap: clamp(32px, 5vw, 50px); 286 - padding: 20px clamp(16px, 3vw, 24px); 287 - } 288 - 289 - .pagination { 290 - display: flex; 291 - justify-content: center; 292 - align-items: center; 293 - gap: 20px; 294 - padding: 30px; 295 - } 296 - 297 - .page-btn { 298 - background: #0B0E17; 299 - color: #FFD93D; 300 - border: 3px solid #0B0E17; 301 - padding: 12px 32px; 302 - border-radius: 40px; 303 - animation: font-switch 4s steps(1) infinite; 304 - font-size: 1.1rem; 305 - cursor: pointer; 306 - transition: all 0.2s ease; 307 - letter-spacing: 0.5px; 308 - text-shadow: 309 - -1px -1px 0 rgba(11, 14, 23, 0.8), 310 - 1px -1px 0 rgba(11, 14, 23, 0.8), 311 - -1px 1px 0 rgba(11, 14, 23, 0.8), 312 - 1px 1px 0 rgba(11, 14, 23, 0.8); 313 - } 261 + .video-container { 262 + flex: 1; 263 + max-width: 1000px; 264 + width: 100%; 265 + margin: 0 auto; 266 + padding: 0 1rem 2rem; 267 + } 314 268 315 - .page-btn:hover { 316 - background: #FFD93D; 317 - color: #0B0E17; 318 - border-color: #0B0E17; 319 - } 269 + .video-info { 270 + padding: 1rem 0; 271 + } 320 272 321 - .page-btn:disabled { 322 - background: #0B0E17; 323 - color: #E8E0FF; 324 - border-color: #0B0E17; 325 - cursor: wait; 326 - opacity: 0.6; 327 - } 273 + .video-title { 274 + margin: 0; 275 + color: #e8e0ff; 276 + font-size: 1.4rem; 277 + animation: font-switch 4s steps(1) infinite; 278 + } 328 279 329 - .page-num { 330 - animation: font-switch 4s steps(1) infinite; 331 - font-size: 1.1rem; 332 - color: #E8E0FF; 333 - text-shadow: 334 - -1px -1px 0 rgba(11, 14, 23, 0.8), 335 - 1px -1px 0 rgba(11, 14, 23, 0.8), 336 - -1px 1px 0 rgba(11, 14, 23, 0.8), 337 - 1px 1px 0 rgba(11, 14, 23, 0.8); 338 - } 280 + .video-meta { 281 + margin: 0.5rem 0 0; 282 + color: #888; 283 + font-family: monospace; 284 + font-size: 0.9rem; 285 + } 339 286 340 - .error { 341 - background: rgba(255, 107, 44, 0.15); 342 - color: #FF6B2C; 343 - padding: 12px 16px; 344 - border-radius: 8px; 345 - margin: 0 10px 20px; 346 - animation: font-switch 4s steps(1) infinite; 347 - border: 2px solid #FF6B2C; 348 - text-shadow: 349 - -1px -1px 0 rgba(11, 14, 23, 0.8), 350 - 1px -1px 0 rgba(11, 14, 23, 0.8), 351 - -1px 1px 0 rgba(11, 14, 23, 0.8), 352 - 1px 1px 0 rgba(11, 14, 23, 0.8); 353 - } 287 + .creator-link { 288 + color: #ffd93d; 289 + text-decoration: none; 290 + } 354 291 355 - /* Fluid values handle all sizes — no hard breakpoints needed */ 292 + .creator-link:hover { 293 + text-decoration: underline; 294 + } 356 295 </style>
-308
src/routes/profile/[handle]/+page.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { page } from '$app/stores'; 4 - import { getProfile, type BskyProfile } from '$lib/api'; 5 - import WavyBorder from '$lib/WavyBorder.svelte'; 6 - import VodjamHeader from '$lib/VodjamHeader.svelte'; 7 - 8 - let profile: BskyProfile | null = $state(null); 9 - let loading = $state(true); 10 - let error = $state(''); 11 - 12 - let handle = $derived($page.params.handle); 13 - 14 - onMount(async () => { 15 - loading = true; 16 - error = ''; 17 - try { 18 - profile = await getProfile(handle); 19 - } catch (e: any) { 20 - error = e.message; 21 - } 22 - loading = false; 23 - }); 24 - </script> 25 - 26 - <svelte:head> 27 - <title>{profile?.displayName || handle} — VoD Jam</title> 28 - </svelte:head> 29 - 30 - <div class="profile-page"> 31 - <VodjamHeader /> 32 - 33 - {#if loading} 34 - <div class="loading"> 35 - <img src="/earl-walk-right-1.png" alt="loading" class="loading-vodjam" /> 36 - <p class="loading-text">hopping over to fetch profile...</p> 37 - </div> 38 - {:else if error} 39 - <div class="error">{error}</div> 40 - {:else if profile} 41 - <div class="profile-layout"> 42 - <!-- Banner --> 43 - {#if profile.banner} 44 - <WavyBorder seed="profile-banner" fill="#1E2952" strokeColor="#0B0E17" strokeWidth={2} padding={4}> 45 - <img src={profile.banner} alt="banner" class="banner-img" /> 46 - </WavyBorder> 47 - {/if} 48 - 49 - <!-- Profile card --> 50 - <div class="profile-card-area"> 51 - <WavyBorder seed="profile-card" fill="#141833" strokeColor="#0B0E17" strokeWidth={2} padding={48}> 52 - <div class="profile-card"> 53 - {#if profile.avatar} 54 - <div class="avatar-wrapper"> 55 - <WavyBorder seed="profile-avatar" fill="#FFD93D" strokeColor="#0B0E17" strokeWidth={1.5} padding={4}> 56 - <img src={profile.avatar} alt={profile.displayName || profile.handle} class="avatar" /> 57 - </WavyBorder> 58 - </div> 59 - {/if} 60 - 61 - <h1 class="display-name">{profile.displayName || profile.handle}</h1> 62 - <p class="handle">@{profile.handle}</p> 63 - 64 - {#if profile.description} 65 - <p class="description">{profile.description}</p> 66 - {/if} 67 - 68 - <div class="stats"> 69 - {#if profile.followersCount !== undefined} 70 - <div class="stat"> 71 - <span class="stat-num">{profile.followersCount}</span> 72 - <span class="stat-label">followers</span> 73 - </div> 74 - {/if} 75 - {#if profile.followsCount !== undefined} 76 - <div class="stat"> 77 - <span class="stat-num">{profile.followsCount}</span> 78 - <span class="stat-label">following</span> 79 - </div> 80 - {/if} 81 - {#if profile.postsCount !== undefined} 82 - <div class="stat"> 83 - <span class="stat-num">{profile.postsCount}</span> 84 - <span class="stat-label">posts</span> 85 - </div> 86 - {/if} 87 - </div> 88 - 89 - <a href="https://witchsky.app/profile/{profile.handle}" target="_blank" class="profile-link"> 90 - view on witchsky → 91 - </a> 92 - </div> 93 - </WavyBorder> 94 - </div> 95 - </div> 96 - {/if} 97 - 98 - <div class="back-link"> 99 - <a href="/">← back to vods</a> 100 - </div> 101 - </div> 102 - 103 - <style> 104 - .profile-page { 105 - max-width: 800px; 106 - margin: 0 auto; 107 - padding: 0 clamp(24px, 7vw, 80px) 60px; 108 - } 109 - 110 - .loading { 111 - text-align: center; 112 - padding: 60px 20px; 113 - } 114 - 115 - .loading-vodjam { 116 - width: 64px; 117 - height: auto; 118 - animation: hop 0.6s ease-in-out infinite alternate; 119 - } 120 - 121 - @keyframes hop { 122 - 0% { transform: translateY(0); } 123 - 100% { transform: translateY(-12px); } 124 - } 125 - 126 - .loading-text { 127 - animation: font-switch 4s steps(1) infinite; 128 - font-size: 1.2rem; 129 - color: #E8E0FF; 130 - margin-top: 12px; 131 - text-shadow: 132 - -1px -1px 0 rgba(11, 14, 23, 0.8), 133 - 1px -1px 0 rgba(11, 14, 23, 0.8), 134 - -1px 1px 0 rgba(11, 14, 23, 0.8), 135 - 1px 1px 0 rgba(11, 14, 23, 0.8); 136 - } 137 - 138 - .error { 139 - background: rgba(255, 107, 44, 0.15); 140 - color: #FF6B2C; 141 - padding: 12px 16px; 142 - border-radius: 8px; 143 - margin: 20px 0; 144 - animation: font-switch 4s steps(1) infinite; 145 - border: 2px solid #FF6B2C; 146 - text-shadow: 147 - -1px -1px 0 rgba(11, 14, 23, 0.8), 148 - 1px -1px 0 rgba(11, 14, 23, 0.8), 149 - -1px 1px 0 rgba(11, 14, 23, 0.8), 150 - 1px 1px 0 rgba(11, 14, 23, 0.8); 151 - } 152 - 153 - .profile-layout { 154 - display: flex; 155 - flex-direction: column; 156 - gap: 24px; 157 - } 158 - 159 - .banner-img { 160 - width: 100%; 161 - display: block; 162 - aspect-ratio: 3 / 1; 163 - object-fit: cover; 164 - } 165 - 166 - .profile-card-area { 167 - margin-top: -20px; 168 - } 169 - 170 - .profile-card { 171 - display: flex; 172 - flex-direction: column; 173 - align-items: center; 174 - text-align: center; 175 - gap: 8px; 176 - } 177 - 178 - .avatar-wrapper { 179 - width: clamp(80px, 15vw, 120px); 180 - margin-bottom: 8px; 181 - } 182 - 183 - .avatar { 184 - width: 100%; 185 - display: block; 186 - aspect-ratio: 1; 187 - object-fit: cover; 188 - border-radius: 0; 189 - } 190 - 191 - .display-name { 192 - font-size: clamp(1.8rem, 4vw, 2.8rem); 193 - color: #E8E0FF; 194 - margin: 0; 195 - line-height: 1.1; 196 - animation: font-switch 4s steps(1) infinite; 197 - text-shadow: 198 - -1px -1px 0 rgba(11, 14, 23, 0.8), 199 - 1px -1px 0 rgba(11, 14, 23, 0.8), 200 - -1px 1px 0 rgba(11, 14, 23, 0.8), 201 - 1px 1px 0 rgba(11, 14, 23, 0.8); 202 - } 203 - 204 - .handle { 205 - animation: font-switch 4s steps(1) infinite; 206 - font-size: 1rem; 207 - color: #E8E0FF; 208 - opacity: 0.7; 209 - margin: 0; 210 - text-shadow: 211 - -1px -1px 0 rgba(11, 14, 23, 0.8), 212 - 1px -1px 0 rgba(11, 14, 23, 0.8), 213 - -1px 1px 0 rgba(11, 14, 23, 0.8), 214 - 1px 1px 0 rgba(11, 14, 23, 0.8); 215 - } 216 - 217 - .description { 218 - animation: font-switch 4s steps(1) infinite; 219 - font-size: 0.95rem; 220 - color: #E8E0FF; 221 - margin: 8px 0; 222 - max-width: 500px; 223 - line-height: 1.5; 224 - text-shadow: 225 - -1px -1px 0 rgba(11, 14, 23, 0.8), 226 - 1px -1px 0 rgba(11, 14, 23, 0.8), 227 - -1px 1px 0 rgba(11, 14, 23, 0.8), 228 - 1px 1px 0 rgba(11, 14, 23, 0.8); 229 - } 230 - 231 - .stats { 232 - display: flex; 233 - gap: clamp(16px, 4vw, 32px); 234 - margin: 12px 0; 235 - } 236 - 237 - .stat { 238 - display: flex; 239 - flex-direction: column; 240 - align-items: center; 241 - } 242 - 243 - .stat-num { 244 - animation: font-switch 4s steps(1) infinite; 245 - font-size: 1.4rem; 246 - color: #E8E0FF; 247 - font-weight: bold; 248 - text-shadow: 249 - -1px -1px 0 rgba(11, 14, 23, 0.8), 250 - 1px -1px 0 rgba(11, 14, 23, 0.8), 251 - -1px 1px 0 rgba(11, 14, 23, 0.8), 252 - 1px 1px 0 rgba(11, 14, 23, 0.8); 253 - } 254 - 255 - .stat-label { 256 - animation: font-switch 4s steps(1) infinite; 257 - font-size: 0.75rem; 258 - color: #E8E0FF; 259 - opacity: 0.6; 260 - text-shadow: 261 - -1px -1px 0 rgba(11, 14, 23, 0.8), 262 - 1px -1px 0 rgba(11, 14, 23, 0.8), 263 - -1px 1px 0 rgba(11, 14, 23, 0.8), 264 - 1px 1px 0 rgba(11, 14, 23, 0.8); 265 - } 266 - 267 - .profile-link { 268 - animation: font-switch 4s steps(1) infinite; 269 - font-size: 1rem; 270 - color: #E8E0FF; 271 - text-decoration: underline; 272 - text-decoration-color: #FF6B2C; 273 - margin-top: 8px; 274 - transition: color 0.15s; 275 - text-shadow: 276 - -1px -1px 0 rgba(11, 14, 23, 0.8), 277 - 1px -1px 0 rgba(11, 14, 23, 0.8), 278 - -1px 1px 0 rgba(11, 14, 23, 0.8), 279 - 1px 1px 0 rgba(11, 14, 23, 0.8); 280 - } 281 - 282 - .profile-link:hover { 283 - color: #FF6B2C; 284 - } 285 - 286 - .back-link { 287 - text-align: center; 288 - padding: 30px; 289 - } 290 - 291 - .back-link a { 292 - animation: font-switch 4s steps(1) infinite; 293 - font-size: 1.1rem; 294 - color: #E8E0FF; 295 - text-decoration: underline; 296 - text-decoration-color: #FF6B2C; 297 - transition: color 0.15s; 298 - text-shadow: 299 - -1px -1px 0 rgba(11, 14, 23, 0.8), 300 - 1px -1px 0 rgba(11, 14, 23, 0.8), 301 - -1px 1px 0 rgba(11, 14, 23, 0.8), 302 - 1px 1px 0 rgba(11, 14, 23, 0.8); 303 - } 304 - 305 - .back-link a:hover { 306 - color: #FF6B2C; 307 - } 308 - </style>
-2
src/routes/profile/[handle]/+page.ts
··· 1 - export const prerender = false; 2 - export const ssr = false;
static/sprites/earl/idle-1.png

This is a binary file and will not be displayed.

static/sprites/earl/idle-2.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-down-1.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-down-2.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-down-3.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-down-4.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-left-1.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-left-2.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-left-3.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-left-4.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-right-1.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-right-2.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-right-3.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-right-4.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-up-1.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-up-2.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-up-3.png

This is a binary file and will not be displayed.

static/sprites/earl/walk-up-4.png

This is a binary file and will not be displayed.

static/sprites/present/closed-blue.png

This is a binary file and will not be displayed.

static/sprites/present/closed-green.png

This is a binary file and will not be displayed.

static/sprites/present/closed.png

This is a binary file and will not be displayed.

static/sprites/scenery/bush-1.png

This is a binary file and will not be displayed.

static/sprites/scenery/tree-1.png

This is a binary file and will not be displayed.

static/sprites/ship/ship.png

This is a binary file and will not be displayed.

static/sprites/terrain/grass-tile.png

This is a binary file and will not be displayed.