diff --git a/app/components/ui/icons.tsx b/app/components/ui/icons.tsx index d990b19..abb57d8 100644 --- a/app/components/ui/icons.tsx +++ b/app/components/ui/icons.tsx @@ -110,6 +110,19 @@ export const COPY_SQUARES_PATH_40 = export const COPY_SQUARES_MORPH_ICON = fitIcon(COPY_SQUARES_PATH_40, 40); export const COPY_SQUARES_MORPH_STROKE_WIDTH = 2.5 * (24 / 40); +// X (Twitter) logo, copied from BDS `packages/icons/src/svg/2/x-twitter.svg` +// (40x40 grid, glyph inset like the rest of the BDS set). +export function XTwitterIcon({ size = 20, className }: { size?: number; className?: string }) { + return ( + + ); +} + export function CheckIcon({ size = 16, className }: { size?: number; className?: string }) { return ( ([]); - const [chainBest, setChainBest] = useState(0); - const [myAddress, setMyAddress] = useState(null); - const [submitState, setSubmitState] = useState<'' | 'funding' | 'submitting' | 'confirming' | 'done' | 'error'>(''); const stageRef = useRef(null); + // Where the canvas actually draws inside the stage (it letterboxes with + // object-contain), so DOM overlays can sit in game space and scale with it. + const [canvasBox, setCanvasBox] = useState<{ x: number; y: number; scale: number } | null>(null); const [score, setScore] = useState(0); const [best, setBest] = useState(0); const [phase, setPhase] = useState('ready'); @@ -765,48 +757,6 @@ export function BlockRunner() { }; }, [apply, setFeed]); - // Onchain high scores: poll the board, know our own best. - useEffect(() => { - let cancelled = false; - const wallet = scoreWallet(); - // Deferred so the effect body itself does not set state synchronously. - const t = window.setTimeout(() => setMyAddress(wallet.address), 0); - const load = async () => { - try { - const [entries, mine] = await Promise.all([fetchBoard(), fetchBest(wallet.address)]); - if (!cancelled) { - setBoard(entries); - setChainBest(mine); - } - } catch { - /* Board is decoration; the next poll retries (or the chain regenesised). */ - } - }; - void load(); - const id = window.setInterval(() => { - if (!document.hidden) void load(); - }, 12_000); - return () => { - cancelled = true; - window.clearTimeout(t); - window.clearInterval(id); - }; - }, []); - - const postScore = useCallback(async () => { - const s = gameRef.current.score; - if (s <= 0 || submitState === 'funding' || submitState === 'submitting' || submitState === 'confirming') return; - setSubmitState('funding'); - try { - await submitScore(s, (st) => setSubmitState(st)); - setSubmitState('done'); - setChainBest((b) => Math.max(b, s)); - setBoard(await fetchBoard()); - } catch { - setSubmitState('error'); - } - }, [submitState]); - const shareOnX = useCallback(() => { trackBlockRunnerShare(shareVariant, score); window.open(xShareUrl(shareText(score, shareVariant)), '_blank', 'noopener,noreferrer'); @@ -821,6 +771,28 @@ export function BlockRunner() { else if (stage.requestFullscreen) void stage.requestFullscreen(); }, []); + useEffect(() => { + const canvas = canvasRef.current; + const stage = stageRef.current; + if (!canvas || !stage) return; + const measure = () => { + const c = canvas.getBoundingClientRect(); + const st = stage.getBoundingClientRect(); + const scale = Math.min(c.width / WIDTH, c.height / HEIGHT); + if (!(scale > 0)) return; + setCanvasBox({ + x: c.left - st.left + (c.width - WIDTH * scale) / 2, + y: c.top - st.top + (c.height - HEIGHT * scale) / 2, + scale, + }); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(canvas); + ro.observe(stage); + return () => ro.disconnect(); + }, [immersive, isFullscreen]); + useEffect(() => { const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); document.addEventListener('fullscreenchange', onChange); @@ -830,7 +802,6 @@ export function BlockRunner() { // Input. const doTap = useCallback(() => apply(tap(gameRef.current)), [apply]); const doRestart = useCallback(() => { - setSubmitState((st) => (st === 'done' || st === 'error' ? '' : st)); apply(restart(gameRef.current)); }, [apply]); @@ -1024,9 +995,6 @@ export function BlockRunner() { aria-label={`Block Runner. ${phase === 'running' ? `Score ${score}.` : 'Tap space or the screen to start eating.'}`} role="img" /> - {/* Once a submit starts, the board's own poll can raise chainBest past - the score mid-flight — keep the prompt mounted on submitState so the - progress and the success line survive their own success. */}
Latest block @@ -1041,40 +1009,34 @@ export function BlockRunner() {
- {phase === 'dead' && score > 0 ? ( -
-
- - {score <= chainBest && submitState === '' ? null : submitState === 'done' ? ( - - On the board — tap the game to run again. - - ) : ( - <> - - New onchain best! - - - - )} -
-
+ ); + })() ) : null} {immersive && !isFullscreen ? (
@@ -1099,89 +1061,6 @@ export function BlockRunner() { ) : null}
-
-
- Onchain high scores -
- {/* Once a submit starts, the board's own poll can raise chainBest past - the score mid-flight — keep the prompt mounted on submitState so the - progress and the success line survive their own success. */} -
-
- Latest block -
- {head ? head.number.toLocaleString() : '———'} - {slot ? {slot} : null} -
-
-
- - - -
-
- {phase === 'dead' && score > 0 && (score > chainBest || submitState !== '') ? ( - - ) : null} - {submitState === 'error' ? ( - - Submission failed — try again. - - ) : null} -
-
-
- {board.length === 0 ? ( - - No scores onchain yet — die proudly and post yours. - - ) : ( - - )} -
- - Your browser plays as{' '} - {myAddress ? {shortAddr(myAddress as `0x${string}`)} : '…'} and the - faucet funds its first submission. - -
-
How to play @@ -1203,7 +1082,6 @@ export function BlockRunner() { 'Block height follows gas used.', `Swallow a block to read its number and slot${head ? `, like ${blockLabel(head)}` : ''}.`, 'The beat runs at one note per block.', - 'Scores live in a contract on vibenet, keyed by the wallet that posted them. A devnet regenesis wipes the board.', ]} />
diff --git a/app/vibenet/demos/200/contract/BlockRunnerScores.json b/app/vibenet/demos/200/contract/BlockRunnerScores.json deleted file mode 100644 index 1457b03..0000000 --- a/app/vibenet/demos/200/contract/BlockRunnerScores.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "abi": [ - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "address", - "name": "player", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint96", - "name": "score", - "type": "uint96" - } - ], - "name": "NewScore", - "type": "event" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "best", - "outputs": [ - { - "internalType": "uint96", - "name": "", - "type": "uint96" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "name": "board", - "outputs": [ - { - "internalType": "address", - "name": "player", - "type": "address" - }, - { - "internalType": "uint96", - "name": "score", - "type": "uint96" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "uint96", - "name": "score", - "type": "uint96" - } - ], - "name": "submit", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "top", - "outputs": [ - { - "components": [ - { - "internalType": "address", - "name": "player", - "type": "address" - }, - { - "internalType": "uint96", - "name": "score", - "type": "uint96" - } - ], - "internalType": "struct BlockRunnerScores.Entry[10]", - "name": "", - "type": "tuple[10]" - } - ], - "stateMutability": "view", - "type": "function" - } - ], - "bytecode": "0x6080604052348015600e575f5ffd5b506106608061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061004a575f3560e01c806312ca564f1461004e57806328ed6e3f14610063578063d53bb092146100a2578063fe6dcdba146100e2575b5f5ffd5b61006161005c36600461051b565b6100f7565b005b610076610071366004610548565b610446565b604080516001600160a01b0390931683526001600160601b039091166020830152015b60405180910390f35b6100ca6100b036600461055f565b600a6020525f90815260409020546001600160601b031681565b6040516001600160601b039091168152602001610099565b6100ea610475565b6040516100999190610585565b5f816001600160601b0316116101415760405162461bcd60e51b815260206004820152600a6024820152697a65726f2073636f726560b01b60448201526064015b60405180910390fd5b335f908152600a60205260409020546001600160601b039081169082161161019b5760405162461bcd60e51b815260206004820152600d60248201526c1b9bdd081e5bdd5c8818995cdd609a1b6044820152606401610138565b335f818152600a602090815260409182902080546bffffffffffffffffffffffff19166001600160601b03861690811790915591519182527f41962b14da4c981f83dac8e262111e9ad37614600f0ea9f119b03af61c544162910160405180910390a2600a5f5b600a81101561023e57335f82600a811061021e5761021e6105d4565b01546001600160a01b0316036102365780915061023e565b600101610202565b5080600a03610284576009546001600160a01b0316158015906102775750600954600160a01b90046001600160601b0390811690831611155b15610280575050565b5060095b604080518082019091523381526001600160601b03831660208201525f82600a81106102b2576102b26105d4565b82516020909301516001600160601b0316600160a01b026001600160a01b03909316929092179101555b5f8111801561033a57505f6102f26001836105fc565b600a8110610302576103026105d4565b0154600160a01b90046001600160601b03165f82600a8110610326576103266105d4565b0154600160a01b90046001600160601b0316115b15610442575f8061034c6001846105fc565b600a811061035c5761035c6105d4565b604080518082019091529101546001600160a01b0381168252600160a01b90046001600160601b0316602082015290505f82600a811061039e5761039e6105d4565b015f6103ab6001856105fc565b600a81106103bb576103bb6105d4565b8254910180546001600160a01b0319166001600160a01b03909216918217815591546001600160601b03600160a01b918290041602179055805f83600a8110610406576104066105d4565b82516020909301516001600160601b0316600160a01b026001600160a01b03909316929092179101558161043981610615565b925050506102dc565b5050565b5f81600a8110610454575f80fd5b01546001600160a01b0381169150600160a01b90046001600160601b031682565b61047d6104e2565b6040805161014081019091525f600a81835b828210156104d95760408051808201909152848301546001600160a01b0381168252600160a01b90046001600160601b03166020808301919091529082526001909201910161048f565b50505050905090565b604051806101400160405280600a905b604080518082019091525f80825260208201528152602001906001900390816104f25790505090565b5f6020828403121561052b575f5ffd5b81356001600160601b0381168114610541575f5ffd5b9392505050565b5f60208284031215610558575f5ffd5b5035919050565b5f6020828403121561056f575f5ffd5b81356001600160a01b0381168114610541575f5ffd5b610280810181835f5b600a8110156105cb57815180516001600160a01b031684526020908101516001600160601b0316818501526040909301929091019060010161058e565b50505092915050565b634e487b7160e01b5f52603260045260245ffd5b634e487b7160e01b5f52601160045260245ffd5b8181038181111561060f5761060f6105e8565b92915050565b5f81610623576106236105e8565b505f19019056fea264697066735822122065c9501d2b15d374c83f39a8c53052366e0254c856e9a850d97efc826538ddcc64736f6c634300081c0033", - "solc": "0.8.28", - "optimizer": 200 -} diff --git a/app/vibenet/demos/200/contract/BlockRunnerScores.sol b/app/vibenet/demos/200/contract/BlockRunnerScores.sol deleted file mode 100644 index bfe512d..0000000 --- a/app/vibenet/demos/200/contract/BlockRunnerScores.sol +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -/// Block Runner's onchain high-score board (vibenet devnet). -/// No names — a score belongs to the wallet that submitted it, and only a -/// wallet's best is kept. The top ten live in a fixed array, sorted on write -/// so reading is one call. NOTE: vibenet is regenesised periodically; when -/// that happens, redeploy with deploy.mjs and update the address constant in -/// ../lib/leaderboard.ts. -contract BlockRunnerScores { - struct Entry { - address player; - uint96 score; - } - - Entry[10] public board; - mapping(address => uint96) public best; - - event NewScore(address indexed player, uint96 score); - - function submit(uint96 score) external { - require(score > 0, "zero score"); - require(score > best[msg.sender], "not your best"); - best[msg.sender] = score; - emit NewScore(msg.sender, score); - - // Find the sender's existing slot, or take the last one if they beat it. - uint256 idx = 10; - for (uint256 i = 0; i < 10; i++) { - if (board[i].player == msg.sender) { - idx = i; - break; - } - } - if (idx == 10) { - if (board[9].player != address(0) && score <= board[9].score) return; - idx = 9; - } - board[idx] = Entry(msg.sender, score); - while (idx > 0 && board[idx].score > board[idx - 1].score) { - Entry memory t = board[idx - 1]; - board[idx - 1] = board[idx]; - board[idx] = t; - idx--; - } - } - - function top() external view returns (Entry[10] memory) { - return board; - } -} diff --git a/app/vibenet/demos/200/contract/deploy.mjs b/app/vibenet/demos/200/contract/deploy.mjs deleted file mode 100644 index e236c82..0000000 --- a/app/vibenet/demos/200/contract/deploy.mjs +++ /dev/null @@ -1,31 +0,0 @@ -// Deploy BlockRunnerScores to vibenet and print the address. -// -// node app/vibenet/demos/200/contract/deploy.mjs -// -// Uses DEPLOYER_PK if set; otherwise generates a throwaway key and funds it -// from the vibenet faucet. After a vibenet regenesis, run this again and put -// the printed address into app/vibenet/demos/200/lib/leaderboard.ts. -import { readFileSync } from 'node:fs'; -import { createPublicClient, createWalletClient, http } from 'viem'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; - -const RPC = process.env.VIBENET_RPC_URL || 'https://rpc.vibes.base.org'; -const FAUCET = 'https://api.vibes.base.org/api/vibenet/faucet/drip'; -const chain = { id: 84538453, name: 'Vibenet', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [RPC] } } }; -const artifact = JSON.parse(readFileSync(new URL('./BlockRunnerScores.json', import.meta.url), 'utf8')); - -const pk = process.env.DEPLOYER_PK ?? generatePrivateKey(); -const account = privateKeyToAccount(pk); -const pub = createPublicClient({ chain, transport: http(RPC) }); -const wallet = createWalletClient({ account, chain, transport: http(RPC) }); - -if ((await pub.getBalance({ address: account.address })) === 0n) { - console.log('funding deployer', account.address, 'from faucet…'); - const res = await fetch(FAUCET, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ address: account.address }) }); - if (!res.ok) throw new Error(`faucet: ${res.status} ${await res.text()}`); - while ((await pub.getBalance({ address: account.address })) === 0n) await new Promise((r) => setTimeout(r, 500)); -} - -const hash = await wallet.deployContract({ abi: artifact.abi, bytecode: artifact.bytecode }); -const receipt = await pub.waitForTransactionReceipt({ hash, pollingInterval: 200 }); -console.log('BlockRunnerScores deployed at:', receipt.contractAddress); diff --git a/app/vibenet/demos/200/lib/leaderboard.test.ts b/app/vibenet/demos/200/lib/leaderboard.test.ts deleted file mode 100644 index 4d7641f..0000000 --- a/app/vibenet/demos/200/lib/leaderboard.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { parseBoard, shortAddr } from './leaderboard'; - -const A = '0x1111111111111111111111111111111111111111' as const; -const B = '0x2222222222222222222222222222222222222222' as const; -const ZERO = '0x0000000000000000000000000000000000000000' as const; - -describe('shortAddr', () => { - it('keeps only the ends of the address — that IS the name on the board', () => { - expect(shortAddr('0x24411613aab0b4f551942f50af090941bd57e07d')).toBe('0x2441…e07d'); - }); -}); - -describe('parseBoard', () => { - it('drops empty slots from the fixed-size board', () => { - const board = parseBoard([ - { player: A, score: 42n }, - { player: B, score: 7n }, - ...Array.from({ length: 8 }, () => ({ player: ZERO, score: 0n })), - ]); - expect(board).toEqual([ - { player: A, score: 42 }, - { player: B, score: 7 }, - ]); - }); -}); diff --git a/app/vibenet/demos/200/lib/leaderboard.ts b/app/vibenet/demos/200/lib/leaderboard.ts deleted file mode 100644 index 41cb655..0000000 --- a/app/vibenet/demos/200/lib/leaderboard.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Block Runner's onchain high-score board. No names: a score belongs to the -// wallet address that submitted it, and only your best is kept. Each browser -// gets a throwaway devnet key (localStorage); the first submission funds it -// from the vibenet faucet. The contract keeps the top ten sorted, so reading -// is one call. -// -// Vibenet is regenesised periodically. When the board vanishes, redeploy with -// contract/deploy.mjs and update SCORES_ADDRESS below. -import { createPublicClient, createWalletClient, http, type Address, type Hex } from 'viem'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; - -import { VIBENET_RPC_URL } from '../../../library/config'; -import { vibenetApi } from '../../../library/client'; -import artifact from '../contract/BlockRunnerScores.json'; - -export const SCORES_ADDRESS: Address = '0x24411613aab0b4f551942f50af090941bd57e07d'; - -const KEY_STORAGE = 'block-runner:pk'; - -const chain = { - id: 84538453, - name: 'Vibenet', - nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - rpcUrls: { default: { http: [VIBENET_RPC_URL] } }, -} as const; - -export type BoardEntry = { player: Address; score: number }; - -const ZERO: Address = '0x0000000000000000000000000000000000000000'; - -/** Drop empty slots and normalize the fixed-size board the contract returns. */ -export function parseBoard(raw: readonly { player: Address; score: bigint }[]): BoardEntry[] { - return raw - .filter((e) => e.player !== ZERO && e.score > 0n) - .map((e) => ({ player: e.player, score: Number(e.score) })); -} - -/** `0x1234…abcd` — the whole identity on the board. */ -export function shortAddr(a: Address): string { - return `${a.slice(0, 6)}…${a.slice(-4)}`; -} - -function pub() { - return createPublicClient({ chain, transport: http(VIBENET_RPC_URL) }); -} - -/** The browser's throwaway score wallet (created on first use). */ -export function scoreWallet(): { address: Address; pk: Hex } { - let pk: Hex | null = null; - try { - pk = window.localStorage.getItem(KEY_STORAGE) as Hex | null; - } catch { - pk = null; - } - if (!pk || !/^0x[0-9a-fA-F]{64}$/.test(pk)) { - pk = generatePrivateKey(); - try { - window.localStorage.setItem(KEY_STORAGE, pk); - } catch { - /* Ephemeral key: scores just will not persist across reloads. */ - } - } - return { address: privateKeyToAccount(pk).address, pk }; -} - -export async function fetchBoard(): Promise { - const raw = (await pub().readContract({ - address: SCORES_ADDRESS, - abi: artifact.abi, - functionName: 'top', - })) as readonly { player: Address; score: bigint }[]; - return parseBoard(raw); -} - -export async function fetchBest(address: Address): Promise { - const raw = (await pub().readContract({ - address: SCORES_ADDRESS, - abi: artifact.abi, - functionName: 'best', - args: [address], - })) as bigint; - return Number(raw); -} - -/** - * Submit a score from the browser's throwaway wallet. Funds it from the - * faucet on first use. Resolves to the tx hash once mined. - */ -export async function submitScore(score: number, onStatus?: (s: 'funding' | 'submitting' | 'confirming') => void): Promise { - const { pk } = scoreWallet(); - const account = privateKeyToAccount(pk); - const client = pub(); - if ((await client.getBalance({ address: account.address })) === 0n) { - onStatus?.('funding'); - await vibenetApi.faucet.drip({ address: account.address }); - const until = Date.now() + 20_000; - while ((await client.getBalance({ address: account.address })) === 0n) { - if (Date.now() > until) throw new Error('The faucet did not fund the score wallet in time.'); - await new Promise((r) => setTimeout(r, 500)); - } - } - onStatus?.('submitting'); - const wallet = createWalletClient({ account, chain, transport: http(VIBENET_RPC_URL) }); - const hash = await wallet.writeContract({ - address: SCORES_ADDRESS, - abi: artifact.abi, - functionName: 'submit', - args: [BigInt(score)], - }); - onStatus?.('confirming'); - // Replacement detection would cost an extra round trip per poll; a fresh - // throwaway key never replaces anything. - await client.waitForTransactionReceipt({ hash, pollingInterval: 200, timeout: 30_000, checkReplacement: false }); - return hash; -}